chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user