summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-01-20 16:39:00 -0600
committerAiden Cline <[email protected]>2026-01-20 16:39:00 -0600
commit021e42c0bb10a2555f78e4f296bbf5406e8a9bba (patch)
tree1679dc3635f86d277e1d7f66d82beac992c219d3
parent0c4ffec857ad9f3c788dbe1313079d86607590a7 (diff)
downloadopencode-021e42c0bb10a2555f78e4f296bbf5406e8a9bba.tar.gz
opencode-021e42c0bb10a2555f78e4f296bbf5406e8a9bba.zip
core: fix issue when switching models (mainly between providers) where past reasoning/metadata would be sent to server and cause 400 errors since they came from another account/provider
-rw-r--r--packages/opencode/src/session/compaction.ts2
-rw-r--r--packages/opencode/src/session/message-v2.ts15
-rw-r--r--packages/opencode/src/session/prompt.ts17
-rw-r--r--packages/opencode/test/session/message-v2.test.ts160
4 files changed, 164 insertions, 30 deletions
diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts
index 7f8c8e00b..fb3825302 100644
--- a/packages/opencode/src/session/compaction.ts
+++ b/packages/opencode/src/session/compaction.ts
@@ -149,7 +149,7 @@ export namespace SessionCompaction {
tools: {},
system: [],
messages: [
- ...MessageV2.toModelMessages(input.messages),
+ ...MessageV2.toModelMessages(input.messages, model),
{
role: "user",
content: [
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index c1a2fd1c9..08e6cf0bc 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -11,6 +11,7 @@ import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import { type SystemError } from "bun"
+import type { Provider } from "@/provider/provider"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
@@ -432,7 +433,7 @@ export namespace MessageV2 {
})
export type WithParts = z.infer<typeof WithParts>
- export function toModelMessages(input: WithParts[]): ModelMessage[] {
+ export function toModelMessages(input: WithParts[], model: Provider.Model): ModelMessage[] {
const result: UIMessage[] = []
for (const msg of input) {
@@ -476,6 +477,8 @@ export namespace MessageV2 {
}
if (msg.info.role === "assistant") {
+ const differentModel = `${model.providerID}/${model.api.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
+
if (
msg.info.error &&
!(
@@ -495,7 +498,7 @@ export namespace MessageV2 {
assistantMessage.parts.push({
type: "text",
text: part.text,
- providerMetadata: part.metadata,
+ ...(differentModel ? {} : { providerMetadata: part.metadata }),
})
if (part.type === "step-start")
assistantMessage.parts.push({
@@ -527,7 +530,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
output: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output,
- callProviderMetadata: part.metadata,
+ ...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
}
if (part.state.status === "error")
@@ -537,7 +540,7 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
- callProviderMetadata: part.metadata,
+ ...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
@@ -548,14 +551,14 @@ export namespace MessageV2 {
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
- callProviderMetadata: part.metadata,
+ ...(differentModel ? {} : { callProviderMetadata: part.metadata }),
})
}
if (part.type === "reasoning") {
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
- providerMetadata: part.metadata,
+ ...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index c38ea3b9e..57ef0ef5e 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -598,7 +598,7 @@ export namespace SessionPrompt {
sessionID,
system: [...(await SystemPrompt.environment()), ...(await SystemPrompt.custom())],
messages: [
- ...MessageV2.toModelMessages(sessionMessages),
+ ...MessageV2.toModelMessages(sessionMessages, model),
...(isLastStep
? [
{
@@ -1778,18 +1778,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const agent = await Agent.get("title")
if (!agent) return
+ const model = await iife(async () => {
+ if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID)
+ return (
+ (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
+ )
+ })
const result = await LLM.stream({
agent,
user: firstRealUser.info as MessageV2.User,
system: [],
small: true,
tools: {},
- model: await iife(async () => {
- if (agent.model) return await Provider.getModel(agent.model.providerID, agent.model.modelID)
- return (
- (await Provider.getSmallModel(input.providerID)) ?? (await Provider.getModel(input.providerID, input.modelID))
- )
- }),
+ model,
abort: new AbortController().signal,
sessionID: input.session.id,
retries: 2,
@@ -1800,7 +1801,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
},
...(hasOnlySubtaskParts
? [{ role: "user" as const, content: subtaskParts.map((p) => p.prompt).join("\n") }]
- : MessageV2.toModelMessages(contextMessages)),
+ : MessageV2.toModelMessages(contextMessages, model)),
],
})
const text = await result.text.catch((err) => log.error("failed to generate title", { error: err }))
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index bb667d284..b8d056433 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -1,7 +1,56 @@
import { describe, expect, test } from "bun:test"
import { MessageV2 } from "../../src/session/message-v2"
+import type { Provider } from "../../src/provider/provider"
const sessionID = "session"
+const model: Provider.Model = {
+ id: "test-model",
+ providerID: "test",
+ api: {
+ id: "test-model",
+ url: "https://example.com",
+ npm: "@ai-sdk/openai",
+ },
+ name: "Test Model",
+ capabilities: {
+ temperature: true,
+ reasoning: false,
+ attachment: false,
+ toolcall: true,
+ input: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ output: {
+ text: true,
+ audio: false,
+ image: false,
+ video: false,
+ pdf: false,
+ },
+ interleaved: false,
+ },
+ cost: {
+ input: 0,
+ output: 0,
+ cache: {
+ read: 0,
+ write: 0,
+ },
+ },
+ limit: {
+ context: 0,
+ input: 0,
+ output: 0,
+ },
+ status: "active",
+ options: {},
+ headers: {},
+ release_date: "2026-01-01",
+}
function userInfo(id: string): MessageV2.User {
return {
@@ -16,7 +65,13 @@ function userInfo(id: string): MessageV2.User {
} as unknown as MessageV2.User
}
-function assistantInfo(id: string, parentID: string, error?: MessageV2.Assistant["error"]): MessageV2.Assistant {
+function assistantInfo(
+ id: string,
+ parentID: string,
+ error?: MessageV2.Assistant["error"],
+ meta?: { providerID: string; modelID: string },
+): MessageV2.Assistant {
+ const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id }
return {
id,
sessionID,
@@ -24,8 +79,8 @@ function assistantInfo(id: string, parentID: string, error?: MessageV2.Assistant
time: { created: 0 },
error,
parentID,
- modelID: "model",
- providerID: "provider",
+ modelID: infoModel.modelID,
+ providerID: infoModel.providerID,
mode: "",
agent: "agent",
path: { cwd: "/", root: "/" },
@@ -66,7 +121,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "hello" }],
@@ -91,7 +146,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([])
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("includes synthetic text parts", () => {
@@ -122,7 +177,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "hello" }],
@@ -189,7 +244,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [
@@ -259,7 +314,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -305,6 +360,81 @@ describe("session.message-v2.toModelMessage", () => {
])
})
+ test("omits provider metadata when assistant model differs", () => {
+ const userID = "m-user"
+ const assistantID = "m-assistant"
+
+ const input: MessageV2.WithParts[] = [
+ {
+ info: userInfo(userID),
+ parts: [
+ {
+ ...basePart(userID, "u1"),
+ type: "text",
+ text: "run tool",
+ },
+ ] as MessageV2.Part[],
+ },
+ {
+ info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }),
+ parts: [
+ {
+ ...basePart(assistantID, "a1"),
+ type: "text",
+ text: "done",
+ metadata: { openai: { assistant: "meta" } },
+ },
+ {
+ ...basePart(assistantID, "a2"),
+ type: "tool",
+ callID: "call-1",
+ tool: "bash",
+ state: {
+ status: "completed",
+ input: { cmd: "ls" },
+ output: "ok",
+ title: "Bash",
+ metadata: {},
+ time: { start: 0, end: 1 },
+ },
+ metadata: { openai: { tool: "meta" } },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "run tool" }],
+ },
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "done" },
+ {
+ type: "tool-call",
+ toolCallId: "call-1",
+ toolName: "bash",
+ input: { cmd: "ls" },
+ providerExecuted: undefined,
+ },
+ ],
+ },
+ {
+ role: "tool",
+ content: [
+ {
+ type: "tool-result",
+ toolCallId: "call-1",
+ toolName: "bash",
+ output: { type: "text", value: "ok" },
+ },
+ ],
+ },
+ ])
+ })
+
test("replaces compacted tool output with placeholder", () => {
const userID = "m-user"
const assistantID = "m-assistant"
@@ -341,7 +471,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -408,7 +538,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -461,7 +591,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([])
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => {
@@ -504,7 +634,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "assistant",
content: [
@@ -540,7 +670,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([
{
role: "assistant",
content: [{ type: "text", text: "first" }],
@@ -567,7 +697,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- expect(MessageV2.toModelMessages(input)).toStrictEqual([])
+ expect(MessageV2.toModelMessages(input, model)).toStrictEqual([])
})
test("converts pending/running tool calls to error results to prevent dangling tool_use", () => {
@@ -614,7 +744,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
- const result = MessageV2.toModelMessages(input)
+ const result = MessageV2.toModelMessages(input, model)
expect(result).toStrictEqual([
{