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:
Vorflux AI 2026-04-15 19:12:23 +00:00
parent e672af6b3d
commit d5cad5502c
2 changed files with 181 additions and 85 deletions

View file

@ -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
}

View file

@ -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>