diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a99f8acff20c..8546ff6e58cc 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -6,7 +6,8 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" -import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" +import { Output, jsonSchema, streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" +import type { JSONSchema7 } from "@ai-sdk/provider" import type { LLMEvent } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" @@ -45,6 +46,13 @@ export type StreamInput = { tools: Record retries?: number toolChoice?: "auto" | "required" | "none" + // When set on a provider with native structured output (e.g. Anthropic's + // `output_format`), the request uses the AI SDK `output` option instead of a + // forced `StructuredOutput` tool. This avoids `tool_choice: "required"`, which + // reasoning providers reject alongside thinking ("Thinking may not be enabled + // when tool_choice forces tool use."). Non-native providers ignore this and + // keep the forced-tool path. + structuredOutput?: { schema: JSONSchema7 } } export type StreamRequest = StreamInput & { @@ -273,11 +281,34 @@ const live: Layer.Layer< "llm.provider": input.model.providerID, "llm.model": input.model.id, }) + + // Native structured output: route `json_schema` through the provider's own + // structured-output mode (Anthropic `output_format`) instead of a forced + // `StructuredOutput` tool. This keeps `tool_choice` untouched so the + // request coexists with thinking. Providers without native support fall + // back to the forced-tool path handled in prompt.ts (strict superset). + const useNativeStructuredOutput = + input.structuredOutput !== undefined && supportsNativeStructuredOutput(input.model) + const providerOptions = ProviderTransform.providerOptions(input.model, prepared.params.options) + if (useNativeStructuredOutput && input.model.api.npm === "@ai-sdk/anthropic") { + providerOptions.anthropic = { + ...providerOptions.anthropic, + structuredOutputMode: "outputFormat", + } + } + // Native structured output and a forced tool choice are mutually exclusive + // on reasoning providers (Anthropic rejects thinking + forced tool use). + // Defensively drop the forced choice so the two never collide. + const toolChoice = useNativeStructuredOutput && input.toolChoice === "required" ? "auto" : input.toolChoice + // Default runtime path: AI SDK owns provider execution and tool dispatch; // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor. return { type: "ai-sdk" as const, result: streamText({ + output: useNativeStructuredOutput + ? Output.object({ schema: jsonSchema(input.structuredOutput!.schema) }) + : undefined, onError(error) { bridge.fork( Effect.logError("stream error", { @@ -313,10 +344,10 @@ const live: Layer.Layer< temperature: prepared.params.temperature, topP: prepared.params.topP, topK: prepared.params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, prepared.params.options), + providerOptions, activeTools: Object.keys(prepared.tools).filter((x) => x !== "invalid"), tools: prepared.tools, - toolChoice: input.toolChoice, + toolChoice, maxOutputTokens: prepared.params.maxOutputTokens, abortSignal: input.abort, headers: prepared.headers, @@ -386,6 +417,13 @@ const live: Layer.Layer< export const hasToolCalls = LLMRequestPrep.hasToolCalls +// Providers whose AI SDK package exposes a native structured-output mode that is +// NOT a forced tool call (so it coexists with thinking). Kept conservative: only +// providers we've verified. Anything else keeps the forced-tool path untouched. +export function supportsNativeStructuredOutput(model: Provider.Model): boolean { + return model.api.npm === "@ai-sdk/anthropic" +} + export const node = LayerNode.make({ service: Service, layer: live, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..e8be46735bc9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1240,7 +1240,12 @@ const layer = Layer.effect( Effect.provideService(RuntimeFlags.Service, flags), ) - if (lastUser.format?.type === "json_schema") { + // Native structured output (e.g. Anthropic `output_format`) coexists + // with thinking, so prefer it. Only inject the forced `StructuredOutput` + // tool for providers without native support. + const useNativeStructuredOutput = + lastUser.format?.type === "json_schema" && LLM.supportsNativeStructuredOutput(model) + if (lastUser.format?.type === "json_schema" && !useNativeStructuredOutput) { tools["StructuredOutput"] = createStructuredOutputTool({ schema: lastUser.format.schema, onSuccess(output) { @@ -1268,7 +1273,10 @@ const layer = Layer.effect( ...(skills ? [skills] : []), ] const format = lastUser.format ?? { type: "text" as const } - if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + // The forced-tool path needs the system prompt to coerce the model + // into calling StructuredOutput. The native path drives the schema + // through the provider's structured-output mode instead. + if (format.type === "json_schema" && !useNativeStructuredOutput) system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) const result = yield* handle.process({ user: lastUser, agent, @@ -1282,9 +1290,30 @@ const layer = Layer.effect( ], tools, model, - toolChoice: format.type === "json_schema" ? "required" : undefined, + // Native structured output must not force tool choice — reasoning + // providers reject `tool_choice: "required"` alongside thinking. + toolChoice: format.type === "json_schema" && !useNativeStructuredOutput ? "required" : undefined, + structuredOutput: + useNativeStructuredOutput && lastUser.format?.type === "json_schema" + ? { schema: lastUser.format.schema as JSONSchema7 } + : undefined, }) + // Native path: the model returns the structured object as its text + // content. Parse it here so it flows into `message.structured`. On + // parse failure we fall through to the StructuredOutputError below, + // matching the forced-tool path's graceful degradation. + if (useNativeStructuredOutput && structured === undefined && !handle.message.error) { + const withParts = yield* sessions.messages({ sessionID }).pipe(Effect.orElseSucceed(() => [])) + const assistant = withParts.find((m) => m.info.id === handle.message.id) + const text = (assistant?.parts ?? []) + .filter((p): p is SessionV1.TextPart => p.type === "text" && !p.synthetic) + .map((p) => p.text) + .join("") + const parsed = parseStructuredOutput(text) + if (parsed !== undefined) structured = parsed + } + if (structured !== undefined) { handle.message.structured = structured handle.message.finish = handle.message.finish ?? "stop" @@ -1561,6 +1590,22 @@ export const CommandInput = Schema.Struct({ }) export type CommandInput = Schema.Schema.Type +// With native structured output the model returns the object as its text +// content. Anthropic returns bare JSON, but tolerate a stray markdown fence in +// case a provider wraps it. Returns undefined when the text isn't parseable so +// the caller can degrade to StructuredOutputError, matching the tool path. +function parseStructuredOutput(text: string): unknown { + const trimmed = text.trim() + if (!trimmed) return undefined + const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/) + const candidate = fenced ? fenced[1] : trimmed + try { + return JSON.parse(candidate) + } catch { + return undefined + } +} + /** @internal Exported for testing */ export function createStructuredOutputTool(input: { schema: Record diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index fcb536f46d91..75ffacf51a83 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -4,6 +4,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" import { tool, type ModelMessage } from "ai" +import type { JSONSchema7 } from "@ai-sdk/provider" import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" @@ -1214,6 +1215,93 @@ describe("session.llm.stream", () => { }, ) + const anthropicThinkingFixture = { providerID: "anthropic", modelID: "claude-sonnet-4-5" } + it.instance( + "uses native output_format (not forced tool_choice) for structured output on a thinking Anthropic model", + () => + Effect.gen(function* () { + const fixture = loadFixture(anthropicThinkingFixture.providerID, anthropicThinkingFixture.modelID) + // Sanity: this only matters for reasoning-capable models. + expect(fixture.model.reasoning).toBe(true) + + // Interrupt before consuming the response body — the request is captured + // on arrival, so the (unparseable) response never has to be drained. + const pending = waitStreamingRequest("/messages") + + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(anthropicThinkingFixture.providerID), + ModelV2.ID.make(fixture.model.id), + ) + const sessionID = SessionID.make("session-test-structured-thinking") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const user = { + id: MessageID.make("msg_user-structured-thinking"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + // "high" variant enables Anthropic thinking for this model. + model: { providerID: ProviderV2.ID.make(anthropicThinkingFixture.providerID), modelID: resolved.id, variant: "high" }, + } satisfies SessionV1.User + + const schema: JSONSchema7 = { + type: "object", + properties: { description: { type: "string" } }, + required: ["description"], + additionalProperties: false, + } + + const fiber = yield* drain({ + user, + sessionID, + model: resolved, + agent, + system: ["Return structured output."], + messages: [{ role: "user", content: "Describe the repo as JSON" }], + tools: {}, + // Even if the caller forces "required" (the legacy structured-output + // path), native structured output must override it — Anthropic rejects + // thinking + forced tool use. + toolChoice: "required", + structuredOutput: { schema }, + }).pipe(Effect.exit, Effect.forkScoped) + + const capture = yield* Effect.promise(() => pending.request) + yield* Fiber.interrupt(fiber) + + const body = capture.body + expect(body.model).toBe(resolved.api.id) + + // Thinking is enabled (this is the conflicting surface). + expect(body.thinking).toBeDefined() + + // Native structured output is requested via output_config.format, not a + // forced StructuredOutput tool. + const outputConfig = body.output_config as { format?: { type?: string } } | undefined + expect(outputConfig?.format?.type).toBe("json_schema") + + // The forced tool choice must NOT survive — Anthropic uses { type: "any" } + // / { type: "tool" } for forced calls; native output must leave it unforced. + const toolChoice = body.tool_choice as { type?: string } | undefined + expect(toolChoice?.type === "any" || toolChoice?.type === "tool").toBe(false) + }), + { + config: () => ({ + enabled_providers: [anthropicThinkingFixture.providerID], + provider: { + [anthropicThinkingFixture.providerID]: { + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + }), + }, + ) + it.instance( "keeps tools enabled by prompt permissions", () =>