Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import type { LLMClientService } from "@opencode-ai/llm/route"
Expand Down Expand Up @@ -45,6 +46,13 @@ export type StreamInput = {
tools: Record<string, Tool>
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 & {
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -401,6 +432,13 @@ export const defaultLayer = Layer.suspend(() =>

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: layer,
Expand Down
51 changes: 48 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,7 +1239,12 @@ export const layer = Layer.effect(
Effect.provideService(Truncate.Service, truncate),
)

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) {
Expand Down Expand Up @@ -1267,7 +1272,10 @@ export 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,
Expand All @@ -1281,9 +1289,30 @@ export 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"
Expand Down Expand Up @@ -1594,6 +1623,22 @@ export const CommandInput = Schema.Struct({
})
export type CommandInput = Schema.Schema.Type<typeof CommandInput>

// 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<string, any>
Expand Down
88 changes: 88 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -896,6 +897,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",
() =>
Expand Down
Loading