From 939b38c5139776ec111e97cb050cd7412efa239d Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Tue, 30 Jun 2026 11:25:01 -0300 Subject: [PATCH 1/3] fix(openai): strip pattern keyword from Responses API schemas to avoid server_error with zod v4 --- .../fix-openai-responses-pattern-keyword.md | 5 +++++ .../openai-responses-language-model.ts | 3 ++- .../openai-responses-prepare-tools.ts | 3 ++- .../openai-responses-sanitize-schema.ts | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-openai-responses-pattern-keyword.md create mode 100644 packages/openai/src/responses/openai-responses-sanitize-schema.ts diff --git a/.changeset/fix-openai-responses-pattern-keyword.md b/.changeset/fix-openai-responses-pattern-keyword.md new file mode 100644 index 000000000000..8592855a4741 --- /dev/null +++ b/.changeset/fix-openai-responses-pattern-keyword.md @@ -0,0 +1,5 @@ +--- +"@ai-sdk/openai": patch +--- + +Fix `server_error` in OpenAI Responses API when schemas generated by zod v4 contain `pattern` keywords with unsupported regex features (e.g. lookaheads from `z.email()`, `z.uuid()`). The `pattern` keyword is now stripped from response format schemas and tool parameters before they are sent to the API. diff --git a/packages/openai/src/responses/openai-responses-language-model.ts b/packages/openai/src/responses/openai-responses-language-model.ts index a4c70054342a..c8241d07cda7 100644 --- a/packages/openai/src/responses/openai-responses-language-model.ts +++ b/packages/openai/src/responses/openai-responses-language-model.ts @@ -1,3 +1,4 @@ +import { removePatternKeyword } from './openai-responses-sanitize-schema'; import { APICallError, type JSONValue, @@ -345,7 +346,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV4 { strict: strictJsonSchema, name: responseFormat.name ?? 'response', description: responseFormat.description, - schema: responseFormat.schema, + schema: removePatternKeyword(responseFormat.schema), } : { type: 'json_object' }, }), diff --git a/packages/openai/src/responses/openai-responses-prepare-tools.ts b/packages/openai/src/responses/openai-responses-prepare-tools.ts index 0c22453a5a7c..a7f4c8572483 100644 --- a/packages/openai/src/responses/openai-responses-prepare-tools.ts +++ b/packages/openai/src/responses/openai-responses-prepare-tools.ts @@ -23,6 +23,7 @@ import type { OpenAIResponsesFunctionTool, OpenAIResponsesTool, } from './openai-responses-api'; +import { removePatternKeyword } from './openai-responses-sanitize-schema'; type OpenAIToolOptions = { deferLoading?: boolean; @@ -404,7 +405,7 @@ function prepareFunctionTool({ type: 'function', name: tool.name, description: tool.description, - parameters: tool.inputSchema, + parameters: removePatternKeyword(tool.inputSchema), ...(tool.strict != null ? { strict: tool.strict } : {}), ...(deferLoading != null ? { defer_loading: deferLoading } : {}), }; diff --git a/packages/openai/src/responses/openai-responses-sanitize-schema.ts b/packages/openai/src/responses/openai-responses-sanitize-schema.ts new file mode 100644 index 000000000000..996f108efd9e --- /dev/null +++ b/packages/openai/src/responses/openai-responses-sanitize-schema.ts @@ -0,0 +1,19 @@ +// OpenAI structured outputs rejects JSON Schema `pattern` values that contain +// regex features it does not support (e.g. lookaheads). zod v4's toJSONSchema() +// emits `pattern` for string validators like z.email(), z.uuid(), z.iso.date(). +// Strip the keyword recursively before sending schemas to the Responses API. +export function removePatternKeyword(schema: unknown): unknown { + if (!schema || typeof schema !== 'object') return schema; + if (Array.isArray(schema)) return schema.map(removePatternKeyword); + + const result = { ...(schema as Record) }; + delete result['pattern']; + + for (const key of Object.keys(result)) { + if (result[key] && typeof result[key] === 'object') { + result[key] = removePatternKeyword(result[key]); + } + } + + return result; +} From ce27c4a73536bd8bb54587e2b52f7b2dd26fbfd7 Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Tue, 30 Jun 2026 19:14:04 -0300 Subject: [PATCH 2/3] fix(openai): scope removePatternKeyword to JSON Schema structural keys Recurse only through known JSON Schema structural keywords (properties, patternProperties, \, allOf, anyOf, etc.) instead of every object key. Prevents deletion of user-defined tool or response parameters whose name happens to be 'pattern'. --- .../openai-responses-sanitize-schema.ts | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/openai/src/responses/openai-responses-sanitize-schema.ts b/packages/openai/src/responses/openai-responses-sanitize-schema.ts index 996f108efd9e..f0cfb06ce323 100644 --- a/packages/openai/src/responses/openai-responses-sanitize-schema.ts +++ b/packages/openai/src/responses/openai-responses-sanitize-schema.ts @@ -2,16 +2,49 @@ // regex features it does not support (e.g. lookaheads). zod v4's toJSONSchema() // emits `pattern` for string validators like z.email(), z.uuid(), z.iso.date(). // Strip the keyword recursively before sending schemas to the Responses API. +// +// Only standard JSON Schema structural keys are recursed into, so that tool or +// response parameters whose name happens to be "pattern" are never touched. export function removePatternKeyword(schema: unknown): unknown { if (!schema || typeof schema !== 'object') return schema; if (Array.isArray(schema)) return schema.map(removePatternKeyword); const result = { ...(schema as Record) }; + + // Remove the JSON Schema string-validator keyword at this level. delete result['pattern']; - for (const key of Object.keys(result)) { - if (result[key] && typeof result[key] === 'object') { - result[key] = removePatternKeyword(result[key]); + // Keys whose values are { name → schema } maps. Recurse into the schema + // values but preserve the keys — a key named "pattern" here is a user- + // defined property name, not the JSON Schema keyword. + for (const mapKey of ['properties', 'patternProperties', '$defs', 'definitions']) { + if ( + result[mapKey] && + typeof result[mapKey] === 'object' && + !Array.isArray(result[mapKey]) + ) { + result[mapKey] = Object.fromEntries( + Object.entries(result[mapKey] as Record).map( + ([k, v]) => [k, removePatternKeyword(v)], + ), + ); + } + } + + // Keys whose values are sub-schemas or arrays of sub-schemas. + for (const schemaKey of [ + 'items', + 'allOf', + 'anyOf', + 'oneOf', + 'not', + 'additionalProperties', + 'if', + 'then', + 'else', + ]) { + if (result[schemaKey] !== undefined) { + result[schemaKey] = removePatternKeyword(result[schemaKey]); } } From 3cfe5a31c38df1d3d02b989e62ac48a81cc8b5ab Mon Sep 17 00:00:00 2001 From: FranKaddour Date: Wed, 1 Jul 2026 10:03:05 -0300 Subject: [PATCH 3/3] fix(ai): wrap unparseable tool input in object to prevent provider rejection When a tool call's input is not valid JSON, the catch block fallback stored toolCall.input (a raw string) directly as the tool call's input field. On multi-turn conversations this raw string ends up in the message history. Providers like Amazon Bedrock expect toolUse.input to be a JSON object and reject the request with a format error on the next turn. Fix: when safeParseJSON fails, store { rawInvalidInput: toolCall.input } so the field is always a JSON-serialisable object. Fixes #14442 --- .changeset/fix-invalid-tool-input-object-fallback.md | 5 +++++ packages/ai/src/generate-text/parse-tool-call.test.ts | 8 ++++++-- packages/ai/src/generate-text/parse-tool-call.ts | 6 ++++-- 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-invalid-tool-input-object-fallback.md diff --git a/.changeset/fix-invalid-tool-input-object-fallback.md b/.changeset/fix-invalid-tool-input-object-fallback.md new file mode 100644 index 000000000000..8e511556de00 --- /dev/null +++ b/.changeset/fix-invalid-tool-input-object-fallback.md @@ -0,0 +1,5 @@ +--- +"ai": patch +--- + +Fix invalid tool call input being stored as a raw string in multi-turn conversations. When a tool call contains JSON that cannot be parsed, the input is now wrapped as `{ rawInvalidInput: string }` instead of storing the raw string directly. This prevents providers such as Amazon Bedrock from rejecting the conversation history on the next turn with a format error. diff --git a/packages/ai/src/generate-text/parse-tool-call.test.ts b/packages/ai/src/generate-text/parse-tool-call.test.ts index 6fae09532489..dd619c478d73 100644 --- a/packages/ai/src/generate-text/parse-tool-call.test.ts +++ b/packages/ai/src/generate-text/parse-tool-call.test.ts @@ -471,7 +471,9 @@ describe('parseToolCall', () => { "dynamic": true, "error": [AI_InvalidToolInputError: Invalid input for tool testTool: AI_JSONParseError: JSON parsing failed: Text: invalid json. Error message: SyntaxError: Unexpected token 'i', "invalid json" is not valid JSON], - "input": "invalid json", + "input": { + "rawInvalidInput": "invalid json", + }, "invalid": true, "providerExecuted": undefined, "providerMetadata": undefined, @@ -510,7 +512,9 @@ describe('parseToolCall', () => { { "dynamic": true, "error": [AI_ToolCallRepairError: Error repairing tool call: Error: test error], - "input": "invalid json", + "input": { + "rawInvalidInput": "invalid json", + }, "invalid": true, "providerExecuted": undefined, "providerMetadata": undefined, diff --git a/packages/ai/src/generate-text/parse-tool-call.ts b/packages/ai/src/generate-text/parse-tool-call.ts index 5066b1085a1e..c11046ebd06f 100644 --- a/packages/ai/src/generate-text/parse-tool-call.ts +++ b/packages/ai/src/generate-text/parse-tool-call.ts @@ -92,9 +92,11 @@ export async function parseToolCall({ }); } } catch (error) { - // use parsed input when possible + // use parsed input when possible; if the raw input is not valid JSON wrap it + // in an object so downstream providers (e.g. Bedrock) always receive a JSON + // object for toolUse.input rather than a bare string that they would reject. const parsedInput = await safeParseJSON({ text: toolCall.input }); - const input = parsedInput.success ? parsedInput.value : toolCall.input; + const input = parsedInput.success ? parsedInput.value : { rawInvalidInput: toolCall.input }; const tool = tools?.[toolCall.toolName]; // TODO AI SDK 6: special invalid tool call parts