fix(agents): handle OpenAI web search schema rejects

This commit is contained in:
Peter Steinberger
2026-04-23 06:40:23 +01:00
parent 87c85c507a
commit f600e98e5b
7 changed files with 166 additions and 3 deletions

View File

@@ -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",

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -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(
{

View File

@@ -755,6 +755,49 @@ function resolveOpenAIReasoningEffort(
) as OpenAIApiReasoningEffort;
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<Api>;
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<Api>,
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,

View File

@@ -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(),
}),
};
}

View File

@@ -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}`,
});
};
}