diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 9e2dd0ba0b5..bcb115edf41 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -999,6 +999,24 @@ export namespace Provider { opts.signal = combined } + // Strip openai itemId metadata following what codex does + // Codex uses #[serde(skip_serializing)] on id fields for all item types: + // Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall + // IDs are only re-attached for Azure with store=true + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id + } + } + opts.body = JSON.stringify(body) + } + } + return fetchFn(input, { ...opts, // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 023b00dd304..71295ba2b67 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -16,34 +16,33 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { + // Maps npm package to the key the AI SDK expects for providerOptions + function sdkKey(npm: string): string | undefined { + switch (npm) { + case "@ai-sdk/github-copilot": + case "@ai-sdk/openai": + case "@ai-sdk/azure": + return "openai" + case "@ai-sdk/amazon-bedrock": + return "bedrock" + case "@ai-sdk/anthropic": + return "anthropic" + case "@ai-sdk/google-vertex": + case "@ai-sdk/google": + return "google" + case "@ai-sdk/gateway": + return "gateway" + case "@openrouter/ai-sdk-provider": + return "openrouter" + } + return undefined + } + function normalizeMessages( msgs: ModelMessage[], model: Provider.Model, options: Record, ): ModelMessage[] { - // Strip openai itemId metadata following what codex does - if (model.api.npm === "@ai-sdk/openai" || options.store === false) { - msgs = msgs.map((msg) => { - if (msg.providerOptions) { - for (const options of Object.values(msg.providerOptions)) { - delete options["itemId"] - } - } - if (!Array.isArray(msg.content)) { - return msg - } - const content = msg.content.map((part) => { - if (part.providerOptions) { - for (const options of Object.values(part.providerOptions)) { - delete options["itemId"] - } - } - return part - }) - return { ...msg, content } as typeof msg - }) - } - // Anthropic rejects messages with empty content - filter out empty string messages // and remove empty text/reasoning parts from array content if (model.api.npm === "@ai-sdk/anthropic") { @@ -257,6 +256,28 @@ export namespace ProviderTransform { msgs = applyCaching(msgs, model.providerID) } + // Remap providerOptions keys from stored providerID to expected SDK key + const key = sdkKey(model.api.npm) + if (key && key !== model.providerID) { + const remap = (opts: Record | undefined) => { + if (!opts) return opts + if (!(model.providerID in opts)) return opts + const result = { ...opts } + result[key] = result[model.providerID] + delete result[model.providerID] + return result + } + + msgs = msgs.map((msg) => { + if (!Array.isArray(msg.content)) return { ...msg, providerOptions: remap(msg.providerOptions) } + return { + ...msg, + providerOptions: remap(msg.providerOptions), + content: msg.content.map((part) => ({ ...part, providerOptions: remap(part.providerOptions) })), + } as typeof msg + }) + } + return msgs } @@ -574,39 +595,8 @@ export namespace ProviderTransform { } export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { - switch (model.api.npm) { - case "@ai-sdk/github-copilot": - case "@ai-sdk/openai": - case "@ai-sdk/azure": - return { - ["openai" as string]: options, - } - case "@ai-sdk/amazon-bedrock": - return { - ["bedrock" as string]: options, - } - case "@ai-sdk/anthropic": - return { - ["anthropic" as string]: options, - } - case "@ai-sdk/google-vertex": - case "@ai-sdk/google": - return { - ["google" as string]: options, - } - case "@ai-sdk/gateway": - return { - ["gateway" as string]: options, - } - case "@openrouter/ai-sdk-provider": - return { - ["openrouter" as string]: options, - } - default: - return { - [model.providerID]: options, - } - } + const key = sdkKey(model.api.npm) ?? model.providerID + return { [key]: options } } export function maxOutputTokens( diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 33047b5bcb4..0f203b93022 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -649,7 +649,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( headers: {}, } as any - test("strips itemId and reasoningEncryptedContent when store=false", () => { + test("preserves itemId and reasoningEncryptedContent when store=false", () => { const msgs = [ { role: "assistant", @@ -680,11 +680,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("strips itemId and reasoningEncryptedContent when store=false even when not openai", () => { + test("preserves itemId and reasoningEncryptedContent when store=false even when not openai", () => { const zenModel = { ...openaiModel, providerID: "zen", @@ -719,11 +719,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, zenModel, { store: false }) as any[] expect(result).toHaveLength(1) - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("rs_123") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBe("msg_456") }) - test("preserves other openai options when stripping itemId", () => { + test("preserves other openai options including itemId", () => { const msgs = [ { role: "assistant", @@ -744,11 +744,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, openaiModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.openai?.otherOption).toBe("value") }) - test("strips metadata for openai package even when store is true", () => { + test("preserves metadata for openai package when store is true", () => { const msgs = [ { role: "assistant", @@ -766,13 +766,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // openai package always strips itemId regardless of store value + // openai package preserves itemId regardless of store value const result = ProviderTransform.message(msgs, openaiModel, { store: true }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata for non-openai packages when store is false", () => { + test("preserves metadata for non-openai packages when store is false", () => { const anthropicModel = { ...openaiModel, providerID: "anthropic", @@ -799,13 +799,13 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( }, ] as any[] - // store=false triggers stripping even for non-openai packages + // store=false preserves metadata for non-openai packages const result = ProviderTransform.message(msgs, anthropicModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_123") }) - test("strips metadata using providerID key when store is false", () => { + test("preserves metadata using providerID key when store is false", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -835,11 +835,11 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_123") expect(result[0].content[0].providerOptions?.opencode?.otherOption).toBe("value") }) - test("strips itemId across all providerOptions keys", () => { + test("preserves itemId across all providerOptions keys", () => { const opencodeModel = { ...openaiModel, providerID: "opencode", @@ -873,12 +873,12 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, opencodeModel, { store: false }) as any[] - expect(result[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].providerOptions?.extra?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.opencode?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.extra?.itemId).toBeUndefined() + expect(result[0].providerOptions?.openai?.itemId).toBe("msg_root") + expect(result[0].providerOptions?.opencode?.itemId).toBe("msg_opencode") + expect(result[0].providerOptions?.extra?.itemId).toBe("msg_extra") + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_openai_part") + expect(result[0].content[0].providerOptions?.opencode?.itemId).toBe("msg_opencode_part") + expect(result[0].content[0].providerOptions?.extra?.itemId).toBe("msg_extra_part") }) test("does not strip metadata for non-openai packages when store is not false", () => {