diff --git a/src/integrations/omp-yaml-source.ts b/src/integrations/omp-yaml-source.ts index dc2c19275b..bfe6d05834 100644 --- a/src/integrations/omp-yaml-source.ts +++ b/src/integrations/omp-yaml-source.ts @@ -29,7 +29,20 @@ interface MissingEntry { insertAt: number; } -type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry }; +interface ReplaceLineEntry { + lines: readonly SourceLine[]; + index: number; + indent: number; + missingDepth: number; +} + +type LocatedPath = + | { kind: "existing"; entry: LocatedEntry } + | { kind: "missing"; entry: MissingEntry } + // `key: {}` — an empty inline map the block-key scanner cannot see (#4260). + | { kind: "replace-line"; entry: ReplaceLineEntry } + // A populated flow container. Still refused, but nameable as its own cause. + | { kind: "unsupported-style" }; export type YamlFragmentMutation = | { kind: "upsert"; value: unknown } @@ -82,6 +95,61 @@ function isPlainBlockKey(line: string, indent: number, key: string): boolean { return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest); } +/** The inline value written after `key:` on this line, or null if the key is not here. */ +function inlineValueAfterKey(line: string, indent: number, key: string): string | null { + const spaces = leadingSpaces(line); + if (spaces !== indent) return null; + const rest = line.slice(indent); + const head = `${key}:`; + // Compared as text, not as a pattern: a path segment is arbitrary user data, + // and brace escaping inside a `u`-flag regex is its own hazard. + if (!rest.startsWith(head)) return null; + return rest.slice(head.length).trim(); +} + +/** Exactly `key: {}` (any inner spacing) — an empty inline map, no inline comment. */ +function isEmptyInlineMapKey(line: string, indent: number, key: string): boolean { + const value = inlineValueAfterKey(line, indent, key); + if (value === null) return false; + return value.startsWith("{") && value.endsWith("}") && value.slice(1, -1).trim().length === 0; +} + +/** `key: { ... }` or `key: [ ... ]` on one line: content we would have to re-render. */ +function isPopulatedInlineFlowKey(line: string, indent: number, key: string): boolean { + const value = inlineValueAfterKey(line, indent, key); + if (value === null) return false; + if (!value.startsWith("{") && !value.startsWith("[")) return false; + return !isEmptyInlineMapKey(line, indent, key); +} + +/** + * A plain block key whose first child opens a flow collection: + * + * providers: + * { native: { ... } } + * + * DSH writes this shape itself. The walk passes straight through it — the key + * line is a plain block key and `containerEnd` does not stop at `}` — so the + * refusal used to surface only as a failed re-parse at the very end and got + * reported as a comment or formatting problem that was not there (#4260). + */ +function firstChildOpensFlow( + lines: readonly SourceLine[], + start: number, + end: number, + parentIndent: number, +): boolean { + for (let index = start + 1; index < end; index += 1) { + const body = lines[index]!.body; + if (isBlank(body) || isComment(body)) continue; + const spaces = leadingSpaces(body); + if (spaces === null || spaces <= parentIndent) continue; + const trimmed = body.trimStart(); + return trimmed.startsWith("{") || trimmed.startsWith("["); + } + return false; +} + function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null { for (let index = start + 1; index < lines.length; index += 1) { const body = lines[index]!.body; @@ -193,9 +261,24 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc if (matches.length > 1) return null; prefix.push(path[depth]!); if (matches.length === 0) { + const seen = readPath(parsed, prefix); + // An empty inline map is the one flow shape we can adopt: rewriting that + // single line into block form adds our subtree and re-renders nothing the + // user wrote, because there is nothing in it (#4260). + const inline: number[] = []; + const populatedFlow: number[] = []; + for (let index = rangeStart; index < rangeEnd; index += 1) { + const body = lines[index]!.body; + if (isEmptyInlineMapKey(body, indent, path[depth]!)) inline.push(index); + else if (isPopulatedInlineFlowKey(body, indent, path[depth]!)) populatedFlow.push(index); + } + if (inline.length === 1 && isPlainRecord(seen) && Object.keys(seen).length === 0) { + return { kind: "replace-line", entry: { lines, index: inline[0]!, indent, missingDepth: depth } }; + } + if (populatedFlow.length === 1 && seen !== undefined) return { kind: "unsupported-style" }; // The parser saw this key through syntax we do not patch (quoted/flow, // merge aliases, or an ambiguous indentation shape). - if (readPath(parsed, prefix) !== undefined) return null; + if (seen !== undefined) return null; const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length; return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } }; } @@ -209,7 +292,20 @@ function locatePath(text: string, parsed: unknown, path: readonly string[]): Loc if (leafEnd === null) return null; return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } }; } - if (!isPlainRecord(readPath(parsed, prefix))) return null; + const container = readPath(parsed, prefix); + // `key:` with no children parses as null. The key line matched, so the + // missing-key branch above never runs, and `isPlainRecord(null)` is false — + // so an empty container used to refuse the whole document (#4260). Insert + // our subtree as its first child instead. + if (container === null) { + const insertAt = end < lines.length ? lines[end]!.start : text.length; + return { + kind: "missing", + entry: { lines, missingDepth: depth + 1, indent: indent + 2, insertAt }, + }; + } + if (!isPlainRecord(container)) return null; + if (firstChildOpensFlow(lines, index, end, indent)) return { kind: "unsupported-style" }; rangeStart = index + 1; rangeEnd = end; parentIndent = indent; @@ -241,7 +337,7 @@ function upsertSource( value: unknown, ): string | null { const located = locatePath(text, parsed, path); - if (located === null) return null; + if (located === null || located.kind === "unsupported-style") return null; const eol = lineEnding(text); if (located.kind === "existing") { const { lines, index, indent, endIndex } = located.entry; @@ -250,6 +346,13 @@ function upsertSource( const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`; return preserveFinalNewline(candidate, text, eol); } + if (located.kind === "replace-line") { + const { lines, index, indent, missingDepth } = located.entry; + const startOffset = lines[index]!.start; + const endOffset = index + 1 < lines.length ? lines[index + 1]!.start : text.length; + const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol); + return preserveFinalNewline(`${text.slice(0, startOffset)}${insertion}${text.slice(endOffset)}`, text, eol); + } const { missingDepth, indent, insertAt } = located.entry; const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : ""; @@ -338,6 +441,22 @@ export function patchYamlFragmentSource( return patched !== null && semanticallyMatches(patched, expected) ? patched : null; } +/** + * True when a refusal on this path is caused by a flow-style container rather + * than by comments or formatting we would have to re-render. DSH writes that + * shape itself, so naming it is the difference between an actionable message + * and one that sends the user hunting for a comment that is not there (#4260). + */ +export function yamlFragmentUnsupportedStyle(text: string, path: readonly string[]): boolean { + let parsed: unknown; + try { + parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text); + } catch { + return false; + } + return locatePath(text, parsed, path)?.kind === "unsupported-style"; +} + /** Backward-compatible OMP wrapper around the generic path patcher. */ export function patchOmpYamlSource( text: string, diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 514fbc3220..2dcc60bda5 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -35,7 +35,29 @@ import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; -import { patchYamlFragmentSource, sourcePrunableYamlContainers } from "./omp-yaml-source"; +import { + patchYamlFragmentSource, + sourcePrunableYamlContainers, + yamlFragmentUnsupportedStyle, +} from "./omp-yaml-source"; + +/** + * "comments or formatting" used to be the only refusal this path could report. + * For a flow-style container that names a cause which is not in the file, and + * DSH writes that shape itself, so the misdirection was routine rather than + * exotic: users went looking for a comment that was never there (#4260). + */ +function yamlRefusalReason( + source: string, + path: readonly string[], + configPath: string, + outcome: string, +): string { + if (yamlFragmentUnsupportedStyle(source, path)) { + return `${configPath} writes ${path.join(".")} as a flow mapping or sequence, a YAML style opencodex will not re-render, so ${outcome}`; + } + return `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so ${outcome}`; +} import { withIntegrationWriterLock, type IntegrationWriterLockSeams } from "./writer-lock"; export type RefusalReason = @@ -399,7 +421,7 @@ function applyOrRefreshIntegration( ); if (patched === null) { return refuse(clientId, "unsafe", "unsafe", - `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`); + yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "it was left alone")); } text = patched; } else { @@ -536,7 +558,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { : recordedCreated; if (prunableCreated === null) { return refuse(clientId, "unsafe", "unsafe", - `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); + yamlRefusalReason(before ?? "", spec.sourcePreservingYaml!.path, configPath, "nothing was removed")); } let doc: unknown; let removed: boolean; @@ -559,7 +581,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { }, doc); if (patched === null) { return refuse(clientId, "unsafe", "unsafe", - `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); + yamlRefusalReason(before, spec.sourcePreservingYaml.path, configPath, "nothing was removed")); } text = patched; } else { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c4ea553d2d..7f08485bd3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1994,7 +1994,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which + // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the + // relative path resolves to the same destination — but it lets `isRegistryModelDiscoveryUrl` + // prove that URL, which is what admits a Clash/Surge/Mihomo TUN fake-IP answer (#4261). The + // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would + // retarget a user's custom base back to Google. A leading `./` is required because a bare + // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. + { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index cad1b39011..69b445a713 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -5,7 +5,7 @@ import { namespacedToolName, normalizeDeclaredToolName, } from "../types"; -import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; /** Item types the client executes through a request-declared wire name. */ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); @@ -171,6 +171,49 @@ export function collectDeclaredWireToolNames(body: unknown): Set { return names; } +/** + * Collects explicitly declared bare wire tool names from a Responses request body. + * + * Bare wire tools are top-level declarations (or grouped under the builtin `functions` + * namespace) that are not namespaced and do not carry a flattened namespace delimiter (`__`) + * or dotted namespace alias (`.`). + * + * @param body - The outbound or inbound request body. + * @returns A set of declared bare tool names. + */ +export function collectDeclaredBareWireToolNames(body: unknown): Set { + const names = new Set(); + if (!isPlainObject(body)) return names; + const specGroups: unknown[] = [body.tools]; + if (Array.isArray(body.input)) { + for (const item of body.input) { + if ( + isPlainObject(item) + && (item.type === "additional_tools" || item.type === "tool_search_output") + ) specGroups.push(item.tools); + } + } + for (const specs of specGroups) { + if (!Array.isArray(specs)) continue; + for (const spec of specs) { + if (!isPlainObject(spec)) continue; + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + if (spec.name === BUILTIN_FUNCTIONS_NAMESPACE) { + for (const inner of spec.tools) { + if (!isPlainObject(inner)) continue; + const name = wireToolInnerName(inner); + if (name && !name.includes("__") && !name.includes(".")) names.add(name); + } + } + continue; + } + const name = wireToolInnerName(spec); + if (name && !name.includes("__") && !name.includes(".")) names.add(name); + } + } + return names; +} + function addNamelessClientCallTypes(callTypes: Set, specs: unknown): void { if (!Array.isArray(specs)) return; for (const spec of specs) { @@ -286,11 +329,22 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean { ); } +/** + * Evaluates whether an individual output item represents an undeclared tool call. + * + * @param item - The item to check. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The undeclared tool call name if unauthorized, or undefined if permitted. + */ function undeclaredNameInItem( item: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(item)) return undefined; if (typeof item.type !== "string") return undefined; @@ -319,51 +373,232 @@ function undeclaredNameInItem( dottedAliasIsUnambiguous(item.namespace, name) && declared.has(dottedToolName(item.namespace, name)) ) return undefined; + const bareDeclared = declaredBare ?? declared; + const bare = name.startsWith("default.") ? name.slice("default.".length) : name; + if ( + item.namespace === "default" + && bare.length > 0 + && bareDeclared.has(bare) + && !declared.has(namespacedToolName(item.namespace, bare)) + && !declared.has(dottedToolName(item.namespace, bare)) + ) return undefined; return name; } - const effectiveName = normalizeDeclaredToolName(name, declared); + const effectiveName = normalizeDeclaredToolName(name, declared, declaredBare); if (declared.has(effectiveName)) return undefined; return name; } -/** First undeclared client tool named by a Responses SSE payload, or undefined. */ +/** + * First undeclared client tool named by a Responses SSE payload, or undefined. + * + * @param payload - The parsed SSE event payload. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The name of the first undeclared tool call, or undefined. + */ export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(payload)) return undefined; if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { - return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); + } + if (payload.type === "response.function_call_arguments.done" && typeof payload.name === "string") { + const fakeItem = { type: "function_call", name: payload.name, namespace: payload.namespace }; + return undeclaredNameInItem(fakeItem, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); } // Sparse gateways skip incremental items and only ever ship the terminal snapshot. if (payload.type === "response.completed" || payload.type === "response.incomplete") { - return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); } return undefined; } -/** First undeclared client tool in a Responses object's `output` array, or undefined. */ +/** + * First undeclared client tool in a Responses object's `output` array, or undefined. + * + * @param response - The Responses result object containing `output`. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The name of the first undeclared tool call, or undefined. + */ export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined; for (const item of response.output) { - const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); if (name !== undefined) return name; } return undefined; } +/** + * Formats an error message indicating that a routed provider emitted an undeclared tool call. + * + * @param name - The undeclared tool name emitted by the provider. + * @returns A formatted error message string. + */ export function undeclaredToolCallMessage(name: string): string { const reported = name.slice(0, MAX_REPORTED_NAME_CHARS); return `routed provider emitted undeclared client tool "${reported}"; only request-declared tools may be called`; } +/** + * Normalizes a single output item's default-namespaced tool call back to declared bare tool. + * + * Strips invented `default.` prefixes or `namespace: "default"` from tool calls when the bare + * tool name was declared and neither dotted nor flattened namespaced forms were declared (#4176). + * + * @param item - The output item to normalize. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized value and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInItem( + item: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(item)) return { value: item, changed: false }; + if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type as string)) { + return { value: item, changed: false }; + } + const name = item.name; + if (typeof name !== "string" || name.length === 0) { + return { value: item, changed: false }; + } + const bareDeclared = declaredBare ?? declared; + if (item.namespace === "default") { + const bare = name.startsWith("default.") ? name.slice("default.".length) : name; + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has(namespacedToolName("default", bare)) + && !declared.has(dottedToolName("default", bare)) + ) { + const next: Record = { ...(item as Record), name: bare }; + delete next.namespace; + return { value: next, changed: true }; + } + return { value: item, changed: false }; + } + if (item.namespace === undefined || item.namespace === BUILTIN_FUNCTIONS_NAMESPACE) { + if (name.startsWith("default.")) { + const bare = name.slice("default.".length); + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has("default." + bare) + && !declared.has("default__" + bare) + ) { + return { value: { ...item, name: bare }, changed: true }; + } + } + } + return { value: item, changed: false }; +} + +/** + * Normalizes default-namespaced tool calls in a Responses object's `output` array. + * + * @param response - The Responses result object containing `output`. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized response and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInResponse( + response: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(response) || !Array.isArray(response.output)) { + return { value: response, changed: false }; + } + let changed = false; + const newOutput = response.output.map(item => { + const res = normalizeDefaultNamespaceInItem(item, declared, declaredBare); + if (res.changed) changed = true; + return res.value; + }); + if (!changed) return { value: response, changed: false }; + return { value: { ...response, output: newOutput }, changed: true }; +} + +/** + * Normalizes default-namespaced tool calls in a Responses SSE payload object. + * + * @param payload - The parsed SSE event payload. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized payload and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInPayload( + payload: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(payload)) return { value: payload, changed: false }; + if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { + const res = normalizeDefaultNamespaceInItem(payload.item, declared, declaredBare); + if (!res.changed) return { value: payload, changed: false }; + return { value: { ...payload, item: res.value }, changed: true }; + } + if (payload.type === "response.function_call_arguments.done" && typeof payload.name === "string") { + const fakeItem = { type: "function_call", name: payload.name, namespace: payload.namespace }; + const res = normalizeDefaultNamespaceInItem(fakeItem, declared, declaredBare); + if (res.changed) { + const normalizedItem = res.value as Record; + const next: Record = { ...payload, name: normalizedItem.name }; + if ("namespace" in next && !("namespace" in normalizedItem)) { + delete next.namespace; + } + return { value: next, changed: true }; + } + } + if (payload.type === "response.completed" || payload.type === "response.incomplete") { + const res = normalizeDefaultNamespaceInResponse(payload.response, declared, declaredBare); + if (!res.changed) return { value: payload, changed: false }; + return { value: { ...payload, response: res.value }, changed: true }; + } + return { value: payload, changed: false }; +} + +/** + * Normalizes default-namespaced tool calls in a raw Responses JSON string. + * + * @param jsonText - Raw JSON string representing a Responses object. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The normalized JSON string, or original text if unchanged or invalid JSON. + */ +export function normalizeDefaultNamespaceInJson( + jsonText: string, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): string { + try { + const parsed = JSON.parse(jsonText); + const normalized = normalizeDefaultNamespaceInResponse(parsed, declared, declaredBare); + return normalized.changed ? JSON.stringify(normalized.value) : jsonText; + } catch { + return jsonText; + } +} + function failedBlocks(name: string, newline: string): readonly string[] { const failure = { type: "upstream_error", @@ -378,7 +613,8 @@ function failedBlocks(name: string, newline: string): readonly string[] { } /** - * Fail closed when a routed provider calls a tool the request never declared (#1700). + * Fail closed when a routed provider calls a tool the request never declared (#1700), + * and normalize provider-invented default namespaces back to declared bare tools (#4176). * * The bridged paths already refuse such a call (`declaredToolNames` in src/bridge.ts), but the * native Responses passthrough relayed it verbatim: Codex received a `function_call` for a tool @@ -389,11 +625,18 @@ function failedBlocks(name: string, newline: string): readonly string[] { * * Everything after the trip is dropped so a later `response.completed` cannot contradict the * terminal already sent. Non-JSON and non-item blocks pass through untouched. + * + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -406,9 +649,15 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); - if (name === undefined) return [block]; - tripped = true; - return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); + if (name !== undefined) { + tripped = true; + return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + } + const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare); + if (normalized.changed) { + return [replaceSseDataPayload(block, JSON.stringify(normalized.value))]; + } + return [block]; }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3f4cfe4345..1925ad7d91 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -424,10 +424,13 @@ import { type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; import { + collectDeclaredBareWireToolNames, collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, + normalizeDefaultNamespaceInJson, + normalizeDefaultNamespaceInResponse, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, undeclaredToolCallMessage, @@ -4714,6 +4717,7 @@ async function handleResponsesInner( ); const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody); const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody); + const clientDeclaredBareWireToolNames = collectDeclaredBareWireToolNames(clientToolAuthorizationBody); const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes( clientToolAuthorizationBody, ); @@ -4789,6 +4793,7 @@ async function handleResponsesInner( }; let outboundRequestBody: Record | undefined; const declaredWireToolNames = new Set(); + const declaredBareWireToolNames = new Set(); const declaredNamelessClientCallTypes = new Set(); // `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one // namespaced tool through a bare tool_choice. Restore that request-bounded identity before @@ -4838,12 +4843,17 @@ async function handleResponsesInner( // aliases are authoritative. A continuation's outbound body still contains historical // catalogs (and may promote historical tool-search definitions), so it can never widen the // current caller snapshot captured above. + declaredBareWireToolNames.clear(); if (replayedInputPrefixLength === 0) { for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { declaredWireToolNames.add(name); } + for (const name of collectDeclaredBareWireToolNames(outboundRequestBody)) { + declaredBareWireToolNames.add(name); + } } for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name); + for (const name of clientDeclaredBareWireToolNames) declaredBareWireToolNames.add(name); declaredNamelessClientCallTypes.clear(); if (replayedInputPrefixLength === 0) { for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) { @@ -4936,6 +4946,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) !== undefined) { inspectionSawUndeclaredTool = true; } @@ -4973,11 +4984,19 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) !== undefined ) { return; } - rememberPassthroughResponse?.(replayResponse); + const normalizedReplayResponse = (undeclaredToolGuardActive + ? normalizeDefaultNamespaceInResponse( + replayResponse, + declaredWireToolNames, + declaredBareWireToolNames, + ).value + : replayResponse) as typeof replayResponse; + rememberPassthroughResponse?.(normalizedReplayResponse); const firstCompletion = !inspectedCompletionSeen; inspectedCompletionSeen = true; if (firstCompletion && (inspectedTerminal === null || firstTerminalAllowsRecall)) { @@ -5998,6 +6017,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -6198,7 +6218,7 @@ async function handleResponsesInner( } const text = bounded.text; inspectResponseLogJson(logCtx, text); - const clientJson = (() => { + let clientJson = (() => { const restoredNamespace = restoreRoutedNamespaceCallsInJson( scrubSelfNamedToolCallNamespaceInJson( restoreImageGenCallsInJson(text, imageGenCallAliases), @@ -6244,6 +6264,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ); } catch { return undefined; @@ -6252,6 +6273,11 @@ async function handleResponsesInner( if (undeclared !== undefined) { return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); } + clientJson = normalizeDefaultNamespaceInJson( + clientJson, + declaredWireToolNames, + declaredBareWireToolNames, + ); } commitReasoningReplayServingRoute(); try { diff --git a/src/types/tools.ts b/src/types/tools.ts index bb91ebe63f..fcc0689819 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -66,22 +66,52 @@ const CODE_MODE_HELPER_TOOL_NAMES = [ */ export const CODE_MODE_EXEC_TOOL_NAME = "exec"; +/** + * Normalizes provider-emitted tool names against declared tool catalogs. + * + * Rewrites invented `default.` prefixes back to a declared bare tool when that bare tool + * is declared and neither `default.` nor `default__` was explicitly declared (#4176). + * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`) to + * `exec` when code-mode `exec` is declared in the request catalog. + * + * @param name - The tool name emitted on the wire by the provider. + * @param declared - All wire tool names declared in the request catalog, including aliases. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * When omitted, falls back to `declared`. + * @returns The normalized tool name to expose downstream. + */ export function normalizeDeclaredToolName( name: string, declared: ReadonlySet | undefined, + declaredBare?: ReadonlySet, ): string { - if (!declared || !declared.has(CODE_MODE_EXEC_TOOL_NAME)) return name; + if (!declared) return name; if (declared.has(name)) return name; - if (name === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; + let candidate = name; + if (name.startsWith("default.")) { + const bare = name.slice("default.".length); + const bareDeclared = declaredBare ?? declared; + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has("default." + bare) + && !declared.has("default__" + bare) + ) { + candidate = bare; + } + } + if (!declared.has(CODE_MODE_EXEC_TOOL_NAME)) return candidate; + if (declared.has(candidate)) return candidate; + if (candidate === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; // When the catalog explicitly declares any legacy shell bridge name, the environment // genuinely exposes that tool — turn normalization off so a call is never mis-routed // to `exec`. if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) { - return name; + return candidate; } - return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(name) + return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(candidate) ? CODE_MODE_EXEC_TOOL_NAME - : name; + : candidate; } /** diff --git a/tests/adapters/bridge-legacy-shell-normalization.test.ts b/tests/adapters/bridge-legacy-shell-normalization.test.ts index c6d5cdbe2d..0b4a94283c 100644 --- a/tests/adapters/bridge-legacy-shell-normalization.test.ts +++ b/tests/adapters/bridge-legacy-shell-normalization.test.ts @@ -87,6 +87,16 @@ describe("bridge normalizes code-mode helper names against the declared catalog" expect(sse).toContain("await tools.apply_patch"); }); + test("default.view_image echoes are normalized back to declared bare view_image (#4176)", async () => { + const sse = await drain(bridgeToResponsesSSE( + toolTurn("default.view_image", "{\"path\":\"image.png\"}"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, + { declaredToolNames: new Set(["view_image"]) }, + )); + expect(sse).not.toContain("undeclared client tool"); + expect(sse).toContain("\"name\":\"view_image\""); + expect(sse).toContain("image.png"); + }); + test("a catalog that declares exec_command itself is never rewritten", async () => { const sse = await drain(bridgeToResponsesSSE( toolTurn("exec_command"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, diff --git a/tests/config/yaml-fragment-source.test.ts b/tests/config/yaml-fragment-source.test.ts index 68450fb7f9..49112f49f0 100644 --- a/tests/config/yaml-fragment-source.test.ts +++ b/tests/config/yaml-fragment-source.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { patchYamlFragmentSource } from "../../src/integrations/omp-yaml-source"; +import { patchYamlFragmentSource, yamlFragmentUnsupportedStyle } from "../../src/integrations/omp-yaml-source"; const DSH_PATH = ["llm-pi-ai", "providers", "opencodex"] as const; const VALUE = { api: "openai-responses", baseURL: "http://127.0.0.1:10100/v1" }; @@ -31,6 +31,55 @@ describe("generic source-preserving YAML fragment mutation", () => { expect(patched).toEndWith("# tail\n"); }); + // #4260: the DSH toggle wrote nothing and blamed comments or formatting. Both + // shapes below are what a DSH-managed file looks like before any provider + // exists, and neither contains a comment. + test("adopts an empty providers container, whether block or inline", () => { + const expected = { + "llm-pi-ai": { providers: { opencodex: VALUE } }, + "ui-theme": { preference: "system" }, + }; + const sources = { + "valueless block key": "llm-pi-ai:\n providers:\nui-theme:\n preference: system\n", + "empty inline map": "llm-pi-ai:\n providers: {}\nui-theme:\n preference: system\n", + "empty inline map, inner space": "llm-pi-ai:\n providers: { }\nui-theme:\n preference: system\n", + }; + for (const [label, source] of Object.entries(sources)) { + const patched = upsert(source, expected); + expect(patched, label).not.toBeNull(); + expect(Bun.YAML.parse(patched!), label).toEqual(expected); + // The untouched sibling keeps its own bytes. + expect(patched, label).toContain("ui-theme:\n preference: system\n"); + expect(yamlFragmentUnsupportedStyle(source, DSH_PATH), label).toBe(false); + } + }); + + // Still refused — re-rendering a user's populated flow collection is exactly + // what this module exists not to do — but the cause is now nameable, so the + // caller stops pointing at a comment that is not there. + test("names a populated flow container as its own refusal cause", () => { + const multiline = [ + "llm-pi-ai:", + " providers:", + " {", + " native:", + " {", + " api: openai-completions", + " }", + " }", + "ui-theme:", + " preference: system", + "", + ].join("\n"); + const inline = "llm-pi-ai:\n providers: { native: { api: openai-completions } }\n"; + for (const source of [multiline, inline]) { + const expected = Bun.YAML.parse(source) as Record; + ((expected["llm-pi-ai"] as { providers: Record }).providers).opencodex = VALUE; + expect(upsert(source, expected)).toBeNull(); + expect(yamlFragmentUnsupportedStyle(source, DSH_PATH)).toBe(true); + } + }); + test("creates every missing container and preserves CRLF plus missing final newline", () => { const source = "agent-default-model: native\r\nother: keep"; const expected = { diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index 4687594379..89cfef37b7 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -641,6 +641,47 @@ describe("registry-owned provider model discovery", () => { } }); + // #4261: Antigravity is the one live-discovery row that never declared its own + // discovery spec, so the loop above did not cover it and the proof returned + // false for Antigravity's OWN canonical URL. Under a Clash/Surge/Mihomo TUN the + // benchmark fake-IP answer was then rejected and the model list came back empty. + // Pin all three halves: the declared spec is valid, the URL the adapter already + // sends is unchanged, and a custom base still fails the proof. + test("google-antigravity proves its own canonical CCA discovery RPC (#4261)", () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "google-antigravity"); + if (!entry?.modelDiscovery) throw new Error("google-antigravity must declare modelDiscovery"); + expect(providerModelDiscoverySpecError(entry.modelDiscovery)).toBeNull(); + + const seed = providerConfigSeed(entry); + const canonical = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + expect(resolveProviderModelDiscoveryUrl(entry.id, seed, entry.baseUrl, canonical)).toBe(canonical); + expect(isRegistryModelDiscoveryUrl(entry.id, canonical)).toBe(true); + // Declaring the spec must not move the request the adapter already made. + expect(buildModelsRequest(seed, "agy-access-token", entry.id)).toMatchObject({ + method: "POST", + url: canonical, + }); + + // allowBaseUrlOverride is set on this row, so a custom base must stay custom + // and must NOT inherit the fake-IP exception. + const custom = resolveProviderModelDiscoveryUrl( + entry.id, + { ...seed, baseUrl: "https://custom.example/proxy" }, + "https://custom.example/proxy", + "https://custom.example/proxy/v1internal:fetchAvailableModels", + ); + expect(custom).toBe("https://custom.example/proxy/v1internal:fetchAvailableModels"); + expect(isRegistryModelDiscoveryUrl(entry.id, custom)).toBe(false); + + for (const url of [ + "https://evil.example/v1internal:fetchAvailableModels", + `${canonical}?token=1`, + `${canonical}#frag`, + canonical.replace("https:", "http:"), + "https://daily-cloudcode-pa.googleapis.com/v1internal:other", + ]) expect(isRegistryModelDiscoveryUrl(entry.id, url)).toBe(false); + }); + // The resolver accepts an effective (possibly custom) baseUrl while the proof // must stay registry-owned: a custom destination that merely resembles the // registry shape must NOT gain the benchmark-address exception. Nebius opts diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index 41d8383a6c..6dd680c9d2 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -7,7 +7,11 @@ import { describe, expect, test } from "bun:test"; import { collectDeclaredNamelessClientCallTypes, + collectDeclaredBareWireToolNames, collectDeclaredWireToolNames, + normalizeDefaultNamespaceInJson, + normalizeDefaultNamespaceInPayload, + normalizeDefaultNamespaceInResponse, collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, @@ -63,6 +67,7 @@ async function relay( upstream: string, declared: Iterable, declaredNamelessClientCallTypes: Iterable = [], + declaredBare?: Iterable, ): Promise { const budget = createTestTranslatorBudget(); try { @@ -71,6 +76,8 @@ async function relay( createUndeclaredToolCallGuardBlockRewrite( new Set(declared), new Set(declaredNamelessClientCallTypes), + undefined, + declaredBare ? new Set(declaredBare) : undefined, ), budget, )); @@ -79,6 +86,33 @@ async function relay( } } +describe("collectDeclaredBareWireToolNames", () => { + test("collects top-level bare tools and functions namespace, ignoring other namespaces and flattened/dotted names", () => { + const names = collectDeclaredBareWireToolNames({ + tools: [ + { type: "function", name: "view_image" }, + { type: "custom", name: "exec" }, + { type: "function", name: "foo__tool" }, + { type: "function", name: "foo.tool" }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "shell" }, { type: "function", name: "bar.baz" }] }, + { type: "namespace", name: "linear", tools: [{ type: "function", name: "create_issue" }] }, + ], + input: [ + { + type: "additional_tools", + tools: [{ type: "function", name: "extra_tool" }, { type: "function", name: "pkg__sub" }], + }, + ], + }); + expect([...names].sort()).toEqual(["exec", "extra_tool", "shell", "view_image"]); + }); + + test("returns empty set for invalid or missing body", () => { + expect(collectDeclaredBareWireToolNames(null).size).toBe(0); + expect(collectDeclaredBareWireToolNames({}).size).toBe(0); + }); +}); + describe("collectDeclaredWireToolNames", () => { test("reads function, custom, and namespaced tools off the outbound body", () => { const names = collectDeclaredWireToolNames({ @@ -436,6 +470,235 @@ describe("undeclared tool call guard", () => { expect(await relay(upstream, ["linear.create_issue"])).toBe(upstream); }); + test("accepts and rewrites dotted default.view_image back to bare view_image in SSE added and done items (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + // output_item.added + const upstreamAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const expectedAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: "{}" }, + }); + expect(await relay(upstreamAdded, declared, [], declaredBare)).toBe(expectedAdded); + + // output_item.done with custom args + const upstreamDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: JSON.stringify({ path: "/tmp/img.png" }) }, + }); + const expectedDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ path: "/tmp/img.png" }) }, + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("accepts and rewrites default. prefix in response.function_call_arguments.done SSE event (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamDone = sse("response.function_call_arguments.done", { + item_id: "item_1", + output_index: 0, + call_id: "call_1", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/img.png" }), + }); + const expectedDone = sse("response.function_call_arguments.done", { + item_id: "item_1", + output_index: 0, + call_id: "call_1", + name: "view_image", + arguments: JSON.stringify({ path: "/tmp/img.png" }), + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("accepts and rewrites namespace: 'default' with bare name back to bare tool (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + const expectedAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + expect(await relay(upstreamAdded, declared, [], declaredBare)).toBe(expectedAdded); + + const upstreamDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + const expectedDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("rewrites terminal snapshots (completed/incomplete) in SSE streams (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamCompleted = sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + { type: "function_call", id: "fc_2", call_id: "call_2", namespace: "default", name: "view_image", arguments: "{}" }, + ], + }, + }); + const expectedCompleted = sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: "{}" }, + { type: "function_call", id: "fc_2", call_id: "call_2", name: "view_image", arguments: "{}" }, + ], + }, + }); + expect(await relay(upstreamCompleted, declared, [], declaredBare)).toBe(expectedCompleted); + }); + + test("does not rewrite default.view_image to bare when default.view_image is explicitly declared (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }, { type: "function", name: "default.view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + expect(await relay(upstream, declared, [], declaredBare)).toBe(upstream); + }); + + test("preserves namespaced default__view_image over bare normalization (#4176)", async () => { + const outbound = { + tools: [ + { type: "function", name: "view_image" }, + { type: "namespace", name: "default", tools: [{ type: "function", name: "view_image" }] }, + ], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + expect(await relay(upstream, declared, [], declaredBare)).toBe(upstream); + }); + + test("rejects default. prefix when the bare tool was not declared (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "list_dir" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const out = await relay(upstream, declared, [], declaredBare); + expect(out).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("rejects default.view_image and namespace=default when only a different namespaced tool was declared (#4176)", async () => { + const outbound = { + tools: [ + { type: "namespace", name: "foo", tools: [{ type: "function", name: "view_image" }] }, + ], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamDotted = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const outDotted = await relay(upstreamDotted, declared, [], declaredBare); + expect(outDotted).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + + const upstreamNs = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: "{}" }, + }); + const outNs = await relay(upstreamNs, declared, [], declaredBare); + expect(outNs).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("normalizes default namespace in non-streaming JSON responses (#4176)", () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const jsonInput = JSON.stringify({ + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: JSON.stringify({ path: "img.png" }) }, + { type: "function_call", id: "fc_2", call_id: "call_2", namespace: "default", name: "view_image", arguments: "{}" }, + ], + }); + const normalized = normalizeDefaultNamespaceInJson(jsonInput, declared, declaredBare); + const parsed = JSON.parse(normalized); + expect(parsed.output[0]).toEqual({ + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "view_image", + arguments: JSON.stringify({ path: "img.png" }), + }); + expect(parsed.output[1]).toEqual({ + type: "function_call", + id: "fc_2", + call_id: "call_2", + name: "view_image", + arguments: "{}", + }); + }); + + test("does not normalize non-streaming JSON when bare tool was not declared (#4176)", () => { + const outbound = { + tools: [{ type: "namespace", name: "foo", tools: [{ type: "function", name: "view_image" }] }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const jsonInput = JSON.stringify({ + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + ], + }); + const normalized = normalizeDefaultNamespaceInJson(jsonInput, declared, declaredBare); + expect(normalized).toBe(jsonInput); + expect(undeclaredToolCallNameInResponse(JSON.parse(normalized), declared, [], undefined, declaredBare)).toBe("default.view_image"); + }); + test("never blocks apply_patch when the request really declared it", async () => { // `apply_patch` is exempt from the routed custom-tool rewrite, so it reaches upstream as // `{type:"custom"}` and comes back as a `custom_tool_call`. A request that declares it must @@ -829,6 +1092,202 @@ describe("a refused turn does not become continuation state", () => { }); }); +describe("real relay and continuation caller normalization (#4176 / #4181)", () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + test("Turn 1 stream normalizes default.view_image, and Turn 2 continuation expands normalized replay", async () => { + const originalFetch = globalThis.fetch; + const capturedOutbound: Array> = []; + let turn = 1; + + globalThis.fetch = (async (_input, init) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + capturedOutbound.push(body); + + if (turn === 2) { + return Response.json({ + id: "resp_turn2", + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "text", text: "image processed" }] }], + }); + } + turn++; + + const toolCall = { + type: "function_call", + id: "fc_img_1", + call_id: "call_img_1", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/sample.png" }), + status: "completed", + }; + const sse = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_turn1", status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { ...toolCall, arguments: "", status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: toolCall })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_turn1", status: "completed", output: [toolCall] } })}\n\n`, + "data: [DONE]\n\n", + ].join(""); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + try { + // 1. Turn 1 (stream): Client declares bare tool 'view_image'. + // Upstream sends SSE stream containing 'default.view_image' with call_id 'call_img_1'. + // Client receives normalized 'view_image' with 'call_img_1' and no 'default.view_image'. + const turn1Req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect this image" }] }], + tools: [{ type: "function", name: "view_image", parameters: { type: "object" } }], + }), + }); + + const turn1Res = await handleResponses(turn1Req, config, { model: "", provider: "" }); + expect(turn1Res.status).toBe(200); + const clientStreamText = await turn1Res.text(); + + expect(clientStreamText).toContain('"name":"view_image"'); + expect(clientStreamText).toContain('"call_id":"call_img_1"'); + expect(clientStreamText).not.toContain("default.view_image"); + expect(clientStreamText).not.toContain("response.failed"); + + // Wait briefly for background stream inspector tee to commit normalized response state + await Bun.sleep(50); + + // 2. Turn 2: Client continuation with previous_response_id and function_call_output for 'call_img_1'. + // Outbound request to upstream expands the replayed tool call with normalized name 'view_image' and matching 'call_img_1'. + const turn2Req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + previous_response_id: "resp_turn1", + input: [ + { + type: "function_call_output", + call_id: "call_img_1", + output: JSON.stringify({ width: 800, height: 600 }), + }, + ], + tools: [{ type: "function", name: "view_image", parameters: { type: "object" } }], + }), + }); + + const turn2Res = await handleResponses(turn2Req, config, { model: "", provider: "" }); + expect(turn2Res.status).toBe(200); + await turn2Res.json(); + + expect(capturedOutbound.length).toBe(2); + const turn2Outbound = capturedOutbound[1]; + const replayedToolCall = (turn2Outbound.input as Array>)?.find( + item => item.call_id === "call_img_1", + ); + expect(replayedToolCall).toBeDefined(); + expect(replayedToolCall).toMatchObject({ + type: "function_call", + id: "fc_img_1", + call_id: "call_img_1", + name: "view_image", + }); + expect(JSON.stringify(turn2Outbound)).not.toContain("default.view_image"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("negative control: request declaring only 'foo__view_image', upstream returning 'default.view_image' is rejected with 502", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + id: "resp_foo", + status: "completed", + output: [{ + type: "function_call", + id: "fc_img_bad", + call_id: "call_img_bad", + name: "default.view_image", + arguments: "{}", + status: "completed", + }], + })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect" }] }], + tools: [{ type: "function", name: "foo__view_image", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "default.view_image"'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("negative control: request explicitly declaring 'default.view_image' preserves 'default.view_image'", async () => { + const originalFetch = globalThis.fetch; + const toolCall = { + type: "function_call", + id: "fc_img_explicit", + call_id: "call_img_explicit", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/explicit.png" }), + status: "completed", + }; + + globalThis.fetch = (async () => Response.json({ + id: "resp_explicit", + status: "completed", + output: [toolCall], + })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect" }] }], + tools: [{ type: "function", name: "default.view_image", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ + type: "function_call", + name: "default.view_image", + call_id: "call_img_explicit", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + describe("empty and absent tool catalogs", () => { const config = { port: 0,