diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index c58638d28e8..6f38e76ed02 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -27,6 +27,12 @@ export namespace ModelsDev { field: z.enum(["reasoning_content", "reasoning_details"]), }) .strict(), + z + .object({ + tagName: z.string(), + startWithReasoning: z.boolean().optional(), + }) + .strict(), ]) .optional(), cost: z diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 62bc5beaa00..1c21a2d0fe2 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -439,6 +439,10 @@ export namespace Provider { z.object({ field: z.enum(["reasoning_content", "reasoning_details"]), }), + z.object({ + tagName: z.string(), + startWithReasoning: z.boolean().optional(), + }), ]), }), cost: z.object({ diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 407f7351b5b..2e9518a311c 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -77,6 +77,7 @@ export namespace ProviderTransform { if ( model.capabilities.interleaved && typeof model.capabilities.interleaved === "object" && + "field" in model.capabilities.interleaved && model.capabilities.interleaved.field === "reasoning_content" ) { return msgs.map((msg) => { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a81aa7db224..27be4868694 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,6 +1,15 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai" +import { + extractReasoningMiddleware, + streamText, + wrapLanguageModel, + type LanguageModelMiddleware, + type ModelMessage, + type StreamTextResult, + type Tool, + type ToolSet, +} from "ai" import { clone, mergeDeep, pipe } from "remeda" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" @@ -171,22 +180,38 @@ export namespace LLM { ], model: wrapLanguageModel({ model: language, - middleware: [ - { - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model) - } - return args.params - }, - }, - ], + middleware: buildMiddleware(input.model), }), experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry }, }) } + function buildMiddleware(model: Provider.Model): LanguageModelMiddleware[] { + const middleware: LanguageModelMiddleware[] = [ + { + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, model) + } + return args.params + }, + }, + ] + + const interleaved = model.capabilities.interleaved + if (typeof interleaved === "object" && "tagName" in interleaved) { + middleware.push( + extractReasoningMiddleware({ + tagName: interleaved.tagName, + startWithReasoning: interleaved.startWithReasoning, + }), + ) + } + + return middleware + } + async function resolveTools(input: Pick) { const enabled = pipe( input.agent.tools, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index c6c6924f01f..f24f82f09b2 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1807,3 +1807,90 @@ test("custom model inherits api.url from models.dev provider", async () => { }, }) }) + +test("model with interleaved tagName for think tag extraction", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "think-provider": { + name: "Think Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "think-model": { + name: "Think Model", + tool_call: true, + reasoning: true, + limit: { context: 128000, output: 65536 }, + interleaved: { + tagName: "think", + }, + }, + }, + options: { apiKey: "test" }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["think-provider"]).toBeDefined() + const model = providers["think-provider"].models["think-model"] + expect(model.capabilities.interleaved).toEqual({ tagName: "think" }) + expect(model.capabilities.reasoning).toBe(true) + }, + }) +}) + +test("model with interleaved tagName and startWithReasoning", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "reasoning-provider": { + name: "Reasoning Provider", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "reasoning-model": { + name: "Reasoning Model", + tool_call: true, + reasoning: true, + limit: { context: 128000, output: 65536 }, + interleaved: { + tagName: "think", + startWithReasoning: true, + }, + }, + }, + options: { apiKey: "test" }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const providers = await Provider.list() + expect(providers["reasoning-provider"]).toBeDefined() + const model = providers["reasoning-provider"].models["reasoning-model"] + expect(model.capabilities.interleaved).toEqual({ + tagName: "think", + startWithReasoning: true, + }) + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5c4cc69423d..d4fa6914a46 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1273,6 +1273,10 @@ export type ProviderConfig = { | { field: "reasoning_content" | "reasoning_details" } + | { + tagName: string + startWithReasoning?: boolean + } cost?: { input: number output: number @@ -1746,6 +1750,10 @@ export type Model = { | { field: "reasoning_content" | "reasoning_details" } + | { + tagName: string + startWithReasoning?: boolean + } } cost: { input: number @@ -3450,6 +3458,10 @@ export type ProviderListResponses = { | { field: "reasoning_content" | "reasoning_details" } + | { + tagName: string + startWithReasoning?: boolean + } cost?: { input: number output: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3903566b91e..1a2b94bbbe5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3098,6 +3098,19 @@ }, "required": ["field"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tagName": { + "type": "string" + }, + "startWithReasoning": { + "type": "boolean" + } + }, + "required": ["tagName"], + "additionalProperties": false } ] }, @@ -7925,6 +7938,19 @@ }, "required": ["field"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "tagName": { + "type": "string" + }, + "startWithReasoning": { + "type": "boolean" + } + }, + "required": ["tagName"], + "additionalProperties": false } ] }, @@ -9015,6 +9041,18 @@ } }, "required": ["field"] + }, + { + "type": "object", + "properties": { + "tagName": { + "type": "string" + }, + "startWithReasoning": { + "type": "boolean" + } + }, + "required": ["tagName"] } ] }