Skip to content
Merged
19 changes: 16 additions & 3 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,22 @@ A hosted call for a declared capability can satisfy `required`; a mismatched hos
HTTP 400 with the same error code and `x-should-retry: false`, since the hosted action may already
have executed. No fallback occurs in that case. Usage remains recorded.
Auto/none choices, incomplete results and unknown tool contracts keep their existing behavior.
This guard covers buffered native combo children. Streaming required-tool enforcement and direct
native routes are separate scopes; visible output is never replayed. Translated Chat routes have
their own guard, described next.
Direct native routes remain separate. Translated Chat routes have their own guard, described below.

## Required tools on streaming native combo routes

For `stream: true`, native Responses combo children also fail an unmet required or named tool
choice with `required_tool_contract_unsatisfied`. The caller's mandatory choice remains binding even
when a target filters out its tool or lowers the outbound choice to `none`. Configured hosted-image
substitutions can fulfill the original choice only when applied to the request actually sent,
including after queued revalidation. A fully held text-only completion can advance to
another eligible target. Known reasoning frames are held for at most one second from the first frame;
later frames do not extend that deadline. Expiry releases the buffered stream and commits the target,
preserving any pending upstream read. Text, tool events, unknown/opaque output and buffer limits also
commit the current target. Namespace selectors and call echoes share the authorized alias restoration.
After that boundary, missing calls produce `response.failed` without another target request; already delivered
output and usage remain intact. Hosted execution evidence also prevents replay, even when the final
snapshot omits it. Auto/none, incomplete results and unknown contracts retain their existing rules.

## Required tools on translated Chat routes

Expand Down
4 changes: 3 additions & 1 deletion scripts/test-layout/layout-combos.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{
"combo-cooldown-reprobe.test.ts": "codex-integration"
"combo-cooldown-reprobe.test.ts": "codex-integration",
"server-combo-stream-hosted-tool-contract.test.ts": "server",
"server-combo-stream-send-contract.test.ts": "server"
}
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"explicitShards": ["layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-providers.json"],
"explicit": { "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows",
"service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai",
"owner-registry-isolation.test.ts": "config",
"owner-registry-isolation.test.ts": "config", "server-combo-stream-required-tool.test.ts": "server", "combo-required-tool-stream-preflight.test.ts": "routing",
"responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses",
"messages-request-id-endpoint.test.ts": "claude-integration",
"anthropic-revoked-token.test.ts": "adapters/anthropic",
Expand Down
2 changes: 2 additions & 0 deletions src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export interface AdapterRequest {
method: string;
headers: Record<string, string>;
body: string;
/** Caller tool identities replaced by hosted tools in this exact build; empty clears prior builds. */
hostedToolSubstitutions?: ReadonlyArray<{ name: string; namespace?: string; callType: string }>;
/** Exact final wire model, captured before serialization for model-scoped transport gates. */
outboundModelId?: string;
/** Final upstream wire names of custom tools lowered to functions while building this request. */
Expand Down
16 changes: 15 additions & 1 deletion src/adapters/openai-responses/image-gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export function preferConfiguredHostedTools(
provider: OcxProviderConfig,
modelId: string,
selectedModelId?: string,
substitutions?: Array<{ name: string; namespace?: string; callType: string }>,
): unknown {
// A virtual model's advertised id takes precedence over its resolved wire-model id.
// Read own properties only: a routed model id of `constructor`/`toString` would
Expand All @@ -87,7 +88,20 @@ export function preferConfiguredHostedTools(
if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body;

const stripGroup = (tools: unknown[]): unknown[] => {
const filtered = tools.filter(tool => !declaresImageGenClientTool(tool));
const filtered = tools.filter(tool => {
if (!declaresImageGenClientTool(tool)) return true;
const declaration = tool as Record<string, unknown>;
if (declaration.type === "namespace" && Array.isArray(declaration.tools)) {
for (const inner of declaration.tools) {
if (isPlainObject(inner) && typeof inner.name === "string") {
substitutions?.push({ name: inner.name, namespace: IMAGE_GEN_NAMESPACE, callType: "image_generation_call" });
}
}
} else if (typeof declaration.name === "string") {
substitutions?.push({ name: declaration.name, callType: "image_generation_call" });
}
return false;
});
return filtered.length === tools.length ? tools : filtered;
};

Expand Down
5 changes: 4 additions & 1 deletion src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { normalizeOpenCodeGoAdditionalTools } from "../opencode-go-additional-to
import { isXaiResponsesDestination } from "../../providers/xai-transport";
import { applyGithubCopilotContextTier } from "../../providers/github-copilot-context";
import { Buffer } from "node:buffer";
import type { IncomingMeta, ProviderAdapter } from "../base";
import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../base";
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types";
import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_RESPONSES_LITE_METADATA_KEY, CODEX_ROUTING_HINT_HEADER } from "../../codex/forward-transport-headers";
import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType, transferCompactionCiphertextLease } from "../../responses/compaction";
Expand Down Expand Up @@ -266,6 +266,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
if (adjacentToolResults) {
outBody = normalizeResponsesToolResultAdjacency(outBody);
}
const hostedToolSubstitutions: NonNullable<AdapterRequest["hostedToolSubstitutions"]>[number][] = [];
if (forward) {
// `metadata` is stripped on every forward route for compatibility with the canonical backend.
// `max_output_tokens` is stripped only when this provider is the canonical backend.
Expand All @@ -286,6 +287,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
provider,
parsed.modelId,
parsed._openAiVirtualSelectedModelId,
hostedToolSubstitutions,
);
outBody = normalizeImageGenClientTools(outBody);
}
Expand Down Expand Up @@ -529,6 +531,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
method: "POST",
headers,
body,
hostedToolSubstitutions,
...(isPlainObject(wireBody) && typeof wireBody.model === "string" ? { outboundModelId: wireBody.model } : {}),
releaseBodyObservation,
...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
Expand Down
2 changes: 1 addition & 1 deletion src/server/responses-image-gen-repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function imageGenToolCallAliases(
}

/** Recursively restore only exact image-gen function-call aliases in a parsed Responses payload. */
function restoreImageGenCalls(
export function restoreImageGenCalls(
value: unknown,
aliases: ReadonlyMap<string, NamespacedTool>,
): { value: unknown; changed: boolean } {
Expand Down
58 changes: 58 additions & 0 deletions src/server/responses/combo-required-tool-fulfillments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { AdapterRequest } from "../../adapters/base";
import { collectResponsesToolGroups } from "../../responses/tool-groups";
import { requiredToolContractOf, type RequiredToolContract } from "./combo-required-tool-contract";
import { parseOutboundRequestBody } from "./passthrough-completion";

type Item = Record<string, unknown>;
const record = (value: unknown): Item | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Item : undefined;
const namespaceOf = (item: Item) => item.namespace === "functions" ? undefined : item.namespace;
const sameIdentity = (a: Item, b: Item) => a.name === b.name && namespaceOf(a) === namespaceOf(b);

function declarations(body: unknown, normalize: (item: unknown) => unknown) {
const clients: Item[] = [];
const hosted = new Set<string>();
const visit = (value: unknown, namespace?: string) => {
const tool = record(value);
if (!tool) return;
if (tool.type === "namespace" && typeof tool.name === "string" && Array.isArray(tool.tools)) {
for (const inner of tool.tools) visit(inner, tool.name);
} else if ((tool.type === "function" || tool.type === "custom") && typeof tool.name === "string") {
const item = record(normalize({ ...tool, ...(namespace ? { namespace } : {}),
type: tool.type === "custom" ? "custom_tool_call" : "function_call" }));
if (item) clients.push(item);
} else {
const contract = requiredToolContractOf({ tools: [tool], tool_choice: "required" });
if (contract?.kind === "any") for (const type of contract.hostedCallTypes) hosted.add(type);
}
};
for (const group of collectResponsesToolGroups(body)) for (const tool of group) visit(tool);
return { clients, hosted };
}

/** Caller intent never depends on capability filtering. Only this physical build grants substitutions. */
export function requiredToolFulfillments(
callerBody: unknown, contract: RequiredToolContract, normalize: (item: unknown) => unknown,
request?: AdapterRequest,
): (item: Item, clientCall: boolean) => boolean {
const caller = declarations(callerBody, normalize);
const choice = record(record(callerBody)?.tool_choice);
const selected = record(normalize({ ...choice, type: choice?.type === "custom" ? "custom_tool_call" : "function_call" }));
const intendedClient = (item: Item) => contract.kind === "any"
? caller.clients.some(tool => sameIdentity(item, tool))
: selected !== undefined && sameIdentity(item, selected) && caller.clients.some(tool => sameIdentity(item, tool));
const hosted = new Set(contract.kind === "any" ? contract.hostedCallTypes : []);
// request is the same object oauthDispatch replaces in place after queued revalidation.
const outbound = request ? parseOutboundRequestBody(request.body) : undefined;
const wire = request ? declarations(outbound, normalize) : undefined;
for (const substitution of request?.hostedToolSubstitutions ?? []) {
const identity = record(normalize({ ...substitution, type: "function_call" }));
if (identity && intendedClient(identity) && wire?.hosted.has(substitution.callType)) hosted.add(substitution.callType);
}
return (item, clientCall) => {
if (request && (outbound?.tool_choice === "none" || !outbound)) return false;
if (!clientCall) return hosted.has(String(item.type)) && (!wire || wire.hosted.has(String(item.type)));
// Sparse calls keep the existing uncertain-name policy; explicit identities must be declared.
if (typeof item.name !== "string") return !wire || wire.clients.length > 0;
return intendedClient(item) && (!wire || wire.clients.some(tool => sameIdentity(item, tool)));
};
}
Loading
Loading