mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:10:43 +00:00
fix(agents): handle OpenAI web search schema rejects
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user