Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity
| `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. |
| `modelPreferHostedTools?` | `Record<string,string[]>` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. |
| `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=<provider>` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. |
| `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=<provider>` accepts an array or `null` to clear it. |
| `reasoningEffortMap?` | `Record<string, string>` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). |
| `modelReasoningEffortMap?` | `Record<string, Record<string, string>>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. |
| `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. The ClinePass preset sets this automatically. |
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,7 @@
"responses-custom-tool-guidance.test.ts": "responses",
"responses-custom-tool-repair.test.ts": "responses",
"responses-fetch-helpers-boundary.test.ts": "responses",
"responses-hosted-tool-declaration.test.ts": "responses",
"responses-field-backfill.test.ts": "responses",
"responses-forward-dangling-call.test.ts": "responses",
"responses-forward-incomplete-quota.test.ts": "responses",
Expand Down
26 changes: 19 additions & 7 deletions src/adapters/openai-responses/tool-schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types";
import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
import { debugProviderDiagnostic } from "../../lib/debug";
import { stripUnicodePropertyPatterns } from "../responses-tool-schema";
import {
Expand Down Expand Up @@ -225,17 +225,29 @@ export function promoteClientLoadedTools(body: unknown): unknown {
}

/**
* Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
* carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
* matches, keeping the common path allocation-free.
* Remove hosted tool entries the destination rejects, so the OAuth-passthrough body never
* carries a tool the upstream 400s on. Two sources of truth are consulted: the built-in
* table of known-broken native slugs and destinations, and the routed provider's own
* `unsupportedHostedTools` declaration. The declaration is what lets an OpenAI-compatible
* Responses gateway with a narrower capability set be described in config instead of
* requiring a hard-coded destination rule per vendor (#5002).
*
* No-op (returns the original reference) when nothing matches, keeping the common path
* allocation-free.
*/
export function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxProviderConfig, "baseUrl">): unknown {
export function stripUnsupportedHostedTools(
body: unknown,
provider: Pick<OcxProviderConfig, "baseUrl" | "unsupportedHostedTools">,
): unknown {
if (!isPlainObject(body)) return body;
const model = typeof body.model === "string" ? body.model : "";
// Expanded once per request rather than per tool: the alias walk is the only
// non-lookup work in this filter.
const declaredUnsupported = declaredUnsupportedHostedTools(provider);
const filterTools = (tools: unknown[]): unknown[] => {
const filtered = tools.filter(t => {
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl);
return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl, declaredUnsupported);
});
return filtered.length === tools.length ? tools : filtered;
};
Expand Down Expand Up @@ -274,7 +286,7 @@ export function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxPro
} else if (
isPlainObject(toolChoice)
&& typeof toolChoice.type === "string"
&& isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl)
&& isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl, declaredUnsupported)
) {
next = { ...next, tool_choice: "none" };
changed = true;
Expand Down
35 changes: 33 additions & 2 deletions src/config/schema/leaf-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerMod
import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models";
import { COST4_RATE_KEYS, isValidCost4Rate } from "../../usage/user-cost-overlays";
import { MAX_COST4_RATE } from "../../usage/expected-prices";
import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
import {
DECLARABLE_HOSTED_TOOL_TYPES,
declaredUnsupportedHostedTools,
isHostedToolUnsupportedForModel,
} from "../../responses/hosted-tool-policy";
import { getConfigDir } from "../paths";

/** One definition of "usable secret", shared by the schema and the warnings. */
Expand Down Expand Up @@ -275,6 +279,18 @@ export const providerConfigSchema = z.object({
omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
.transform(normalizeNonBlankStringArray)
.optional(),
// Validated against a closed vocabulary rather than accepted as free strings. This
// schema ends in `.passthrough()`, so a misspelled `web_serch` would otherwise be
// accepted, persisted, and strip nothing -- leaving the operator with the upstream 400
// the field was set to prevent, and no message saying why (the `codexToolMode` lesson,
// #2106).
unsupportedHostedTools: z.array(z.string().min(1))
.transform(normalizeNonBlankStringArray)
.refine(
tools => tools.every(tool => DECLARABLE_HOSTED_TOOL_TYPES.has(tool)),
{ message: `unsupportedHostedTools accepts only hosted tool types: ${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}` },
)
.optional(),
retryOn429: retryOn429PolicySchema.optional(),
transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
codexAccountMode: z.enum(["pool", "direct"]).optional(),
Expand Down Expand Up @@ -377,14 +393,26 @@ export function modelPreferHostedToolsConfigError(
value: unknown,
field: string,
providerName: string,
provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
provider: {
adapter?: unknown;
authMode?: unknown;
modelAdapters?: unknown;
baseUrl?: unknown;
unsupportedHostedTools?: unknown;
},
): string | null {
if (value === undefined) return null;
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
const prototype = Object.getPrototypeOf(value);
if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
const entries = Object.entries(value);
const registry = getProviderRegistryEntry(providerName);
// A provider that denies a hosted tool cannot also prefer it. Both keys are
// provider-owned capability statements about the same tool, and the denial wins at
// request time, so accepting the pair would silently ignore the preference.
const declaredUnsupported = declaredUnsupportedHostedTools(
provider as { unsupportedHostedTools?: readonly string[] },
);
// Effective transport: a `preserveCustomDestination` registry row reused under a
// different endpoint keeps its own adapter AND its own auth at runtime, because
// `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
Expand Down Expand Up @@ -445,6 +473,9 @@ export function modelPreferHostedToolsConfigError(
if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
return `${field}.${key} supports only image_generation`;
}
if (declaredUnsupported.has(tool)) {
return `${field}.${key} cannot prefer ${tool}: unsupportedHostedTools declares it unsupported`;
}
if (isHostedToolUnsupportedForModel(key, tool)) {
return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
}
Expand Down
87 changes: 85 additions & 2 deletions src/responses/hosted-tool-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,90 @@ const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{
},
];

/** True when forwarding this hosted tool to the model would be rejected upstream. */
export function isHostedToolUnsupportedForModel(modelId: string, tool: string, baseUrl?: string): boolean {
/**
* Hosted-tool declaration names an operator may list in `unsupportedHostedTools`.
*
* A gateway must be able to deny anything a client can send it, so this is the set of
* nameless hosted/private declaration types the proxy recognizes on a Responses request.
* It is deliberately a closed vocabulary: the provider config schema ends in
* `.passthrough()`, so an unvalidated misspelling would be accepted, persisted, and then
* silently strip nothing -- which is the exact 400 the operator set the field to avoid
* (the `codexToolMode` lesson in #2106).
*/
export const DECLARABLE_HOSTED_TOOL_TYPES: ReadonlySet<string> = new Set([
"web_search",
"web_search_preview",
"file_search",
"computer_use_preview",
"computer_use",
"code_interpreter",
"image_generation",
"image_gen",
"mcp",
"tool_search",
"local_shell",
"x_search",
]);

/**
* Spellings that name one capability. Declaring either member denies both, because the
* rest of the proxy already treats these as a single tool: the parser folds
* `web_search_preview` onto one name (`src/responses/parser-tools.ts`), Chat ingress
* accepts the pair together (`src/chat/inbound.ts`), and the canonical-field strip lists
* the pair in a single `toolTypes` set
* (`src/adapters/openai-responses/request-strips.ts`).
*
* Without the alias a capability declaration would be honoured for the spelling the
* operator happened to write and ignored for the one the client happened to send, which
* reproduces the original rejection while the config claims to have prevented it.
*/
const HOSTED_TOOL_ALIAS_GROUPS: ReadonlyArray<ReadonlySet<string>> = [
new Set(["web_search", "web_search_preview"]),
new Set(["image_generation", "image_gen"]),
new Set(["computer_use_preview", "computer_use"]),
];

const NO_DECLARED_HOSTED_TOOLS: ReadonlySet<string> = new Set();

/**
* Expand a provider's declared denials through the alias groups once per request, so the
* per-tool predicate stays a set lookup. Returns a shared empty set when the provider
* declares nothing, keeping the common path allocation-free.
*/
export function declaredUnsupportedHostedTools(
provider?: { unsupportedHostedTools?: readonly string[] },
): ReadonlySet<string> {
const declared = provider?.unsupportedHostedTools;
if (!Array.isArray(declared) || declared.length === 0) return NO_DECLARED_HOSTED_TOOLS;
const out = new Set<string>();
for (const raw of declared) {
if (typeof raw !== "string") continue;
const tool = raw.trim();
if (!tool) continue;
out.add(tool);
for (const group of HOSTED_TOOL_ALIAS_GROUPS) {
if (!group.has(tool)) continue;
for (const alias of group) out.add(alias);
}
}
return out.size > 0 ? out : NO_DECLARED_HOSTED_TOOLS;
}

/**
* True when forwarding this hosted tool to the model would be rejected upstream.
*
* `declaredUnsupported` is the provider's own capability declaration, expanded by
* `declaredUnsupportedHostedTools`. It is additive to the built-in table rather than a
* replacement for it: the table covers destinations that reject a tool regardless of how
* the operator configured them, so an operator who never heard of the field stays
* protected.
*/
export function isHostedToolUnsupportedForModel(
modelId: string,
tool: string,
baseUrl?: string,
declaredUnsupported?: ReadonlySet<string>,
): boolean {
if (declaredUnsupported?.has(tool)) return true;
return UNSUPPORTED_HOSTED_TOOLS.some(entry => entry.match(modelId, baseUrl) && entry.tools.has(tool));
}
11 changes: 9 additions & 2 deletions src/responses/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,10 +126,17 @@ export const toolSchema = z.object({

const builtinToolSchema = z.object({ type: z.string() }).loose();

const hostedToolType = z.enum([
/**
* Hosted tool types a client may declare on an inbound Responses request. Exported so the
* provider-side capability vocabulary in `src/responses/hosted-tool-policy.ts` can be
* asserted to cover all of them: a gateway must be able to deny anything it can be sent.
*/
export const HOSTED_TOOL_TYPES = [
"web_search", "web_search_preview", "file_search", "computer_use_preview",
"code_interpreter", "image_generation", "mcp",
]);
] as const;

const hostedToolType = z.enum(HOSTED_TOOL_TYPES);

const allowedToolEntrySchema = z.object({ type: z.string(), name: z.string().optional() });

Expand Down
18 changes: 18 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "../config/provider-validation";
import { providerDestinationConfigError } from "../lib/destination-policy";
import { redactSecretString } from "../lib/redact";
import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
import { providerConfigSeed } from "../providers/derive";
import type { OcxConfig, OcxProviderConfig } from "../types";
Expand Down Expand Up @@ -839,6 +840,22 @@ export function providerManagementConfigError(
"omitReasoningEffortWithToolsModels",
);
if (toolReasoningOptOutError) return `provider ${name} ${toolReasoningOptOutError}`;
const unsupportedHostedToolsError = nonBlankStringArrayConfigError(
raw.unsupportedHostedTools,
"unsupportedHostedTools",
);
if (unsupportedHostedToolsError) return `provider ${name} ${unsupportedHostedToolsError}`;
if (Array.isArray(raw.unsupportedHostedTools)) {
// Closed vocabulary, same reason as the config schema: an unrecognized name would be
// stored and then strip nothing, so the operator would keep getting the upstream 400
// this field exists to prevent.
const unknownTool = (raw.unsupportedHostedTools as unknown[])
.find(tool => typeof tool === "string" && !DECLARABLE_HOSTED_TOOL_TYPES.has(tool.trim()));
if (unknownTool !== undefined) {
return `provider ${name} unsupportedHostedTools must name only hosted tool types: `
+ `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`;
}
}
const openRouterError = openRouterRoutingConfigError(typed);
if (openRouterError) return `provider ${name} ${openRouterError}`;
const vercelError = vercelGatewayRoutingConfigError(typed);
Expand Down Expand Up @@ -984,6 +1001,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
xaiResponsesDefaultVersion: "runtime",
zaiResponsesDefaultVersion: "runtime",
supportsResponsesCustomTools: "editor",
unsupportedHostedTools: "editor",
responsesSnapshotRepair: "editor",
webSearchBridge: "editor",
reasoningEffortMap: "editor",
Expand Down
22 changes: 22 additions & 0 deletions src/server/management/provider-routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation";
import { DECLARABLE_HOSTED_TOOL_TYPES } from "../../responses/hosted-tool-policy";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { isDeepStrictEqual } from "node:util";
Expand Down Expand Up @@ -672,6 +673,26 @@ function applyProviderPatchFields(
}
touched = true;
}
if (Object.hasOwn(rawBody, "unsupportedHostedTools")) {
const value = rawBody.unsupportedHostedTools;
if (value === null) {
delete next.unsupportedHostedTools;
} else {
const error = nonBlankStringArrayConfigError(value, "unsupportedHostedTools");
if (error) return { error };
const tools = normalizeNonBlankStringArray(value as string[]);
const unknownTool = tools.find(tool => !DECLARABLE_HOSTED_TOOL_TYPES.has(tool));
if (unknownTool !== undefined) {
return {
error: `unsupportedHostedTools must name only hosted tool types: `
+ `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`,
};
}
if (tools.length > 0) next.unsupportedHostedTools = tools;
else delete next.unsupportedHostedTools;
}
touched = true;
}
if (Object.hasOwn(rawBody, "retainModels")) {
const value = rawBody.retainModels;
if (value === null) {
Expand Down Expand Up @@ -873,6 +894,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
modelSupportsServiceTier: p.modelSupportsServiceTier,
noStructuredOutputModels: p.noStructuredOutputModels,
noJsonSchemaModels: p.noJsonSchemaModels,
unsupportedHostedTools: p.unsupportedHostedTools,
retainModels: p.retainModels,
omitReasoningEffortWithToolsModels: p.omitReasoningEffortWithToolsModels,
upstreamHttpVersion: p.upstreamHttpVersion,
Expand Down
Loading
Loading