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 - 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([ {