diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 3b35bb88d82..e423137fca0 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -25,6 +25,7 @@ import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" const IMAGE_MIMES = new Set(ProviderShared.IMAGE_MIMES) +const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"]) export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/chat/completions" @@ -70,15 +71,18 @@ const OpenAIChatMessage = Schema.Union([ role: Schema.Literal("user"), content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]), }), - Schema.Struct({ - role: Schema.Literal("assistant"), - content: Schema.NullOr(Schema.String), - tool_calls: optionalArray(OpenAIChatAssistantToolCall), - reasoning_content: Schema.optional(Schema.String), - reasoning: Schema.optional(Schema.String), - reasoning_text: Schema.optional(Schema.String), - reasoning_details: optionalArray(Schema.Unknown), - }), + Schema.StructWithRest( + Schema.Struct({ + role: Schema.Literal("assistant"), + content: Schema.NullOr(Schema.String), + tool_calls: optionalArray(OpenAIChatAssistantToolCall), + reasoning_content: Schema.optional(Schema.String), + reasoning: Schema.optional(Schema.String), + reasoning_text: Schema.optional(Schema.String), + reasoning_details: Schema.optional(Schema.Unknown), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }), ]).pipe(Schema.toTaggedUnion("role")) type OpenAIChatMessage = Schema.Schema.Type @@ -145,14 +149,17 @@ const OpenAIChatToolCallDelta = Schema.Struct({ }) type OpenAIChatToolCallDelta = Schema.Schema.Type -const OpenAIChatDelta = Schema.Struct({ - content: optionalNull(Schema.String), - reasoning_content: optionalNull(Schema.String), - reasoning: optionalNull(Schema.String), - reasoning_text: optionalNull(Schema.String), - reasoning_details: optionalNull(Schema.Array(Schema.Unknown)), - tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), -}) +const OpenAIChatDelta = Schema.StructWithRest( + Schema.Struct({ + content: optionalNull(Schema.String), + reasoning_content: optionalNull(Schema.String), + reasoning: optionalNull(Schema.String), + reasoning_text: optionalNull(Schema.String), + reasoning_details: optionalNull(Schema.Unknown), + tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)), + }), + [Schema.Record(Schema.String, Schema.Unknown)], +) const OpenAIChatChoice = Schema.Struct({ delta: optionalNull(OpenAIChatDelta), @@ -179,7 +186,7 @@ export interface ParserState { readonly usage?: Usage readonly finishReason?: FinishReason readonly lifecycle: Lifecycle.State - readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text" + readonly reasoningField?: string readonly reasoningDetails: Array readonly reasoningDetailsObserved: boolean readonly reasoningEmitted: boolean @@ -227,7 +234,7 @@ const openAICompatibleReasoningContent = (native: unknown) => const reasoningField = (part: ReasoningPart) => { const field = part.providerMetadata?.openai?.reasoningField - if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field + return typeof field === "string" ? field : undefined } const reasoningDetails = (parts: ReadonlyArray, native: unknown) => { @@ -259,6 +266,7 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (mes const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* ( message: OpenAIChatRequestMessage, + configuredField?: string, ) { const content: TextPart[] = [] const reasoning: ReasoningPart[] = [] @@ -285,24 +293,25 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible) const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails)) const field = (() => { - if (reasoning.length === 0) return + if (configuredField !== undefined) return configuredField + if (reasoning.length === 0) return undefined if (observedField !== undefined) return observedField if (nativeReasoning !== undefined) return "reasoning_content" if (!fullyStructured) return "reasoning_content" })() - const reasoningContent = (() => { + const reasoningText = (() => { + if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text if (reasoning.length === 0) return nativeReasoning - if (field === "reasoning_content") return text + return text })() - return { + const result = { role: "assistant" as const, content: content.length === 0 ? null : ProviderShared.joinText(content), tool_calls: toolCalls.length === 0 ? undefined : toolCalls, - reasoning_content: reasoningContent, - reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined, - reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined, reasoning_details: details, } + if (field === undefined || reasoningText === undefined) return result + return { ...result, [field]: reasoningText } }) const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) { @@ -328,9 +337,12 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m return { messages, images } }) -const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) { +const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* ( + message: OpenAIChatRequestMessage, + reasoningField?: string, +) { if (message.role === "user") return [yield* lowerUserMessage(message)] - if (message.role === "assistant") return [yield* lowerAssistantMessage(message)] + if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)] return (yield* lowerToolMessages(message)).messages }) @@ -368,7 +380,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: continue } flushImages() - messages.push(...(yield* lowerMessage(message))) + messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField))) } flushImages() return messages @@ -386,6 +398,11 @@ const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LL const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) { // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. + const reasoningField = request.model.compatibility?.reasoningField + if (reasoningField && RESERVED_REASONING_FIELDS.has(reasoningField)) + return yield* ProviderShared.invalidRequest( + `OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`, + ) const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { @@ -446,10 +463,18 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { }) } -const reasoningDelta = (delta: Schema.Schema.Type | null | undefined) => { - if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const - if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const - if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const +const reasoningDelta = ( + delta: Schema.Schema.Type | null | undefined, + configuredField?: string, +) => { + if (!delta) return undefined + const fields = new Set([configuredField, "reasoning_content", "reasoning", "reasoning_text"]) + for (const field of fields) { + if (field === undefined) continue + const text = delta[field] + if (typeof text === "string" && text.length > 0) return { field, text } + } + return undefined } const detailText = (details: ReadonlyArray) => { @@ -518,7 +543,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => let lifecycle = state.lifecycle - const reasoning = reasoningDelta(delta) + const reasoning = reasoningDelta(delta, state.reasoningField) const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined) const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta) @@ -635,12 +660,12 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(OpenAIChatEvent), - initial: () => ({ + initial: (request) => ({ tools: ToolStream.empty(), pendingTools: {}, toolCallEvents: [], lifecycle: Lifecycle.initial(), - reasoningField: undefined, + reasoningField: request.model.compatibility?.reasoningField, reasoningDetails: [], reasoningDetailsObserved: false, reasoningEmitted: false, diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 6d11333b536..b7ef9325278 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -168,6 +168,7 @@ export type ModelToolSchemaCompatibility = Schema.Schema.Type("LLM.ModelCompatibility")({ toolSchema: Schema.optional(ModelToolSchemaCompatibility), + reasoningField: Schema.optional(Schema.String), }) {} export namespace ModelCompatibility { diff --git a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts index 5ac810db616..a8b40346cf2 100644 --- a/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts +++ b/packages/ai/test/provider/openai-chat-reasoning.recorded.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" -import { LLM, LLMEvent, LLMResponse } from "../../src" +import { LLM, LLMEvent, LLMResponse, Model } from "../../src" import { OpenAIChat } from "../../src/protocols/openai-chat" import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenRouter from "../../src/providers/openrouter" @@ -11,22 +11,28 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop const cases = [ { name: "OpenRouter", - model: OpenRouter.configure({ - apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", - providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, - }).model("anthropic/claude-sonnet-4.6"), + model: Model.update( + OpenRouter.configure({ + apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", + providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, + }).model("anthropic/claude-sonnet-4.6"), + { compatibility: { reasoningField: "reasoning" } }, + ), requires: ["OPENROUTER_API_KEY"], cassette: "openrouter-reasoning", structured: true, }, { name: "Vercel AI Gateway", - model: OpenAICompatible.configure({ - provider: "vercel-ai-gateway", - baseURL: "https://ai-gateway.vercel.sh/v1", - apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture", - http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } }, - }).model("anthropic/claude-sonnet-4.6"), + model: Model.update( + OpenAICompatible.configure({ + provider: "vercel-ai-gateway", + baseURL: "https://ai-gateway.vercel.sh/v1", + apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture", + http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } }, + }).model("anthropic/claude-sonnet-4.6"), + { compatibility: { reasoningField: "reasoning" } }, + ), requires: ["AI_GATEWAY_API_KEY"], cassette: "vercel-ai-gateway-reasoning", structured: true, diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 591e5f0be0d..50bc34df15f 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -92,6 +92,45 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("writes reasoning to a configured custom field on every assistant message", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), + messages: [ + Message.assistant([ + { + type: "reasoning", + text: "thinking", + providerMetadata: { openai: { reasoningField: "reasoning" } }, + }, + { type: "text", text: "Hello" }, + ]), + Message.assistant("Done"), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "assistant", content: "Hello", vendor_reasoning: "thinking" }, + { role: "assistant", content: "Done", vendor_reasoning: "" }, + ]) + }), + ) + + it.effect("rejects reasoning fields that conflict with assistant message fields", () => + Effect.gen(function* () { + const error = yield* LLMClient.prepare( + LLM.request({ + model: Model.update(model, { compatibility: { reasoningField: "content" } }), + messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], + }), + ).pipe(Effect.flip) + + expect(error.message).toContain("reserved field content") + }), + ) + it.effect("maps OpenAI provider options to Chat options", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -570,6 +609,35 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("parses and replays a configured custom reasoning field", () => + Effect.gen(function* () { + const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }) + const response = yield* LLMClient.generate(LLM.updateRequest(request, { model: custom })).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { choices: [{ delta: { vendor_reasoning: "thinking" } }] }, + { choices: [{ delta: { content: "Hello" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ), + ), + ), + ) + + expect(response.reasoning).toBe("thinking") + expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({ + openai: { reasoningField: "vendor_reasoning" }, + }) + + const replay = yield* LLMClient.prepare( + LLM.request({ model: custom, messages: [response.message] }), + ) + expect(replay.body.messages).toEqual([ + { role: "assistant", content: "Hello", vendor_reasoning: "thinking" }, + ]) + }), + ) + it.effect("preserves and replays reasoning details alongside scalar reasoning", () => Effect.gen(function* () { const details = [ diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 6664c1424c2..d09aa48238d 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -163,6 +163,8 @@ export type SessionMessageProviderState7 = { [x: string]: any } export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number } +export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {}) + export type ModelCapabilities = { tools: boolean; input: Array; output: Array } export type ModelVariant = { @@ -1094,7 +1096,7 @@ export type TuiCommandExecute = { | "prompt.clear" | "prompt.submit" | "agent.cycle" - | string + | (string & {}) } } @@ -1403,6 +1405,8 @@ export type SessionToolFailed = { } } +export type ModelCompatibility = { reasoningField?: ModelReasoningField } + export type ModelCost = { tier?: { type: "context"; size: number } input: MoneyUSDPerMillionTokens @@ -1893,6 +1897,7 @@ export type ModelInfo = { providerID: string family?: string name: string + compatibility?: ModelCompatibility package?: string settings?: { [x: string]: JsonValue } headers?: { [x: string]: string } diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index d81d6250575..6d749b8526f 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -344,7 +344,12 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) { prepareTransport: (body) => Effect.succeed(body), streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions), } - return Model.make({ id: info.modelID ?? info.id, provider: info.providerID, route }) + return Model.make({ + id: info.modelID ?? info.id, + provider: info.providerID, + route, + compatibility: info.compatibility, + }) } function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly>) { diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 1bb05e0fa19..b0dceca26e3 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,7 +4,6 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { Money } from "@opencode-ai/schema/money" import { Effect, Stream } from "effect" import { Config } from "../../config" -import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" export const Plugin = define({ @@ -59,6 +58,8 @@ export const Plugin = define({ if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name if (config.modelID !== undefined) model.modelID = config.modelID + if (config.compatibility !== undefined) + model.compatibility = { ...model.compatibility, ...config.compatibility } if (config.package !== undefined) model.package = config.package if (config.settings !== undefined) model.settings = ProviderV2.mergeOverlay(model.settings, config.settings) diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index e2fa972806e..8441ef0828c 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -42,6 +42,7 @@ class Model extends Schema.Class("ConfigV2.Model")({ modelID: ModelV2.ID.pipe(Schema.optional), family: ModelV2.Family.pipe(Schema.optional), name: Schema.String.pipe(Schema.optional), + compatibility: ModelV2.Compatibility.pipe(Schema.optional), package: Schema.String.pipe(Schema.optional), ...Overlays, capabilities: ModelV2.Capabilities.pipe(Schema.optional), diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 3f264ecd9f4..e8045f11043 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -12,6 +12,12 @@ export type VariantID = typeof VariantID.Type export const Family = Model.Family export type Family = Model.Family +export const ReasoningField = Model.ReasoningField +export type ReasoningField = Model.ReasoningField + +export const Compatibility = Model.Compatibility +export type Compatibility = Model.Compatibility + export const Capabilities = Model.Capabilities export type Capabilities = Model.Capabilities @@ -25,6 +31,12 @@ export type Info = Model.Info export type MutableInfo = DeepMutable +export function compatibility(input: unknown): Compatibility | undefined { + if (typeof input === "string") return { reasoningField: input } + if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined + return typeof input.field === "string" ? { reasoningField: input.field } : undefined +} + export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { const [providerID, ...modelID] = input.split("/") return { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 6fb30113631..2db39ab124e 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -43,7 +43,7 @@ type SourceModel = { readonly reasoning_options?: readonly ReasoningOption[] readonly temperature?: boolean readonly tool_call: boolean - readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" } + readonly interleaved?: boolean | string | { readonly field: string } readonly cost?: Cost readonly limit: { readonly context: number; readonly input?: number; readonly output: number } readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] } @@ -495,6 +495,7 @@ function modelInfo( modelID: ModelV2.ID.make(model.id), providerID, name: input.name ?? model.name, + compatibility: ModelV2.compatibility(model.interleaved), family: model.family ? ModelV2.Family.make(model.family) : undefined, package: model.provider?.npm ? ProviderV2.aisdk(model.provider.npm) : undefined, settings: model.provider?.api ? { baseURL: model.provider.api } : undefined, diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 1e8c52e6fc0..e70be428142 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -134,6 +134,7 @@ export const OpencodePlugin = define - Model.update(module.model(resolved.modelID ?? resolved.id, settings), { provider: resolved.providerID }), + try: () => { + const runtime = module.model(resolved.modelID ?? resolved.id, settings) + return Model.update(runtime, { + provider: resolved.providerID, + compatibility: resolved.compatibility + ? { ...runtime.compatibility, ...resolved.compatibility } + : runtime.compatibility, + }) + }, catch: () => unsupported(resolved), }) }) @@ -302,7 +309,7 @@ const codexModel = ( account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), ), }) - .model({ id: model.modelID ?? model.id }) + .model({ id: model.modelID ?? model.id, compatibility: model.compatibility }) } const unsupported = (model: ModelV2.Info) => diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 63348e54806..6c0a342abe7 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -8,6 +8,7 @@ import { ConfigPermissionV1 } from "./permission" import { ConfigProviderV1 } from "./provider" import { ConfigProviderOptionsV1 } from "./provider-options" import { ProviderV2 } from "../../provider" +import { ModelV2 } from "../../model" const keys = new Set([ "logLevel", @@ -278,6 +279,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) { modelID: info.id, family: info.family, name: info.name, + compatibility: ModelV2.compatibility(info.interleaved), package: info.provider?.npm ? ProviderV2.aisdk(info.provider.npm) : undefined, settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings, capabilities, diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f92..37e8c58c27f 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -16,9 +16,10 @@ export const Model = Schema.Struct({ tool_call: Schema.optional(Schema.Boolean), interleaved: Schema.optional( Schema.Union([ - Schema.Literal(true), + Schema.Boolean, + Schema.String, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: Schema.String, }), ]), ), diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2daecccc6a0..64e08b5e796 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -419,6 +419,28 @@ describe("Config", () => { }), ) + it.effect("migrates v1 interleaved fields to compatibility", () => + Effect.sync(() => { + const migrated = ConfigMigrateV1.migrate({ + provider: { + custom: { + models: { + object: { interleaved: { field: "vendor_reasoning" } }, + string: { interleaved: "reasoning_text" }, + boolean: { interleaved: true }, + }, + }, + }, + }) + + expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({ + reasoningField: "vendor_reasoning", + }) + expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" }) + expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined() + }), + ) + it.effect("migrates v1 command configuration", () => Effect.sync(() => { expect( diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 0c7c85a1ca6..c3c5b9c4205 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -170,6 +170,7 @@ describe("ConfigProviderPlugin.Plugin", () => { models: { chat: { name: "First", + compatibility: { reasoningField: "vendor_reasoning" }, capabilities: { tools: true, input: ["text"], output: ["text"] }, disabled: true, limit: { context: 100, output: 50 }, @@ -251,6 +252,7 @@ describe("ConfigProviderPlugin.Plugin", () => { expect(model.id).toBe(modelID) expect(model.modelID).toBe(ModelV2.ID.make("api-chat")) expect(model.name).toBe("Last") + expect(model.compatibility).toEqual({ reasoningField: "vendor_reasoning" }) expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) expect(model.enabled).toBe(false) expect(model.limit).toEqual({ context: 100, output: 75 }) diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 0a85eac11ac..1d8bd4ae184 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, beforeEach, afterAll } from "bun:test" +import { describe, expect, beforeEach, afterAll, test } from "bun:test" import { Money } from "@opencode-ai/schema/money" import { Effect, Layer, Ref } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" @@ -15,6 +15,13 @@ import path from "path" const cacheFile = path.join(Global.Path.cache, "models.json") +test("normalizes permissive interleaved values to compatibility", () => { + expect(ModelV2.compatibility("reasoning_text")).toEqual({ reasoningField: "reasoning_text" }) + expect(ModelV2.compatibility({ field: "vendor_reasoning" })).toEqual({ reasoningField: "vendor_reasoning" }) + expect(ModelV2.compatibility(true)).toBeUndefined() + expect(ModelV2.compatibility(false)).toBeUndefined() +}) + const fixture = { acme: { id: "acme", @@ -30,6 +37,7 @@ const fixture = { reasoning: false, temperature: true, tool_call: true, + interleaved: { field: "vendor_reasoning" }, limit: { context: 128000, output: 8192 }, }, }, @@ -49,6 +57,7 @@ const fixtureSnapshot = [ modelID: ModelV2.ID.make("acme-1"), providerID: ProviderV2.ID.make("acme"), name: "Acme One", + compatibility: { reasoningField: "vendor_reasoning" }, family: undefined, package: undefined, settings: undefined, diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 291cd66cc33..38fc707918c 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -16,6 +16,7 @@ import { it } from "./lib/effect" interface ModelOptions { readonly modelID?: string + readonly compatibility?: ModelV2.Compatibility readonly settings?: ModelV2.Info["settings"] readonly headers?: ModelV2.Info["headers"] readonly body?: ModelV2.Info["body"] @@ -28,6 +29,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) => modelID: ModelV2.ID.make(options.modelID ?? "api-test-model"), providerID: ProviderV2.ID.make("test-provider"), name: "Test model", + compatibility: options.compatibility, package: packageName, settings: options.settings ?? {}, headers: options.headers ?? { "x-test": "header" }, @@ -101,6 +103,7 @@ describe("SessionRunnerModel", () => { Effect.gen(function* () { const resolved = yield* SessionRunnerModel.fromCatalogModel( model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), { + compatibility: { reasoningField: "vendor_reasoning" }, settings: { apiKey: "settings-secret", baseURL: "https://compatible.example/v1", @@ -121,6 +124,7 @@ describe("SessionRunnerModel", () => { expect(headers.authorization).toBe("Bearer settings-secret") expect(resolved.route.id).toBe("openai-compatible-chat") + expect(resolved.compatibility?.reasoningField).toBe("vendor_reasoning") expect(resolved.route.endpoint.baseURL).toBe("https://compatible.example/v1") expect(resolved.route.defaults.http?.body).toEqual({}) }), diff --git a/packages/docs/models.mdx b/packages/docs/models.mdx index d8ba1a8be3b..60fd63812ad 100644 --- a/packages/docs/models.mdx +++ b/packages/docs/models.mdx @@ -98,6 +98,28 @@ Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2 model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog. +OpenAI-compatible models that stream reasoning through a custom assistant-message field can set +`compatibility.reasoningField`: + +```jsonc title="opencode.jsonc" +{ + "providers": { + "local": { + "models": { + "reasoner": { + "compatibility": { + "reasoningField": "reasoning_content" + } + } + } + } + } +} +``` + +OpenCode recognizes `reasoning`, `reasoning_content`, and `reasoning_text`, and accepts any provider-specific string. It +reads streamed reasoning from this field and includes the field when replaying assistant messages to the model. + ### Custom variants Add a variant, or override a catalog variant with the same ID, under the model's `variants` array: diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 337593117c7..4de8068d56c 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -853,7 +853,7 @@ function structuralTypes(schemas: ReadonlyArray, mutable: boolean, r .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") .replaceAll("Schema.Json", "JsonValue") .replaceAll(/(? render(code.Type)), @@ -893,9 +893,15 @@ function structuralType(schema: Schema.Top) { } return type } - return expand(document.codes[0].Type) - .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") - .replaceAll("Schema.Json", "JsonValue") + return preserveStringSuggestions( + expand(document.codes[0].Type) + .replaceAll(/ & Brand\.Brand<"[^"]+">/g, "") + .replaceAll("Schema.Json", "JsonValue"), + ) +} + +function preserveStringSuggestions(type: string) { + return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})") } function normalizePromiseClientContent(content: string, groups: ReadonlyArray) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 0322157cbd0..7374bd54903 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -503,6 +503,19 @@ describe("HttpApiCodegen.generate", () => { expect(types).not.toContain("Brand") }) + test("preserves suggestions for open string unions in Promise wire types", () => { + const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({ + identifier: "Field", + }) + const output = emitPromise( + compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))), + ) + + expect(output.files.find((file) => file.path === "types.ts")?.content).toContain( + 'export type Field = "reasoning" | "reasoning_content" | (string & {})', + ) + }) + test("retains non-recursive references in Promise wire types", () => { const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" }) const output = emitPromise( diff --git a/packages/plugin/src/v2/effect/catalog.ts b/packages/plugin/src/v2/effect/catalog.ts index 834578dd604..2d2f6777c6b 100644 --- a/packages/plugin/src/v2/effect/catalog.ts +++ b/packages/plugin/src/v2/effect/catalog.ts @@ -1,11 +1,14 @@ import type { ModelInfo, ProviderV2Info } from "@opencode-ai/sdk/v2/types" import type { CatalogApi } from "@opencode-ai/client/effect/api" +import type { Model } from "@opencode-ai/schema/model" import type { Effect } from "effect" import type { Transform } from "./registration.js" +type CatalogModel = ModelInfo & { compatibility?: Model.Compatibility } + export interface CatalogProviderRecord { readonly provider: ProviderV2Info - readonly models: ReadonlyMap + readonly models: ReadonlyMap } export interface CatalogDraft { @@ -16,8 +19,8 @@ export interface CatalogDraft { remove(providerID: string): void } readonly model: { - get(providerID: string, modelID: string): ModelInfo | undefined - update(providerID: string, modelID: string, update: (model: ModelInfo) => void): void + get(providerID: string, modelID: string): CatalogModel | undefined + update(providerID: string, modelID: string, update: (model: CatalogModel) => void): void remove(providerID: string, modelID: string): void readonly default: { get(): { providerID: string; modelID: string } | undefined diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 623b80a60a1..81caea654fc 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -41,6 +41,17 @@ export interface Ref extends Schema.Schema.Type {} export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export type Family = typeof Family.Type +export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {}) +export const ReasoningField: Schema.Codec = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]).annotate({ identifier: "Model.ReasoningField" }) + +export interface Compatibility extends Schema.Schema.Type {} +export const Compatibility = Schema.Struct({ + reasoningField: ReasoningField.pipe(optional), +}).annotate({ identifier: "Model.Compatibility" }) + export interface Capabilities extends Schema.Schema.Type {} export const Capabilities = Schema.Struct({ tools: Schema.Boolean, @@ -75,6 +86,7 @@ export const Info = Schema.Struct({ providerID: Provider.ID, family: Family.pipe(optional), name: Schema.String, + compatibility: Compatibility.pipe(optional), package: Provider.Package.pipe(optional), ...Provider.Overlays, capabilities: Capabilities, diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts index 1dedf086981..7a11eb84241 100644 --- a/packages/schema/test/model.test.ts +++ b/packages/schema/test/model.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Schema } from "effect" import { Model } from "../src/model.js" describe("Model.Ref", () => { @@ -20,3 +21,12 @@ describe("Model.Ref", () => { expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow() }) }) + +describe("Model.ReasoningField", () => { + test("accepts suggested and custom fields", () => { + const decode = Schema.decodeUnknownSync(Model.ReasoningField) + + for (const field of ["reasoning", "reasoning_content", "reasoning_text", "vendor_reasoning"]) + expect(decode(field)).toBe(field) + }) +})