Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline bd074ed8a9 feat(llm): pass strict through tool definitions for Codex parity 2026-06-22 11:02:17 -05:00
6 changed files with 25 additions and 3 deletions
@@ -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"]>) =>
+1
View File
@@ -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)),
+5
View File
@@ -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 = {
@@ -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
}
/**
@@ -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<ToolDefinitionClass>
description: item._definition.description,
inputSchema: item._definition.inputSchema,
outputSchema: item._definition.outputSchema,
strict: item._definition.strict,
}),
)
@@ -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,
@@ -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: () => {
@@ -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 &&
@@ -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" }))