From bd074ed8a94bbacd681a69080149d81ca59cf634 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 22 Jun 2026 11:02:17 -0500 Subject: [PATCH] feat(llm): pass strict through tool definitions for Codex parity --- packages/llm/src/protocols/openai-responses.ts | 1 + packages/llm/src/schema/messages.ts | 1 + packages/llm/src/tool.ts | 5 +++++ packages/opencode/src/session/llm/native-runtime.ts | 7 ++++++- packages/opencode/src/session/llm/request.ts | 10 ++++++++++ packages/opencode/test/session/llm-native.test.ts | 4 ++-- 6 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index b8a955640ed2..ff0b2467195c 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -258,6 +258,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ name: tool.name, description: tool.description, parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + ...(tool.strict != null ? { strict: tool.strict } : {}), }) const lowerToolChoice = (toolChoice: NonNullable) => diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index b160f2d4a495..2d6201447d69 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -241,6 +241,7 @@ export class ToolDefinition extends Schema.Class("LLM.ToolDefini description: Schema.String, inputSchema: JsonSchema, outputSchema: Schema.optional(JsonSchema), + strict: Schema.optional(Schema.Boolean), cache: Schema.optional(CacheHint), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), diff --git a/packages/llm/src/tool.ts b/packages/llm/src/tool.ts index 11ed9854ca38..29abca862b87 100644 --- a/packages/llm/src/tool.ts +++ b/packages/llm/src/tool.ts @@ -88,6 +88,7 @@ type TypedToolConfig = { readonly execute?: ToolExecute, ToolSchema> readonly toModelOutput?: ToolToModelOutput, ToolSchema> readonly toStructuredOutput?: (output: unknown) => unknown + readonly strict?: boolean } type DynamicToolConfig = { @@ -97,6 +98,7 @@ type DynamicToolConfig = { readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect readonly toModelOutput?: (input: ToolModelOutputInput) => ReadonlyArray readonly toStructuredOutput?: (output: unknown) => unknown + readonly strict?: boolean } /** @@ -181,6 +183,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { description: config.description, inputSchema: config.jsonSchema, outputSchema: config.outputSchema, + strict: config.strict, }), } } @@ -201,6 +204,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool { description: config.description, inputSchema: toJsonSchema(config.parameters), outputSchema: toJsonSchema(config.success), + strict: config.strict, }), } } @@ -226,6 +230,7 @@ export const toDefinitions = (tools: Tools): ReadonlyArray description: item._definition.description, inputSchema: item._definition.inputSchema, outputSchema: item._definition.outputSchema, + strict: item._definition.strict, }), ) diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index bac385c59137..4daefd31764c 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -166,7 +166,11 @@ function nativeSchema(value: unknown): JsonSchema { return asSchema(value as Parameters[0]).jsonSchema as JsonSchema } -export function nativeTools(tools: Record, input: Pick) { +export function nativeTools( + tools: Record, + input: Pick, +) { + const strict = input.model.api.npm === "@ai-sdk/openai" ? false : undefined return Object.fromEntries( Object.entries(tools).map(([name, item]) => [ name, @@ -175,6 +179,7 @@ export function nativeTools(tools: Record, input: Pick Effect.tryPromise({ try: () => { diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 2785d9852631..4f93411107df 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -146,6 +146,16 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre ) const tools = resolveTools(input) + // Codex parity: OpenAI Responses-family providers hardcode `strict: false` + // on every function tool so MCP-sourced and dynamic schemas that don't + // satisfy OpenAI's structured-outputs constraints still register. + if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" + ) { + for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false } + } if ( input.model.providerID.includes("github-copilot") && Object.keys(tools).length === 0 && diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 702bb67e390d..8d7ba92a1244 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -511,7 +511,7 @@ describe("session.llm-native.request", () => { }, } satisfies Tool, }, - { messages: [] as ModelMessage[], abort: new AbortController().signal }, + { messages: [] as ModelMessage[], abort: new AbortController().signal, model: baseModel }, ) const failure = yield* Effect.flip(wrapped.explode.execute({}, { id: "call-1", name: "explode" })) @@ -527,7 +527,7 @@ describe("session.llm-native.request", () => { // wiring is wrong; we want a typed failure, not a silent skip or unhandled exception. const wrapped = LLMNativeRuntime.nativeTools( { incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } satisfies Tool }, - { messages: [] as ModelMessage[], abort: new AbortController().signal }, + { messages: [] as ModelMessage[], abort: new AbortController().signal, model: baseModel }, ) const failure = yield* Effect.flip(wrapped.incomplete.execute({}, { id: "call-1", name: "incomplete" }))