mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-05-22 03:01:07 +00:00
fix: preserve prototype properties when wrapping language models for AI SDK 6 compatibility
Replace object spread with Object.create + Object.defineProperties to preserve
prototype getters (e.g. `provider`) that AI SDK v4/v5/v6 models define on their
class prototype rather than as own properties.
The spread operator `{ ...model }` only copies own enumerable properties, which
causes prototype getters like `provider` to become `undefined` on the wrapped
model. This triggers AI_UnsupportedModelVersionError at runtime with AI SDK 6.
The fix:
1. Creates the wrapper via Object.create(Object.getPrototypeOf(model)) to
inherit all prototype properties (getters, methods)
2. Copies all own property descriptors via Object.getOwnPropertyDescriptors
3. Overrides doGenerate/doStream on the instance to shadow the inherited ones
Tested with real OpenAI API calls against AI SDK v4, v5, and v6.
Closes #852
This commit is contained in:
parent
e672af6b3d
commit
d5cad5502c
2 changed files with 181 additions and 85 deletions
|
|
@ -119,96 +119,99 @@ const wrapVercelLanguageModel = <T extends LanguageModel>(
|
|||
promptTemplate: options?.promptTemplate,
|
||||
})
|
||||
|
||||
const wrappedModel = {
|
||||
...model,
|
||||
// Use Object.create to preserve prototype getters (e.g. `provider` in V3 models)
|
||||
// instead of object spread which only copies own enumerable properties.
|
||||
const wrappedModel = Object.create(
|
||||
Object.getPrototypeOf(model) ?? Object.prototype,
|
||||
) as T
|
||||
|
||||
doGenerate: async (params: LanguageModelCallOptions) => {
|
||||
try {
|
||||
const transformedParams = await transformParamsWithMemory(params, ctx)
|
||||
// Copy all own property descriptors (preserves getters/setters on the instance)
|
||||
const descriptors = Object.getOwnPropertyDescriptors(model)
|
||||
Object.defineProperties(wrappedModel, descriptors)
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3
|
||||
const result = await model.doGenerate(transformedParams as any)
|
||||
// Override doGenerate and doStream with memory-aware implementations
|
||||
wrappedModel.doGenerate = async (params: LanguageModelCallOptions) => {
|
||||
try {
|
||||
const transformedParams = await transformParamsWithMemory(params, ctx)
|
||||
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (ctx.addMemory === "always" && userMessage && userMessage.trim()) {
|
||||
const assistantResponseText = extractAssistantResponseText(
|
||||
result.content as unknown[],
|
||||
)
|
||||
saveMemoryAfterResponse(
|
||||
ctx.client,
|
||||
ctx.containerTag,
|
||||
ctx.conversationId,
|
||||
assistantResponseText,
|
||||
params,
|
||||
ctx.logger,
|
||||
ctx.apiKey,
|
||||
ctx.normalizedBaseUrl,
|
||||
)
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3
|
||||
const result = await model.doGenerate(transformedParams as any)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
ctx.logger.error("Error generating response", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
doStream: async (params: LanguageModelCallOptions) => {
|
||||
let generatedText = ""
|
||||
|
||||
try {
|
||||
const transformedParams = await transformParamsWithMemory(params, ctx)
|
||||
|
||||
const { stream, ...rest } = await model.doStream(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3
|
||||
transformedParams as any,
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (ctx.addMemory === "always" && userMessage && userMessage.trim()) {
|
||||
const assistantResponseText = extractAssistantResponseText(
|
||||
result.content as unknown[],
|
||||
)
|
||||
saveMemoryAfterResponse(
|
||||
ctx.client,
|
||||
ctx.containerTag,
|
||||
ctx.conversationId,
|
||||
assistantResponseText,
|
||||
params,
|
||||
ctx.logger,
|
||||
ctx.apiKey,
|
||||
ctx.normalizedBaseUrl,
|
||||
)
|
||||
|
||||
const transformStream = new TransformStream<
|
||||
LanguageModelStreamPart,
|
||||
LanguageModelStreamPart
|
||||
>({
|
||||
transform(chunk, controller) {
|
||||
if (chunk.type === "text-delta") {
|
||||
generatedText += chunk.delta
|
||||
}
|
||||
controller.enqueue(chunk)
|
||||
},
|
||||
flush: async () => {
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (
|
||||
ctx.addMemory === "always" &&
|
||||
userMessage &&
|
||||
userMessage.trim()
|
||||
) {
|
||||
saveMemoryAfterResponse(
|
||||
ctx.client,
|
||||
ctx.containerTag,
|
||||
ctx.conversationId,
|
||||
generatedText,
|
||||
params,
|
||||
ctx.logger,
|
||||
ctx.apiKey,
|
||||
ctx.normalizedBaseUrl,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
stream: stream.pipeThrough(transformStream),
|
||||
...rest,
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.error("Error streaming response", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
} as T
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
ctx.logger.error("Error generating response", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
wrappedModel.doStream = async (params: LanguageModelCallOptions) => {
|
||||
let generatedText = ""
|
||||
|
||||
try {
|
||||
const transformedParams = await transformParamsWithMemory(params, ctx)
|
||||
|
||||
const { stream, ...rest } = await model.doStream(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Union type compatibility between V2 and V3
|
||||
transformedParams as any,
|
||||
)
|
||||
|
||||
const transformStream = new TransformStream<
|
||||
LanguageModelStreamPart,
|
||||
LanguageModelStreamPart
|
||||
>({
|
||||
transform(chunk, controller) {
|
||||
if (chunk.type === "text-delta") {
|
||||
generatedText += chunk.delta
|
||||
}
|
||||
controller.enqueue(chunk)
|
||||
},
|
||||
flush: async () => {
|
||||
const userMessage = getLastUserMessage(params)
|
||||
if (ctx.addMemory === "always" && userMessage && userMessage.trim()) {
|
||||
saveMemoryAfterResponse(
|
||||
ctx.client,
|
||||
ctx.containerTag,
|
||||
ctx.conversationId,
|
||||
generatedText,
|
||||
params,
|
||||
ctx.logger,
|
||||
ctx.apiKey,
|
||||
ctx.normalizedBaseUrl,
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
stream: stream.pipeThrough(transformStream),
|
||||
...rest,
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.logger.error("Error streaming response", {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return wrappedModel
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
LanguageModelV2,
|
||||
LanguageModelV2CallOptions,
|
||||
LanguageModelV2Message,
|
||||
LanguageModelV3,
|
||||
} from "@ai-sdk/provider"
|
||||
import "dotenv/config"
|
||||
|
||||
|
|
@ -22,7 +23,7 @@ const TEST_CONFIG = {
|
|||
containerTag: "test-vercel-wrapper",
|
||||
}
|
||||
|
||||
// Mock language model for testing
|
||||
// Mock language model for testing (V2 - plain object)
|
||||
const createMockLanguageModel = (): LanguageModelV2 => ({
|
||||
specificationVersion: "v2",
|
||||
provider: "test-provider",
|
||||
|
|
@ -32,6 +33,31 @@ const createMockLanguageModel = (): LanguageModelV2 => ({
|
|||
doStream: vi.fn(),
|
||||
})
|
||||
|
||||
// Mock V3 language model using a class (simulates real AI SDK 6 models
|
||||
// where `provider` is a prototype getter, not an own property)
|
||||
class MockLanguageModelV3 implements LanguageModelV3 {
|
||||
readonly specificationVersion = "v3" as const
|
||||
readonly modelId: string
|
||||
supportedUrls: Record<string, RegExp[]>
|
||||
private config: { provider: string }
|
||||
|
||||
constructor(modelId: string, config: { provider: string }) {
|
||||
this.modelId = modelId
|
||||
this.config = config
|
||||
this.supportedUrls = { "image/*": [/^https?:\/\/.*$/] }
|
||||
}
|
||||
|
||||
get provider(): string {
|
||||
return this.config.provider
|
||||
}
|
||||
|
||||
doGenerate = vi.fn()
|
||||
doStream = vi.fn()
|
||||
}
|
||||
|
||||
const createMockV3LanguageModel = (): LanguageModelV3 =>
|
||||
new MockLanguageModelV3("test-v3-model", { provider: "test-v3-provider" })
|
||||
|
||||
// Mock profile API response
|
||||
const createMockProfileResponse = (
|
||||
staticMemories: string[] = [],
|
||||
|
|
@ -88,6 +114,73 @@ describe("Unit: withSupermemory", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("AI SDK 6 (V3) compatibility", () => {
|
||||
it("should preserve prototype getter properties on V3 models", () => {
|
||||
process.env.SUPERMEMORY_API_KEY = "test-key"
|
||||
|
||||
const mockModel = createMockV3LanguageModel()
|
||||
// Verify the mock has `provider` as a prototype getter, not an own property
|
||||
expect(
|
||||
Object.getOwnPropertyDescriptor(mockModel, "provider"),
|
||||
).toBeUndefined()
|
||||
expect(mockModel.provider).toBe("test-v3-provider")
|
||||
|
||||
const wrappedModel = withSupermemory(mockModel, TEST_CONFIG.containerTag)
|
||||
|
||||
expect(wrappedModel).toBeDefined()
|
||||
expect(wrappedModel.specificationVersion).toBe("v3")
|
||||
expect(wrappedModel.provider).toBe("test-v3-provider")
|
||||
expect(wrappedModel.modelId).toBe("test-v3-model")
|
||||
expect(wrappedModel.supportedUrls).toEqual({
|
||||
"image/*": [/^https?:\/\/.*$/],
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve all V3 metadata after wrapping", () => {
|
||||
process.env.SUPERMEMORY_API_KEY = "test-key"
|
||||
|
||||
const mockModel = createMockV3LanguageModel()
|
||||
const wrappedModel = withSupermemory(mockModel, TEST_CONFIG.containerTag)
|
||||
|
||||
// These are the four properties that AI SDK checks at runtime
|
||||
expect(wrappedModel.specificationVersion).toBe("v3")
|
||||
expect(wrappedModel.provider).toBe("test-v3-provider")
|
||||
expect(wrappedModel.modelId).toBe("test-v3-model")
|
||||
expect(wrappedModel.supportedUrls).toBeDefined()
|
||||
})
|
||||
|
||||
it("should still override doGenerate and doStream on V3 models", () => {
|
||||
process.env.SUPERMEMORY_API_KEY = "test-key"
|
||||
|
||||
const mockModel = createMockV3LanguageModel()
|
||||
const originalDoGenerate = mockModel.doGenerate
|
||||
const originalDoStream = mockModel.doStream
|
||||
|
||||
const wrappedModel = withSupermemory(mockModel, TEST_CONFIG.containerTag)
|
||||
|
||||
// doGenerate and doStream should be overridden (not the same reference)
|
||||
expect(wrappedModel.doGenerate).not.toBe(originalDoGenerate)
|
||||
expect(wrappedModel.doStream).not.toBe(originalDoStream)
|
||||
// But they should still be functions
|
||||
expect(typeof wrappedModel.doGenerate).toBe("function")
|
||||
expect(typeof wrappedModel.doStream).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("V2 backwards compatibility", () => {
|
||||
it("should preserve all properties on V2 plain-object models", () => {
|
||||
process.env.SUPERMEMORY_API_KEY = "test-key"
|
||||
|
||||
const mockModel = createMockLanguageModel()
|
||||
const wrappedModel = withSupermemory(mockModel, TEST_CONFIG.containerTag)
|
||||
|
||||
expect(wrappedModel.specificationVersion).toBe("v2")
|
||||
expect(wrappedModel.provider).toBe("test-provider")
|
||||
expect(wrappedModel.modelId).toBe("test-model")
|
||||
expect(wrappedModel.supportedUrls).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Memory caching", () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue