From f600e98e5bad6cd87087c1c88ea8e48aa36e8a27 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 23 Apr 2026 06:40:23 +0100 Subject: [PATCH] fix(agents): handle OpenAI web search schema rejects --- src/agents/failover-error.test.ts | 20 +++++ src/agents/failover-error.ts | 6 ++ src/agents/model-fallback.ts | 2 +- src/agents/openai-transport-stream.test.ts | 76 +++++++++++++++++++ src/agents/openai-transport-stream.ts | 52 ++++++++++++- .../run/assistant-failover.ts | 2 + .../run/failover-observation.ts | 11 ++- 7 files changed, 166 insertions(+), 3 deletions(-) diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 05cec5c5870..da9bc7e2723 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { coerceToFailoverError, describeFailoverError, + FailoverError, isTimeoutError, resolveFailoverReasonFromError, resolveFailoverStatus, @@ -609,6 +610,25 @@ describe("failover-error", () => { expect(err?.model).toBe("claude-opus-4-6"); }); + it("preserves raw provider error text for diagnostic logs", () => { + const err = new FailoverError("LLM request failed: provider rejected the request schema.", { + reason: "format", + provider: "openai", + model: "gpt-5.4", + status: 400, + rawError: + "400 The following tools cannot be used with reasoning.effort 'minimal': web_search.", + }); + + expect(describeFailoverError(err)).toMatchObject({ + message: "LLM request failed: provider rejected the request schema.", + rawError: + "400 The following tools cannot be used with reasoning.effort 'minimal': web_search.", + reason: "format", + status: 400, + }); + }); + it("coerces JSON-wrapped OpenRouter stealth-model 404s into FailoverError", () => { const err = coerceToFailoverError(OPENROUTER_MODEL_NOT_FOUND_PAYLOAD, { provider: "openrouter", diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index 5ca0cdf739e..94e43251435 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -16,6 +16,7 @@ export class FailoverError extends Error { readonly profileId?: string; readonly status?: number; readonly code?: string; + readonly rawError?: string; constructor( message: string, @@ -26,6 +27,7 @@ export class FailoverError extends Error { profileId?: string; status?: number; code?: string; + rawError?: string; cause?: unknown; }, ) { @@ -37,6 +39,7 @@ export class FailoverError extends Error { this.profileId = params.profileId; this.status = params.status; this.code = params.code; + this.rawError = params.rawError; } } @@ -275,6 +278,7 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n export function describeFailoverError(err: unknown): { message: string; + rawError?: string; reason?: FailoverReason; status?: number; code?: string; @@ -282,6 +286,7 @@ export function describeFailoverError(err: unknown): { if (isFailoverError(err)) { return { message: err.message, + rawError: err.rawError, reason: err.reason, status: err.status, code: err.code, @@ -325,6 +330,7 @@ export function coerceToFailoverError( profileId: context?.profileId, status, code, + rawError: message, cause: err instanceof Error ? err : undefined, }); } diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts index 1dde7f41e1d..c676acf4631 100644 --- a/src/agents/model-fallback.ts +++ b/src/agents/model-fallback.ts @@ -255,7 +255,7 @@ function recordFailedCandidateAttempt(params: { reason: described.reason, status: described.status, code: described.code, - error: described.message, + error: described.rawError ?? described.message, nextCandidate: params.nextCandidate, isPrimary: params.isPrimary, requestedModelMatched: params.requestedModelMatched, diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 1a21e776954..a8fe415e8b6 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -717,6 +717,82 @@ describe("openai transport stream", () => { expect(params.reasoning).toEqual({ effort: "low", summary: "auto" }); }); + it("raises minimal OpenAI Responses reasoning when web_search is available", () => { + const model = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + compat: { + supportedReasoningEfforts: ["minimal", "low", "medium", "high"], + }, + } satisfies Model<"openai-responses">; + + const params = buildOpenAIResponsesParams( + model, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "web_search", + description: "Search the web", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + } as never, + { + reasoning: "minimal", + } as never, + ) as { reasoning?: unknown }; + + expect(params.reasoning).toEqual({ effort: "low", summary: "auto" }); + }); + + it("keeps minimal OpenAI Responses reasoning without web_search", () => { + const model = { + id: "gpt-5.4", + name: "GPT-5.4", + api: "openai-responses", + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + compat: { + supportedReasoningEfforts: ["minimal", "low", "medium", "high"], + }, + } satisfies Model<"openai-responses">; + + const params = buildOpenAIResponsesParams( + model, + { + systemPrompt: "system", + messages: [], + tools: [ + { + name: "lookup_weather", + description: "Get forecast", + parameters: { type: "object", properties: {}, additionalProperties: false }, + }, + ], + } as never, + { + reasoning: "minimal", + } as never, + ) as { reasoning?: unknown }; + + expect(params.reasoning).toEqual({ effort: "minimal", summary: "auto" }); + }); + it("maps low reasoning to medium for Codex mini responses models", () => { const params = buildOpenAIResponsesParams( { diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 46a796e6972..b271ce0cdc7 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -755,6 +755,49 @@ function resolveOpenAIReasoningEffort( ) as OpenAIApiReasoningEffort; } +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function hasResponsesWebSearchTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some((tool) => { + if (!isRecord(tool)) { + return false; + } + if (tool.type === "web_search") { + return true; + } + if (tool.type === "function" && tool.name === "web_search") { + return true; + } + const fn = tool.function; + return isRecord(fn) && fn.name === "web_search"; + }); +} + +function raiseMinimalReasoningForResponsesWebSearch(params: { + model: Model; + effort: OpenAIApiReasoningEffort; + tools: unknown; +}): OpenAIApiReasoningEffort { + if (params.effort !== "minimal" || !hasResponsesWebSearchTool(params.tools)) { + return params.effort; + } + for (const effort of ["low", "medium", "high"] as const) { + const resolved = resolveOpenAIReasoningEffortForModel({ + model: params.model, + effort, + }); + if (resolved && resolved !== "none" && resolved !== "minimal") { + return resolved; + } + } + return params.effort; +} + export function buildOpenAIResponsesParams( model: Model, context: Context, @@ -801,10 +844,17 @@ export function buildOpenAIResponsesParams( if (model.reasoning) { if (options?.reasoningEffort || options?.reasoning || options?.reasoningSummary) { const requestedReasoningEffort = resolveOpenAIReasoningEffort(options); - const reasoningEffort = resolveOpenAIReasoningEffortForModel({ + const resolvedReasoningEffort = resolveOpenAIReasoningEffortForModel({ model, effort: requestedReasoningEffort, }); + const reasoningEffort = resolvedReasoningEffort + ? raiseMinimalReasoningForResponsesWebSearch({ + model, + effort: resolvedReasoningEffort, + tools: params.tools, + }) + : undefined; if (reasoningEffort) { params.reasoning = { effort: reasoningEffort, diff --git a/src/agents/pi-embedded-runner/run/assistant-failover.ts b/src/agents/pi-embedded-runner/run/assistant-failover.ts index be8460f4ce3..9b4448e0f81 100644 --- a/src/agents/pi-embedded-runner/run/assistant-failover.ts +++ b/src/agents/pi-embedded-runner/run/assistant-failover.ts @@ -135,6 +135,7 @@ export async function handleAssistantFailover(params: { model: params.activeErrorContext.model, profileId: params.lastProfileId, status, + rawError: params.lastAssistant?.errorMessage?.trim(), }, ), }; @@ -216,6 +217,7 @@ export async function handleAssistantFailover(params: { model: params.activeErrorContext.model, profileId: params.lastProfileId, status, + rawError: params.lastAssistant?.errorMessage?.trim(), }), }; } diff --git a/src/agents/pi-embedded-runner/run/failover-observation.ts b/src/agents/pi-embedded-runner/run/failover-observation.ts index 613399136e8..8ec5ca0eb14 100644 --- a/src/agents/pi-embedded-runner/run/failover-observation.ts +++ b/src/agents/pi-embedded-runner/run/failover-observation.ts @@ -57,6 +57,15 @@ export function createFailoverDecisionLogger( const sourceChanged = safeSourceProvider !== safeProvider || safeSourceModel !== safeModel; return (decision, extra) => { const observedError = buildApiErrorObservationFields(normalizedBase.rawError); + const safeRawErrorPreview = sanitizeForConsole(observedError.rawErrorPreview); + const shouldSuppressRawErrorConsoleSuffix = + observedError.providerRuntimeFailureKind === "auth_html_403" || + observedError.providerRuntimeFailureKind === "auth_scope" || + observedError.providerRuntimeFailureKind === "auth_refresh"; + const rawErrorConsoleSuffix = + safeRawErrorPreview && !shouldSuppressRawErrorConsoleSuffix + ? ` rawError=${safeRawErrorPreview}` + : ""; log.warn("embedded run failover decision", { event: "embedded_run_failover_decision", tags: ["error_handling", "failover", normalizedBase.stage, decision], @@ -78,7 +87,7 @@ export function createFailoverDecisionLogger( consoleMessage: `embedded run failover decision: runId=${safeRunId} stage=${normalizedBase.stage} decision=${decision} ` + `reason=${reasonText} from=${safeSourceProvider}/${safeSourceModel}` + - `${sourceChanged ? ` to=${safeProvider}/${safeModel}` : ""} profile=${profileText}`, + `${sourceChanged ? ` to=${safeProvider}/${safeModel}` : ""} profile=${profileText}${rawErrorConsoleSuffix}`, }); }; }