Files
2026-07-13 13:39:12 +08:00

247 lines
8.4 KiB
TypeScript

import { FORMATS } from "../translator/formats.ts";
export const OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME = "omniroute_web_search";
const WEB_SEARCH_TOOL_TYPES = new Set(["web_search", "web_search_preview"]);
const SEARCH_CONTEXT_DEFAULTS: Record<string, number> = {
low: 5,
medium: 8,
high: 10,
};
type JsonRecord = Record<string, unknown>;
export interface WebSearchFallbackPlan {
enabled: boolean;
toolName: string | null;
convertedToolCount: number;
}
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function isBuiltInWebSearchTool(tool: unknown): tool is JsonRecord {
const toolRecord = toRecord(tool);
const toolType = typeof toolRecord.type === "string" ? toolRecord.type : "";
return WEB_SEARCH_TOOL_TYPES.has(toolType) && !toolRecord.function;
}
function isBuiltInWebSearchToolChoice(toolChoice: unknown): boolean {
const choice = toRecord(toolChoice);
const toolType = typeof choice.type === "string" ? choice.type : "";
return WEB_SEARCH_TOOL_TYPES.has(toolType);
}
function buildFallbackDescription(tool: JsonRecord): string {
const externalWebAccess = tool.external_web_access !== false;
const contextSize =
typeof tool.search_context_size === "string"
? tool.search_context_size.trim().toLowerCase()
: "";
const defaultMaxResults = SEARCH_CONTEXT_DEFAULTS[contextSize] || SEARCH_CONTEXT_DEFAULTS.medium;
const accessMode = externalWebAccess ? "public web" : "configured search index";
return [
`Search the ${accessMode} for recent, factual information and return cited results.`,
"Use this when the answer depends on current events, external documents, or fresh facts.",
`If max_results is omitted, prefer about ${defaultMaxResults} results.`,
].join(" ");
}
function buildFallbackParameters(tool: JsonRecord): JsonRecord {
const contextSize =
typeof tool.search_context_size === "string"
? tool.search_context_size.trim().toLowerCase()
: "";
const defaultMaxResults = SEARCH_CONTEXT_DEFAULTS[contextSize] || SEARCH_CONTEXT_DEFAULTS.medium;
return {
type: "object",
additionalProperties: false,
properties: {
query: {
type: "string",
description: "The web search query to execute.",
},
search_type: {
type: "string",
enum: ["web", "news"],
description: "Use 'news' for recent headlines or reporting; otherwise use 'web'.",
},
max_results: {
type: "integer",
minimum: 1,
maximum: 20,
default: defaultMaxResults,
description: "Maximum number of results to retrieve.",
},
country: {
type: "string",
description: "Optional 2-letter country code for localization, e.g. US or BR.",
},
language: {
type: "string",
description: "Optional language code such as en or pt-BR.",
},
time_range: {
type: "string",
enum: ["any", "day", "week", "month", "year"],
description: "Optional recency filter.",
},
filters: {
type: "object",
additionalProperties: false,
properties: {
include_domains: {
type: "array",
items: { type: "string" },
description: "Optional list of domains to include.",
},
exclude_domains: {
type: "array",
items: { type: "string" },
description: "Optional list of domains to exclude.",
},
},
},
},
required: ["query"],
};
}
function buildFallbackTool(tool: JsonRecord, targetFormat?: string | null): JsonRecord {
const name = OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME;
const description = buildFallbackDescription(tool);
const parameters = buildFallbackParameters(tool);
// Responses API expects FLAT function tools ({ type, name, parameters }), whereas
// Chat Completions expects NESTED ({ type, function: { name, parameters } }). On the
// Responses→Responses passthrough path nothing flattens the injected tool, so a nested
// shape reaches the upstream as `tools[0].function.name` and is rejected with
// "Missing required parameter: 'tools[0].name'." (issue #2390).
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
return { type: "function", name, description, parameters };
}
return {
type: "function",
function: { name, description, parameters },
};
}
// Providers whose endpoint advertises Claude/Anthropic format but does NOT implement
// Anthropic's typed server tools (web_search_20250305, …). For these the Claude -> Claude
// bypass below must NOT apply: forwarding the native server tool makes the upstream 400
// (MiniMax returns `invalid params, function name or parameters is empty (2013)`), so the
// built-in web-search tool has to be converted to the omniroute_web_search function
// fallback — which these models accept as a normal function tool (#4481).
const CLAUDE_FORMAT_PROVIDERS_WITHOUT_SERVER_TOOLS = new Set(["minimax"]);
export function supportsNativeWebSearchFallbackBypass({
provider,
sourceFormat,
targetFormat,
nativeCodexPassthrough,
}: {
provider?: string | null;
sourceFormat?: string | null;
targetFormat: string | null | undefined;
nativeCodexPassthrough: boolean;
}): boolean {
// Native Codex (OpenAI Responses) passthrough: the upstream runs web search itself.
if (nativeCodexPassthrough) return true;
// Gemini target: the Gemini translator maps built-in web search to googleSearch natively.
if (targetFormat === FORMATS.GEMINI) return true;
// Claude -> Claude passthrough: the Anthropic Messages upstream (e.g. a Claude
// subscription driven by Claude Code) natively runs web_search_20250305. Forward the
// native tool untouched instead of rewriting it to omniroute_web_search. Mirrors the
// Codex/Gemini bypasses so every native-web-search provider is treated symmetrically.
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) {
// …except Anthropic-compatible providers that don't actually implement server tools.
if (provider && CLAUDE_FORMAT_PROVIDERS_WITHOUT_SERVER_TOOLS.has(provider)) return false;
return true;
}
return false;
}
export function prepareWebSearchFallbackBody<T extends JsonRecord>(
body: T,
options: {
provider?: string | null;
sourceFormat?: string | null;
targetFormat?: string | null;
nativeCodexPassthrough: boolean;
}
): { body: T; fallback: WebSearchFallbackPlan } {
const tools = Array.isArray(body.tools) ? body.tools : null;
if (!tools || tools.length === 0) {
return {
body,
fallback: { enabled: false, toolName: null, convertedToolCount: 0 },
};
}
const builtInSearchTools = tools.filter(isBuiltInWebSearchTool);
if (builtInSearchTools.length === 0) {
return {
body,
fallback: { enabled: false, toolName: null, convertedToolCount: 0 },
};
}
if (supportsNativeWebSearchFallbackBypass(options)) {
return {
body,
fallback: { enabled: false, toolName: null, convertedToolCount: 0 },
};
}
const toolNames = new Set<string>();
const preservedTools = tools.filter((tool) => {
if (isBuiltInWebSearchTool(tool)) return false;
const toolRecord = toRecord(tool);
const functionRecord = toRecord(toolRecord.function);
const name =
typeof functionRecord.name === "string"
? functionRecord.name
: typeof toolRecord.name === "string"
? toolRecord.name
: "";
if (name.trim().length > 0) {
toolNames.add(name.trim());
}
return true;
});
const isResponsesTarget = options.targetFormat === FORMATS.OPENAI_RESPONSES;
if (!toolNames.has(OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME)) {
preservedTools.unshift(
buildFallbackTool(toRecord(builtInSearchTools[0]), options.targetFormat)
);
}
const nextBody: T = {
...body,
tools: preservedTools as T["tools"],
};
if (isBuiltInWebSearchToolChoice(body.tool_choice)) {
// Match the injected tool shape: flat for Responses API, nested for Chat Completions.
nextBody.tool_choice = (
isResponsesTarget
? { type: "function", name: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME }
: { type: "function", function: { name: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME } }
) as T["tool_choice"];
}
return {
body: nextBody,
fallback: {
enabled: true,
toolName: OMNIROUTE_WEB_SEARCH_FALLBACK_TOOL_NAME,
convertedToolCount: builtInSearchTools.length,
},
};
}