Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/llm/src/protocols/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LLMRequest["toolChoice"]>) =>
Expand Down
1 change: 1 addition & 0 deletions packages/llm/src/schema/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export class ToolDefinition extends Schema.Class<ToolDefinition>("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)),
Expand Down
5 changes: 5 additions & 0 deletions packages/llm/src/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type TypedToolConfig = {
readonly execute?: ToolExecute<ToolSchema<any>, ToolSchema<any>>
readonly toModelOutput?: ToolToModelOutput<ToolSchema<any>, ToolSchema<any>>
readonly toStructuredOutput?: (output: unknown) => unknown
readonly strict?: boolean
}

type DynamicToolConfig = {
Expand All @@ -97,6 +98,7 @@ type DynamicToolConfig = {
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown
readonly strict?: boolean
}

/**
Expand Down Expand Up @@ -181,6 +183,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
description: config.description,
inputSchema: config.jsonSchema,
outputSchema: config.outputSchema,
strict: config.strict,
}),
}
}
Expand All @@ -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,
}),
}
}
Expand All @@ -226,6 +230,7 @@ export const toDefinitions = (tools: Tools): ReadonlyArray<ToolDefinitionClass>
description: item._definition.description,
inputSchema: item._definition.inputSchema,
outputSchema: item._definition.outputSchema,
strict: item._definition.strict,
}),
)

Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,11 @@ function nativeSchema(value: unknown): JsonSchema {
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
}

export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
export function nativeTools(
tools: Record<string, Tool>,
input: Pick<StreamInput, "messages" | "abort" | "model">,
) {
const strict = input.model.api.npm === "@ai-sdk/openai" ? false : undefined
return Object.fromEntries(
Object.entries(tools).map(([name, item]) => [
name,
Expand All @@ -175,6 +179,7 @@ export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput
NativeTool.make({
description: item.description ?? "",
jsonSchema: nativeSchema(item.inputSchema),
...(strict !== undefined && { strict }),
execute: (args: unknown, ctx) =>
Effect.tryPromise({
try: () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/opencode/src/session/llm/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/session/llm-native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }))
Expand All @@ -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" }))
Expand Down
Loading