diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 901c75b2425..94c006e724f 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -771,7 +771,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys) - CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys) - **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed. -- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. +- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. If the CLI writes an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. - **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement. ### Official Qoder CLI (Global & CN) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d7aeb3c43c6..89d2a010e6c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -793,6 +793,7 @@ "key-login-preserves-model-costs.test.ts": "oauth", "keyring-smoke.test.ts": "ci-workflows", "kimi-oauth-identity.test.ts": "providers", + "kimi-responses-adjacency.test.ts": "providers", "kiro-account-quota.test.ts": "providers/kiro", "kiro-adapter.test.ts": "providers/kiro", "kiro-auth-context-continuation.test.ts": "providers/kiro", @@ -993,6 +994,7 @@ "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", + "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 234e06907e3..a769ac37dae 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn"; import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles"; +import { guardCodeBuddyScaffolding } from "./scaffold-guard"; export type { SpawnFn } from "../coding-agent/turn"; export type CodeBuddyAdapterDeps = CodingAgentDeps; @@ -75,7 +76,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu provider, parsed, incoming, - emit, + emit: guardCodeBuddyScaffolding(emit), buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), deps, diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts new file mode 100644 index 00000000000..6f8c80aed57 --- /dev/null +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -0,0 +1,248 @@ +import type { AdapterEvent } from "../../types"; + +/** Error code for a CodeBuddy turn whose output contains vendor agent scaffolding. */ +export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; + +// The observed control protocol uses FULLWIDTH VERTICAL LINE (U+FF5C). Detection stays +// deliberately narrower than the marker spelling: a calls control line must be followed by an +// invoke line for a functions.* tool. That distinguishes an agent scaffold from prose quoting or +// discussing one tag. +const DSML_CALLS_LINE = "<||dsml|| calls>"; +const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions."; + +export interface CodeBuddyScaffoldFilterResult { + /** Bytes released from a suffix withheld by an earlier event on this channel. */ + releasedPending: string; + /** Safe bytes belonging to the event currently being processed. */ + text: string; + /** The earlier pending event still owns the extended candidate. */ + pendingContinues: boolean; + fail: boolean; +} + +interface ScanResult { + safe: string; + held: string; + fail: boolean; + fence: "`" | "~" | null; + lineStart: boolean; +} + +function prefixAtEnd(text: string, at: number, expected: string): boolean { + const rest = text.slice(at).toLowerCase(); + return rest.length < expected.length && expected.startsWith(rest); +} + +/** + * Scan complete bytes and retain only a bounded suffix that can still become a control sequence. + * + * Control tags are recognized only at column zero and outside fenced Markdown. Inline code, + * quoted strings, blockquotes, indented source, and prose all add syntax before the tag and are + * therefore forwarded unchanged. A calls line alone is harmless; refusal requires the observed + * two-line calls-plus-functions-invoke grammar. + */ +function scan( + text: string, + initialFence: "`" | "~" | null, + initialLineStart: boolean, +): ScanResult { + let fence = initialFence; + let lineStart = initialLineStart; + let index = 0; + + while (index < text.length) { + if (lineStart) { + const fenceMarkers = fence ? [fence.repeat(3)] : ["```", "~~~"]; + const completeFence = fenceMarkers.find(marker => text.startsWith(marker, index)); + if (completeFence) { + fence = fence ? null : (completeFence[0] as "`" | "~"); + index += completeFence.length; + lineStart = false; + continue; + } + if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (!fence) { + const lowered = text.slice(index).toLowerCase(); + if (lowered.startsWith(DSML_CALLS_LINE)) { + const afterCalls = index + DSML_CALLS_LINE.length; + let invokeAt = -1; + if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; + else if (text[afterCalls] === "\r" && text[afterCalls + 1] === "\n") invokeAt = afterCalls + 2; + else if (afterCalls === text.length || (text[afterCalls] === "\r" && afterCalls + 1 === text.length)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (invokeAt >= 0) { + const invokeRest = text.slice(invokeAt).toLowerCase(); + if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) { + return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; + } + if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } + + const char = text[index]!; + index += 1; + lineStart = char === "\n"; + } + + return { safe: text, held: "", fail: false, fence, lineStart }; +} + +/** Streaming DSML control-sequence filter for one text or reasoning channel. */ +export class CodeBuddyScaffoldFilter { + private pending = ""; + private failed = false; + private fence: "`" | "~" | null = null; + private lineStart = true; + + /** True while an earlier event owns an unresolved marker or fence prefix. */ + hasPending(): boolean { + return this.pending.length > 0; + } + + push(chunk: string): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + if (!chunk) { + return { + releasedPending: "", + text: "", + pendingContinues: this.hasPending(), + fail: false, + }; + } + + const priorPending = this.pending; + const result = scan(priorPending + chunk, this.fence, this.lineStart); + this.pending = result.held; + this.fence = result.fence; + this.lineStart = result.lineStart; + this.failed = result.fail; + + const releasedLength = Math.min(priorPending.length, result.safe.length); + return { + releasedPending: result.safe.slice(0, releasedLength), + text: result.safe.slice(releasedLength), + pendingContinues: priorPending.length > 0 && result.safe.length === 0 && result.held.length > 0, + fail: result.fail, + }; + } + + /** Release a suffix that never completed the two-line control grammar. */ + flush(): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + const text = this.pending; + this.pending = ""; + return { releasedPending: text, text: "", pendingContinues: false, fail: false }; + } +} + +function codeBuddyScaffoldErrorMessage(): string { + return "CodeBuddy CLI emitted vendor tool-call markup in an assistant output channel. This route" + + " runs the CLI with its own tools and MCP servers disabled and Codex owns tool control, so" + + " the turn was refused rather than forwarding or executing vendor agent scaffolding."; +} + +/** Guard both streamed channels while preserving event order around withheld marker prefixes. */ +export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new CodeBuddyScaffoldFilter(); + const thinkingFilter = new CodeBuddyScaffoldFilter(); + type PendingChannel = "text" | "thinking"; + type EventSlot = { resolved: boolean; event?: AdapterEvent }; + const eventQueue: EventSlot[] = []; + const pendingSlots = new Map(); + let closed = false; + + const channelEvent = (channel: PendingChannel, text: string): AdapterEvent => channel === "text" + ? { type: "text_delta", text } + : { type: "thinking_delta", thinking: text }; + + const drainResolved = (): void => { + while (eventQueue[0]?.resolved) { + const slot = eventQueue.shift()!; + if (slot.event) emit(slot.event); + } + }; + + const enqueueResolved = (event: AdapterEvent): void => { + eventQueue.push({ resolved: true, event }); + drainResolved(); + }; + + const resolvePendingSlot = (channel: PendingChannel, text: string): void => { + const slot = pendingSlots.get(channel); + if (!slot) return; + slot.resolved = true; + if (text) slot.event = channelEvent(channel, text); + pendingSlots.delete(channel); + drainResolved(); + }; + + const enqueuePendingSlot = (channel: PendingChannel): void => { + const slot: EventSlot = { resolved: false }; + eventQueue.push(slot); + pendingSlots.set(channel, slot); + }; + + const flushAllPending = (): void => { + for (const channel of ["text", "thinking"] as const) { + if (!pendingSlots.has(channel)) continue; + const filter = channel === "text" ? textFilter : thinkingFilter; + resolvePendingSlot(channel, filter.flush().releasedPending); + } + drainResolved(); + }; + + const refuse = (): void => { + if (closed) return; + flushAllPending(); + closed = true; + emit({ + type: "error", + message: codeBuddyScaffoldErrorMessage(), + status: 502, + errorType: "upstream_error", + code: CODEBUDDY_SCAFFOLD_ERROR_CODE, + retryable: false, + }); + }; + + return (event: AdapterEvent): void => { + if (closed) return; + if (event.type === "text_delta" || event.type === "thinking_delta") { + const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; + const filter = channel === "text" ? textFilter : thinkingFilter; + const hadPending = filter.hasPending(); + const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); + if (hadPending && !cleaned.pendingContinues) resolvePendingSlot(channel, cleaned.releasedPending); + if (cleaned.text) { + enqueueResolved(event.type === "text_delta" + ? { ...event, text: cleaned.text } + : { ...event, thinking: cleaned.text }); + } + if (filter.hasPending() && !cleaned.pendingContinues) enqueuePendingSlot(channel); + if (cleaned.fail) refuse(); + return; + } + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + flushAllPending(); + closed = true; + emit(event); + return; + } + enqueueResolved(event); + }; +} diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 8503210e465..d74640a3e23 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -6,7 +6,6 @@ import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; -import { frameAgentRouterMessages } from "./agentrouter"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; import { fastPolicyForModel } from "../providers/service-tier"; @@ -39,6 +38,7 @@ import { upstreamErrorEvent, } from "./openai-chat/errors"; import { messagesToChatFormat } from "./openai-chat/messages"; +import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; @@ -88,7 +88,7 @@ function canSerializeOpenAIChatServiceTier( export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { let lastRequestedModelId: string | undefined; - return { + return withOpenAIChatToolNames(toolNames => ({ name: "openai-chat", formatErrorBody: formatOpenAIChatErrorBody, @@ -96,10 +96,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) { lastRequestedModelId = parsed.modelId; const { url, headers, hasCredential } = openAIChatTransport(provider); - const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider)); + const messages = toolNames.messages(parsed, provider.baseUrl, messagesToChatFormat(parsed, provider)); const finish = (): AdapterRequest => { - const tools = toolsToChatFormatForProvider(parsed, provider); - const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider); + const tools = toolsToChatFormatForProvider(parsed, provider, toolNames.registry()); + const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider, toolNames.registry()); const body: Record = { model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId, @@ -365,7 +365,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd return "terminate"; } if (!call.id) call.id = `call_${++toolCallSeq}`; - yield { type: "tool_call_start", id: call.id, name: call.name }; + yield { type: "tool_call_start", id: call.id, name: toolNames.restore(call.name) }; if (call.args.length > 0) yield { type: "tool_call_delta", arguments: call.args }; yield { type: "tool_call_end" }; } @@ -801,7 +801,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } - events.push({ type: "tool_call_start", id, name }); + events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); events.push({ type: "tool_call_delta", arguments: args }); events.push({ type: "tool_call_end" }); } @@ -818,5 +818,5 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.releaseRetained(responseBytes, { kind: "retained_collectors" }); } }, - }; + })); } diff --git a/src/adapters/openai-chat/tool-name-registry.ts b/src/adapters/openai-chat/tool-name-registry.ts new file mode 100644 index 00000000000..c9c8e1fa5ff --- /dev/null +++ b/src/adapters/openai-chat/tool-name-registry.ts @@ -0,0 +1,166 @@ +import { createHash } from "node:crypto"; +import { namespacedToolName, type OcxParsedRequest, type OcxTool } from "../../types"; +import { frameAgentRouterMessages } from "../agentrouter"; + +const MAX_CHAT_TOOL_NAME_LENGTH = 64; +const ALIAS_HINT_CHARS = 16; +const RESERVED_ALIAS_PATTERN = /^ocx_[a-zA-Z0-9_-]{16}_[a-zA-Z0-9_-]{43}$/; +type ToolIdentity = Readonly>; + +export interface OpenAIChatToolNameRegistry { + alias(tool: ToolIdentity): string; + aliasWireName(wireName: string): string; + restore(wireName: string): string; +} + +interface OpenAIChatToolNameScope { + messages(parsed: OcxParsedRequest, baseUrl: string, messages: readonly unknown[]): unknown; + registry(): OpenAIChatToolNameRegistry; + restore(wireName: string): string; +} + +function identityKey(tool: ToolIdentity): string { + return JSON.stringify([tool.namespace ?? null, tool.name]); +} + +function boundedAlias(tool: ToolIdentity, wireName: string): string { + const hint = wireName + .replace(/[^a-zA-Z0-9_-]/g, "_") + .slice(-ALIAS_HINT_CHARS) + .padStart(ALIAS_HINT_CHARS, "_"); + const key = identityKey(tool); + const digest = createHash("sha256") + .update(key) + .digest("base64url"); + return `ocx_${hint}_${digest}`; +} + +/** Catalog declarations plus structured calls retained in replay history. */ +export function openAIChatToolNameIdentities(parsed: OcxParsedRequest): ToolIdentity[] { + const identities: ToolIdentity[] = [...(parsed.context.tools ?? [])]; + for (const message of parsed.context.messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part.type !== "toolCall") continue; + identities.push({ + name: part.name, + ...(part.namespace === undefined ? {} : { namespace: part.namespace }), + }); + } + } + return identities; +} + +/** + * One collision domain for a translated Chat Completions request. + * + * Namespaced names whose flattened spelling exceeds Chat Completions' 64-character function-name + * bound are rewritten. Ordinary names and bare names pass through byte-for-byte unless they occupy + * the reserved alias spelling; those are re-aliased so no declaration can shadow another identity's + * deterministic alias. Distinct identities sharing one flattened spelling each keep an identity + * alias, while replay rewriting leaves that ambiguous spelling untouched. Echoed aliases restore to + * the original flattened name consumed by the Responses bridge's existing namespace map. + */ +export function createOpenAIChatToolNameRegistry( + tools: readonly ToolIdentity[] | undefined, +): OpenAIChatToolNameRegistry { + const identities = new Map(); + for (const tool of tools ?? []) identities.set(identityKey(tool), tool); + const sortedIdentities = [...identities.entries()] + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + + const aliasesByIdentity = new Map(); + const aliasesByWireName = new Map(); + const originalsByAlias = new Map(); + const wireOwners = new Map(); + for (const [key, tool] of sortedIdentities) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const owner = wireOwners.get(wireName); + if (owner === undefined) wireOwners.set(wireName, key); + else if (owner !== key) wireOwners.set(wireName, null); + } + + const aliasOwners = new Map(); + const wireClaims = new Map(); + for (const [key, tool] of sortedIdentities) { + const wireName = namespacedToolName(tool.namespace, tool.name); + const candidate = (tool.namespace !== undefined && wireName.length > MAX_CHAT_TOOL_NAME_LENGTH) + || RESERVED_ALIAS_PATTERN.test(wireName) + || wireOwners.get(wireName) === null + ? boundedAlias(tool, wireName) + : wireName; + // A full SHA-256 collision is not safely attributable. Keep the later identity's native + // spelling instead of failing the request or stealing the first identity's restore entry. + const alias = aliasOwners.has(candidate) ? wireName : candidate; + aliasesByIdentity.set(key, alias); + if (!aliasOwners.has(alias)) aliasOwners.set(alias, key); + if (alias !== wireName) originalsByAlias.set(alias, wireName); + + const claim = wireClaims.get(wireName); + if (claim === undefined) wireClaims.set(wireName, { key, alias }); + else if (claim !== null && claim.key !== key) wireClaims.set(wireName, null); + } + for (const [wireName, claim] of wireClaims) { + if (claim !== null) aliasesByWireName.set(wireName, claim.alias); + } + + return { + alias(tool: ToolIdentity): string { + const key = identityKey(tool); + const known = aliasesByIdentity.get(key); + if (known !== undefined) return known; + return namespacedToolName(tool.namespace, tool.name); + }, + aliasWireName(wireName: string): string { + return aliasesByWireName.get(wireName) ?? wireName; + }, + restore(wireName: string): string { + return originalsByAlias.get(wireName) ?? wireName; + }, + }; +} + +export function restoreOpenAIChatToolName( + registry: OpenAIChatToolNameRegistry, + wireName: string, +): string { + return registry.restore(wireName); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Rewrite replayed assistant tool calls after the ordinary message converter has flattened them. */ +export function aliasOpenAIChatMessageToolNames( + messages: readonly unknown[], + registry: OpenAIChatToolNameRegistry, +): unknown[] { + return messages.map(message => { + if (!isRecord(message) || !Array.isArray(message.tool_calls)) return message; + let changed = false; + const toolCalls = message.tool_calls.map(toolCall => { + if (!isRecord(toolCall) || !isRecord(toolCall.function) + || typeof toolCall.function.name !== "string") return toolCall; + const name = registry.aliasWireName(toolCall.function.name); + if (name === toolCall.function.name) return toolCall; + changed = true; + return { ...toolCall, function: { ...toolCall.function, name } }; + }); + return changed ? { ...message, tool_calls: toolCalls } : message; + }); +} + +export function withOpenAIChatToolNames( + build: (scope: OpenAIChatToolNameScope) => T, +): T { + let registry = createOpenAIChatToolNameRegistry(undefined); + return build({ + messages(parsed, baseUrl, messages): unknown { + registry = createOpenAIChatToolNameRegistry(openAIChatToolNameIdentities(parsed)); + return frameAgentRouterMessages(baseUrl, aliasOpenAIChatMessageToolNames(messages, registry)); + }, + registry: () => registry, + restore: wireName => restoreOpenAIChatToolName(registry, wireName), + }); +} diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts index c056a6a043a..23f77121bd5 100644 --- a/src/adapters/openai-chat/tool-schema.ts +++ b/src/adapters/openai-chat/tool-schema.ts @@ -1,7 +1,8 @@ import { isNativeOpenAIChatTarget } from "./wire"; +import { createOpenAIChatToolNameRegistry, type OpenAIChatToolNameRegistry } from "./tool-name-registry"; import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } from "../xai-tool-schema"; import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../responses-tool-schema"; -import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; +import { isAllowedToolChoice, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); @@ -409,7 +410,11 @@ function normalizeMoonshotToolParameters(parameters: unknown): Record 0 ? formatted : undefined; } -export function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { - const base = toolsToChatFormat(parsed, provider); +export function toolsToChatFormatForProvider( + parsed: OcxParsedRequest, + provider: OcxProviderConfig, + registry: OpenAIChatToolNameRegistry = createOpenAIChatToolNameRegistry(parsed.context.tools), +): unknown[] | undefined { + const base = toolsToChatFormat(parsed, provider, registry); const azureChat = isAzureOpenAiChatTarget(provider); const zenChat = shouldSanitizeZenToolParameters(provider); if (!base || (!zenChat && !azureChat)) return base; @@ -463,15 +472,24 @@ export function toolChoiceToChatFormat( tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"], provider: OcxProviderConfig, + registry: OpenAIChatToolNameRegistry = createOpenAIChatToolNameRegistry(tools), ): unknown { if (!tc) return undefined; if (isAllowedToolChoice(tc)) { if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) { - return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } }; + return { + type: "function", + function: { name: registry.aliasWireName(resolveToolChoiceWireName(tools, tc.allowedTools[0])) }, + }; } return tc.mode === "required" ? "required" : "auto"; } if (tc === "auto" || tc === "none" || tc === "required") return tc; - if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; + if ("name" in tc) { + return { + type: "function", + function: { name: registry.aliasWireName(resolveToolChoiceWireName(tools, tc.name)) }, + }; + } return undefined; } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index e4904846517..471e16534ca 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -47,7 +47,7 @@ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry"; -import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; +import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; import { resolveProviderTransport } from "../providers/xai-transport"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; @@ -1228,7 +1228,7 @@ export function buildModelsRequest( return { url: discoveryUrl(`${base}/v1/models?limit=1000`), headers }; } if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - return { url: discoveryUrl(`${effectiveProvider.baseUrl}/models`), headers }; + return { url: discoveryUrl(providerModelsUrl(effectiveProvider.baseUrl)), headers }; } /** diff --git a/src/oauth/key-providers.ts b/src/oauth/key-providers.ts index f147b6a44ff..29386cb8b6e 100644 --- a/src/oauth/key-providers.ts +++ b/src/oauth/key-providers.ts @@ -1,6 +1,6 @@ import type { OcxProviderConfig } from "../types"; import { deriveKeyLoginMap, enrichProviderFromRegistry, type DerivedKeyLoginProvider } from "../providers/derive"; -import { resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; +import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery"; /** * API-key "login" providers: not OAuth — the flow opens the provider's dashboard so the user can @@ -117,7 +117,7 @@ export async function validateApiKey( providerName, configuredProvider, provider.baseUrl, - `${provider.baseUrl}/models`, + providerModelsUrl(provider.baseUrl), ); const res = await fetch(modelsUrl, { headers: { Authorization: `Bearer ${key}` }, diff --git a/src/providers/model-discovery.ts b/src/providers/model-discovery.ts index a0718ac0684..78b68e55ef7 100644 --- a/src/providers/model-discovery.ts +++ b/src/providers/model-discovery.ts @@ -23,6 +23,8 @@ import { const MODEL_DISCOVERY_MAX_FILTER_VALUES = 256; const MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH = 1_024; +const TRAILING_SLASHES = /\/+$/; +const TRAILING_MODELS = /\/models$/; export interface ResolvedProviderModelDiscovery { spec?: ProviderModelDiscoverySpec; @@ -50,6 +52,20 @@ export type ModelEnvelopeRowsResult = | { ok: true; rows: unknown[] } | { ok: false; reason: "invalid_shape" | "too_many_models" }; +/** + * Build the default OpenAI-compatible model-discovery URL from a configured baseUrl. + * + * `baseUrl` is required on both `OcxProviderConfig` and the persisted-config schema, so a row + * without one is not a state configuration loading can produce. It is deliberately not tolerated + * here: the old template-literal join silently produced `"undefined/models"`, which is not a usable + * fallback either — it only ever survived because a static row returns before the URL is parsed. + */ +export function providerModelsUrl(baseUrl: string): string { + const trimmed = baseUrl.trim().replace(TRAILING_SLASHES, ""); + const withoutEndpoint = trimmed.replace(TRAILING_MODELS, ""); + return `${withoutEndpoint}/models`; +} + function positiveIntegerAtMost(value: number | undefined, hardLimit: number): number { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return hardLimit; return Math.min(Math.floor(value), hardLimit); diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts index 07689dfdcba..54196adda1b 100644 --- a/src/providers/registry/entries-core.ts +++ b/src/providers/registry/entries-core.ts @@ -423,6 +423,10 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ // or the one the Claude /v1/messages inbound derives); the adapter itself never invents one. // Evidence: https://platform.kimi.com/docs/api/chat promptCacheKey: true, + // Kimi's Responses endpoint rejects hook-provided context between a tool call and + // its matching result (#4726), the same strict shape DeepSeek exposed in #1292. + // The flag is inert while this preset uses the Chat wire. + requiresAdjacentResponsesToolResults: true, featured: true, oauthId: "kimi", jawcodeBundle: "moonshot", diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts index 5cea61a84ff..17916596155 100644 --- a/src/providers/registry/entries-extended.ts +++ b/src/providers/registry/entries-extended.ts @@ -890,6 +890,8 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ modelSuffixBracketStrip: true, // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth. promptCacheKey: true, + // Keep Responses tool-result adjacency aligned with the OAuth preset (#4726). + requiresAdjacentResponsesToolResults: true, models: KIMI_CODING_MODELS, modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index e9a780ee532..381276d04b4 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -62,10 +62,20 @@ request-local alias. Raw API-key continuations deliberately preserve ids because continuation may reference a call stored upstream under its original id; proxy-expanded API-key replays are explicit and receive the same repair. -Separately, Meta Muse Responses (`src/responses/muse-tool-name-alias.ts`) aliases function *tool -names* that exceed 64 characters or contain characters outside `[a-zA-Z0-9_-]` on `api.meta.ai` -only. That map is not the call-id repair: it covers tools, `additional_tools`, history calls, and -`tool_choice`, then restores original names inbound. +Tool-name normalization stays adapter-scoped. The translated Chat Completions path uses a +request-scoped registry in `src/adapters/openai-chat/`: only flattened namespaced names over 64 +characters receive a deterministic, charset-safe alias. Catalog declarations, replayed calls and +`tool_choice` share that registry, and streamed or buffered echoes restore to the original flattened +name before the Responses bridge restores `{namespace, name}`. Names at or below the bound and bare +names pass through unchanged, except declarations matching the reserved alias shape; those are +re-aliased so they cannot shadow an identity-derived alias. + +The 64-character bound is a Chat Completions and strict-gateway compatibility concern: Command Code +rejects a 66-character function name (#4679). Upstream Codex raised its own MCP ceiling to 128 bytes +in `openai/codex#39594` because native Responses accepts 128, so that Responses limit does not govern +this translated wire. Kiro (`src/adapters/kiro-tools.ts`), Google (its wire compiler), and Meta Muse +Responses (`src/responses/muse-tool-name-alias.ts`, gated to `api.meta.ai`) each retain their own +equivalent normalization and restoration. These compatibility guards are covered by focused tests and should stay close to the adapters that need them. @@ -131,6 +141,14 @@ dropped. This preserves #1292's single-call adjacency repair without splitting a batch away from its preceding plaintext reasoning (#1477). Tolerant providers never enter this pass, and duplicate, missing, or backwards call/result pairs are left for the upstream to reject rather than guessed. +That pass is gated by `requiresAdjacentResponsesToolResults`, not by provider name. Kimi's Code Plan +Responses endpoint enforces the same strict shape and rejects a hook-split pair with HTTP 400 (#4726), +so `kimi` and `kimi-code` carry the flag as well. The flag is inert while those presets use the Chat +wire and takes effect when a row is configured onto `openai-responses`, which is the configuration the +report exercised. No upstream specification documents the requirement; the evidence is the observed +400 and DeepSeek's identical failure shape, which is why this stays a per-provider capability rather +than a wire-wide default — upstream Codex leaves an intervening developer message where it is. + > Decision record: [ADR-0052](../decisions/ADR-0052-reasoning-and-tool-result-compatibility.md) ## OpenRouter provider routing @@ -336,7 +354,11 @@ The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images real image blocks rather than flattening them to the text `[image]`, and orders image blocks chronologically — history before current — so attachment order matches the prose the model reads beside them. Vendor tool execution stays disabled on both -adapters, and Qoder's explicit refusal of original images is unchanged. +adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` +container followed by a `functions.*` invoke control line in either output channel; it +preserves preceding answer text, never promotes vendor prose into execution authority, +and leaves discussed or quoted literals and code examples untouched. Qoder's explicit +refusal of original images is unchanged. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/structure/runtime.md b/structure/runtime.md index 27ff0636027..37cee672c1c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -226,7 +226,11 @@ The image/video loop bounds each hidden iteration before replay or fulfillment; [media iteration retention](transports/inventory.md#media-iteration-retention). Live model discovery is bounded and registry-driven through `src/providers/model-discovery.ts`. -Custom providers keep the conventional `${baseUrl}/models` request; canonical presets may select a +Custom providers keep the conventional `${baseUrl}/models` request, normalized by +`providerModelsUrl` the same way `openaiChatCompletionsUrl` normalizes the send path: outer +whitespace and trailing slashes are trimmed and an already-pasted `/models` is not doubled, so a +`baseUrl` written with or without a trailing slash yields the identical discovery URL and an +existing path prefix is preserved. Canonical presets may select a trusted URL/path/query and declarative eligibility filter without persisting that policy into user config. A response is rejected before caching when it exceeds 4 MiB, contains more than 2,000 raw rows, has a malformed OpenAI list envelope, or includes an invalid model id. Tests use fixtures and diff --git a/tests/adapters/openai/openai-chat-bounded-tool-names.test.ts b/tests/adapters/openai/openai-chat-bounded-tool-names.test.ts new file mode 100644 index 00000000000..f7cdc756a39 --- /dev/null +++ b/tests/adapters/openai/openai-chat-bounded-tool-names.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { createOpenAIChatToolNameRegistry } from "../../../src/adapters/openai-chat/tool-name-registry"; +import { compileGoogleWireBody } from "../../../src/adapters/google-wire-compiler"; +import { kiroToolName } from "../../../src/adapters/kiro-wire"; +import { buildResponseJSON } from "../../../src/bridge"; +import { parseRequest } from "../../../src/responses/parser"; +import { buildToolBridgeMaps } from "../../../src/server/responses"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxTool } from "../../../src/types"; +import { namespacedToolName } from "../../../src/types/tools"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const LONG_NAMESPACE = "mcp__codex_apps__codex_document_control"; +const REPORTED_NAME = "execute_document_command"; +const OTHER_LONG_NAME = "get_document_tool_schemas"; + +function provider(): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + authMode: "key", + }; +} + +function tool(namespace: string | undefined, name: string): OcxTool { + return { namespace, name, description: "Test tool", parameters: { type: "object" } }; +} + +function parsedWith( + tools: OcxTool[], + options: OcxParsedRequest["options"] = {}, + messages: OcxParsedRequest["context"]["messages"] = [{ role: "user", content: "Use the tool", timestamp: 0 }], +): OcxParsedRequest { + return { modelId: "test-model", stream: false, options, context: { tools, messages } }; +} + +describe("bounded OpenAI Chat tool wire names (#4679)", () => { + test("bounds and restores the exact reported identity across request, replay, tool_choice, and response", async () => { + const declared = tool(LONG_NAMESPACE, REPORTED_NAME); + const originalWireName = namespacedToolName(LONG_NAMESPACE, REPORTED_NAME); + const replayMessages: OcxParsedRequest["context"]["messages"] = [ + { + role: "assistant", + content: [{ + type: "toolCall", + id: "call_replay", + namespace: LONG_NAMESPACE, + name: REPORTED_NAME, + arguments: {}, + }], + timestamp: 0, + }, + { role: "toolResult", toolCallId: "call_replay", toolName: REPORTED_NAME, content: "ok", timestamp: 1 }, + { role: "user", content: "Run it again", timestamp: 2 }, + ]; + const parsed = parsedWith([declared], { toolChoice: { name: REPORTED_NAME } }, replayMessages); + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(parsed, { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { + tools: Array<{ function: { name: string } }>; + messages: Array<{ tool_calls?: Array<{ function: { name: string } }> }>; + tool_choice: { function: { name: string } }; + }; + const alias = body.tools[0].function.name; + + expect(new TextEncoder().encode(originalWireName).byteLength).toBeGreaterThan(64); + expect(alias).not.toBe(originalWireName); + expect(alias).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + expect(new TextEncoder().encode(alias).byteLength).toBeLessThanOrEqual(64); + expect(body.messages.find(message => message.tool_calls)?.tool_calls?.[0].function.name).toBe(alias); + expect(body.tool_choice.function.name).toBe(alias); + + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ + message: { tool_calls: [{ id: "call_echo", function: { name: alias, arguments: "{}" } }] }, + finish_reason: "tool_calls", + }], + })), createTestTranslatorBudget()); + expect(events.find(event => event.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + id: "call_echo", + name: originalWireName, + }); + + const streamed: AdapterEvent[] = []; + const streamBody = `data: ${JSON.stringify({ + choices: [{ + delta: { tool_calls: [{ index: 0, id: "call_stream", function: { name: alias, arguments: "{}" } }] }, + finish_reason: "tool_calls", + }], + })}\n\ndata: [DONE]\n\n`; + for await (const event of adapter.parseStream( + new Response(streamBody), + createTestTranslatorBudget(), + )) streamed.push(event); + expect(streamed.find(event => event.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + id: "call_stream", + name: originalWireName, + }); + + const responseRequest = parseRequest({ + model: "test-model", + input: "Use the tool", + tools: [{ + type: "namespace", + name: LONG_NAMESPACE, + tools: [{ type: "function", name: REPORTED_NAME, parameters: { type: "object" } }], + }], + }); + const maps = buildToolBridgeMaps(responseRequest); + const bridged = buildResponseJSON(events, "test-model", maps); + const call = (bridged.output as Record[])[0]; + if (!call) throw new Error("Expected a bridged function call"); + expect(call).toMatchObject({ + type: "function_call", + namespace: LONG_NAMESPACE, + name: REPORTED_NAME, + }); + + const replayed = parseRequest({ + model: "test-model", + tools: [{ + type: "namespace", + name: LONG_NAMESPACE, + tools: [{ type: "function", name: REPORTED_NAME, parameters: { type: "object" } }], + }], + input: [call], + }); + const replayedCall = replayed.context.messages + .flatMap(message => Array.isArray(message.content) ? message.content : []) + .find(part => part.type === "toolCall"); + expect(replayedCall).toMatchObject({ namespace: LONG_NAMESPACE, name: REPORTED_NAME }); + }); + + test("leaves names at or under 64 characters and ordinary bare names byte-identical", () => { + const exactly64 = tool("n".repeat(30), "x".repeat(32)); + const ordinary = tool("mcp__short", "read"); + const longBare = tool(undefined, "b".repeat(200)); + const registry = createOpenAIChatToolNameRegistry([exactly64, ordinary, longBare]); + + expect(new TextEncoder().encode(namespacedToolName(exactly64.namespace, exactly64.name)).byteLength).toBe(64); + expect(registry.alias(exactly64)).toBe(namespacedToolName(exactly64.namespace, exactly64.name)); + expect(registry.alias(ordinary)).toBe(namespacedToolName(ordinary.namespace, ordinary.name)); + expect(registry.alias(longBare)).toBe(longBare.name); + expect(namespacedToolName(undefined, longBare.name)).toBe(longBare.name); + + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(parsedWith([exactly64, ordinary, longBare]), { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { tools: Array<{ function: { name: string } }> }; + expect(body.tools.map(entry => entry.function.name)).toEqual([ + namespacedToolName(exactly64.namespace, exactly64.name), + namespacedToolName(ordinary.namespace, ordinary.name), + longBare.name, + ]); + }); + + test("bounds and restores a replay-only historical call absent from the current catalog", async () => { + const originalWireName = namespacedToolName(LONG_NAMESPACE, REPORTED_NAME); + const replayed = parseRequest({ + model: "test-model", + tools: [], + input: [ + { + type: "function_call", + call_id: "call_historical", + namespace: LONG_NAMESPACE, + name: REPORTED_NAME, + arguments: "{}", + }, + { type: "function_call_output", call_id: "call_historical", output: "ok" }, + { role: "user", content: "Continue" }, + ], + }); + expect(replayed.context.tools ?? []).toHaveLength(0); + + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(replayed, { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { + tools?: unknown; + messages: Array<{ tool_calls?: Array<{ function: { name: string } }> }>; + }; + const alias = body.messages.find(message => message.tool_calls)?.tool_calls?.[0].function.name; + if (!alias) throw new Error("Expected the historical tool call on replay"); + + expect(body.tools).toBeUndefined(); + expect(alias).not.toBe(originalWireName); + expect(alias).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ + message: { tool_calls: [{ id: "call_echo", function: { name: alias, arguments: "{}" } }] }, + finish_reason: "tool_calls", + }], + })), createTestTranslatorBudget()); + expect(events.find(event => event.type === "tool_call_start")).toMatchObject({ + type: "tool_call_start", + name: originalWireName, + }); + }); + + test("derives deterministic distinct aliases independent of catalog order", () => { + const catalog = [ + tool(LONG_NAMESPACE, REPORTED_NAME), + tool(LONG_NAMESPACE, OTHER_LONG_NAME), + tool(`${LONG_NAMESPACE}_other`, REPORTED_NAME), + ]; + const forward = createOpenAIChatToolNameRegistry(catalog); + const reverse = createOpenAIChatToolNameRegistry([...catalog].reverse()); + const forwardAliases = catalog.map(entry => forward.alias(entry)); + + expect(catalog.map(entry => reverse.alias(entry))).toEqual(forwardAliases); + expect(new Set(forwardAliases).size).toBe(catalog.length); + for (const alias of forwardAliases) expect(alias).toMatch(/^[a-zA-Z0-9_-]{1,64}$/); + + const longTool = catalog[0]!; + const identityAlias = createOpenAIChatToolNameRegistry([longTool]).alias(longTool); + const aliasShapedBareTool = tool(undefined, identityAlias); + const collisionCatalog = [longTool, aliasShapedBareTool]; + const collisionRegistry = createOpenAIChatToolNameRegistry(collisionCatalog); + const reversedCollisionRegistry = createOpenAIChatToolNameRegistry([...collisionCatalog].reverse()); + const reservedNameAlias = collisionRegistry.alias(aliasShapedBareTool); + expect(collisionRegistry.alias(longTool)).toBe(identityAlias); + expect(reversedCollisionRegistry.alias(longTool)).toBe(identityAlias); + expect(reservedNameAlias).not.toBe(identityAlias); + expect(reservedNameAlias).toMatch(/^ocx_[a-zA-Z0-9_-]{16}_[a-zA-Z0-9_-]{43}$/); + expect(collisionRegistry.restore(reservedNameAlias)).toBe(identityAlias); + }); + + test("aliases colliding identities but leaves their ambiguous replay spelling unchanged", () => { + const first = tool("a__b", "c"); + const second = tool("a", "b__c"); + const flattened = namespacedToolName(first.namespace, first.name); + expect(namespacedToolName(second.namespace, second.name)).toBe(flattened); + + const registry = createOpenAIChatToolNameRegistry([first, second]); + const reversed = createOpenAIChatToolNameRegistry([second, first]); + const firstAlias = registry.alias(first); + const secondAlias = registry.alias(second); + expect(firstAlias).not.toBe(flattened); + expect(secondAlias).not.toBe(flattened); + expect(firstAlias).not.toBe(secondAlias); + expect(reversed.alias(first)).toBe(firstAlias); + expect(reversed.alias(second)).toBe(secondAlias); + expect(registry.aliasWireName(flattened)).toBe(flattened); + + const adapter = createOpenAIChatAdapter(provider()); + const request = adapter.buildRequest(parsedWith([first, second], {}, [ + { + role: "assistant", + content: [{ type: "toolCall", id: "call_ambiguous", namespace: first.namespace, name: first.name, arguments: {} }], + timestamp: 0, + }, + { role: "user", content: "Continue", timestamp: 1 }, + ]), { + headers: new Headers(), + translatorBudget: createTestTranslatorBudget(), + }); + if (request instanceof Promise) throw new Error("OpenAI Chat request unexpectedly became async"); + const body = JSON.parse(request.body) as { + tools: Array<{ function: { name: string } }>; + messages: Array<{ tool_calls?: Array<{ function: { name: string } }> }>; + }; + expect(body.tools.map(entry => entry.function.name)).toEqual([firstAlias, secondAlias]); + expect(body.messages.find(message => message.tool_calls)?.tool_calls?.[0].function.name).toBe(flattened); + }); + + test("keeps shared naming untouched so Kiro and Google retain adapter-owned normalization", () => { + const original = namespacedToolName(LONG_NAMESPACE, REPORTED_NAME); + expect(original).toBe(`${LONG_NAMESPACE}__${REPORTED_NAME}`); + expect(new TextEncoder().encode(original).byteLength).toBeGreaterThan(64); + + const kiro = kiroToolName(original); + expect(kiro).not.toBe(original); + expect(kiro).toMatch(/_[0-9a-f]{8}$/); + expect(kiro.length).toBeLessThanOrEqual(64); + + const google = compileGoogleWireBody({ + tools: [{ functionDeclarations: [{ name: original, parameters: { type: "object" } }] }], + }); + const googleName = (google.body.tools as Array<{ + functionDeclarations: Array<{ name: string }>; + }>)[0].functionDeclarations[0].name; + expect(googleName).not.toBe(original); + expect(googleName).toMatch(/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/); + expect(google.restoreToolName(googleName)).toBe(original); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 814d5ab913e..52d44eb91a0 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -623,6 +623,7 @@ "key-login-preserves-model-costs.test.ts": "oauth", "keyring-smoke.test.ts": "ci-workflows", "kimi-oauth-identity.test.ts": "providers", + "kimi-responses-adjacency.test.ts": "providers", "kiro-account-quota.test.ts": "providers/kiro", "kiro-adapter.test.ts": "providers/kiro", "kiro-auth-context-continuation.test.ts": "providers/kiro", @@ -821,6 +822,7 @@ "omp-path-contract.test.ts": "clients", "omp-yaml-source-inline-comments.test.ts": "clients", "openai-api-virtual-models.test.ts": "adapters/openai", + "openai-chat-bounded-tool-names.test.ts": "adapters/openai", "openai-chat-dangling-toolcalls.test.ts": "adapters/openai", "openai-chat-eof.test.ts": "adapters/openai", "openai-chat-hardening.test.ts": "adapters/openai", diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 0da898ea829..76caabfae99 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { buildArgs, buildChildEnv, createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; +import { guardCodeBuddyScaffolding } from "../../src/adapters/codebuddy/scaffold-guard"; import { CODEBUDDY_CN_PROFILE, CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -221,6 +222,236 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(child.written.join("")).toContain('"text":"hello"'); }); + test("refuses a full-message DSML calls-and-invoke scaffold", async () => { + const leaked = "I'll inspect it.\n<||DSML|| calls>\n" + + "<||DSML|| invoke name=\"functions.exec\">\nsecret-command"; + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: leaked }] }, + })}\n`), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "text_delta")) + .toEqual([{ type: "text_delta", text: "I'll inspect it.\n" }]); + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + status: 502, + }); + expect(JSON.stringify(events)).not.toContain("secret-command"); + }); + + test("detects a DSML control sequence split across streamed text deltas", async () => { + const frame = (text: string) => `${JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + })}\n`; + const stdout = [ + enc.encode(frame("Safe prefix.\n<||DS")), + enc.encode(frame("ML|| calls>\n<||DSML|| invoke name=\"funct")), + enc.encode(frame("ions.exec\">private-body")), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "text_delta")) + .toEqual([{ type: "text_delta", text: "Safe prefix.\n" }]); + expect(events.at(-1)).toMatchObject({ type: "error", code: "vendor_scaffold_detected" }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("refuses DSML calls-and-invoke scaffolding from reasoning independently", async () => { + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { + type: "thinking_delta", + thinking: "Safe thought.\n<||DSML|| calls>\n" + + "<||DSML|| invoke name=\"functions.exec\">private-body", + }, + }, + })}\n`), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "thinking_delta")) + .toEqual([{ type: "thinking_delta", thinking: "Safe thought.\n" }]); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("delivers a lone discussed DSML calls tag unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "The string <||DSML|| calls> names the calls container."; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers quoted and inline-code DSML literals unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "\"<||DSML|| calls>\"\n" + + "\"<||DSML|| invoke name=\\\"functions.exec\\\">\"\n" + + "Use `<||DSML|| calls>` when discussing the literal.\n" + + "> <||DSML|| calls>\n> <||DSML|| invoke name=\"functions.exec\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers a fenced DSML source example unchanged across deltas", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const first = "```text\n<||DSML|| calls>\n"; + const second = "<||DSML|| invoke name=\"functions.exec\">\n```"; + + guarded({ type: "text_delta", text: first }); + guarded({ type: "text_delta", text: second }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: first }, + { type: "text_delta", text: second }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers source strings containing both DSML literals unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "const calls = '<||DSML|| calls>';\n" + + "const invoke = '<||DSML|| invoke name=\"functions.exec\">';"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers an unquoted invoke line when no calls container precedes it", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "<||DSML|| invoke name=\"functions.exec\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("releases a lone control-line candidate at the terminal", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "text_delta", text: "<||DSML|| calls>" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: "<||DSML|| calls>" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("queues later events behind an unresolved marker prefix", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + guarded({ type: "tool_call_start", id: "call_1", name: "exec" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "tool_call_start", id: "call_1", name: "exec" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("keeps an existing pending slot when its channel receives an empty delta", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + guarded({ type: "thinking_delta", thinking: "" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("moves a replaced pending marker prefix to its new arrival position", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "<" }); + guarded({ type: "thinking_delta", thinking: "not marker\n<" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "<" }, + { type: "thinking_delta", thinking: "not marker\n" }, + { type: "thinking_delta", thinking: "<" }, + { type: "done", stopReason: "stop" }, + ]); + }); + test("region isolation: the global adapter never spawns with the CN environment", async () => { let seenEnv: NodeJS.ProcessEnv | undefined; const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; diff --git a/tests/providers/kimi-responses-adjacency.test.ts b/tests/providers/kimi-responses-adjacency.test.ts new file mode 100644 index 00000000000..e498fbae4cd --- /dev/null +++ b/tests/providers/kimi-responses-adjacency.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { normalizeResponsesToolResultAdjacency } from "../../src/adapters/openai-responses/tool-output-recovery"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { routedProviderConfig } from "../../src/router"; +import type { OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const MODEL = "kimi-k2.7-code"; + +const createResponsesPassthroughAdapter = ( + ...args: Parameters +) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +function buildBody(provider: OcxProviderConfig, input: unknown[]): { input: unknown[] } { + const built = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: MODEL, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: MODEL, input }, + } as Parameters["buildRequest"]>[0], { + headers: new Headers(), + }); + return JSON.parse(String(built.body)) as { input: unknown[] }; +} + +describe("Kimi Responses tool-result adjacency", () => { + test("both Kimi registry entries seed the adjacency capability", () => { + for (const providerId of ["kimi", "kimi-code"]) { + const entry = getProviderRegistryEntry(providerId)!; + expect(entry.requiresAdjacentResponsesToolResults).toBe(true); + expect(providerConfigSeed(entry).requiresAdjacentResponsesToolResults).toBe(true); + } + }); + + test("a stale persisted Kimi row is backfilled and activates adjacency repair on replay", () => { + const stale: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.kimi.com/coding/v1", + authMode: "oauth", + statelessResponses: true, + }; + const routedStale = routedProviderConfig("kimi", { ...stale }); + const call = { type: "custom_tool_call", call_id: "exec_replay", name: "exec", input: "text('hi')" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] replay diagnostics" }], + }; + const output = { type: "custom_tool_call_output", call_id: "exec_replay", output: "hi" }; + const nextTurn = { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }; + + expect(stale.requiresAdjacentResponsesToolResults).toBeUndefined(); + expect(routedStale.requiresAdjacentResponsesToolResults).toBe(true); + enrichProviderFromRegistry("kimi", stale); + expect(stale.requiresAdjacentResponsesToolResults).toBe(true); + expect(buildBody(stale, [call, injected, output, nextTurn]).input).toEqual([ + call, + output, + injected, + nextTurn, + ]); + }); + + test("moves a result next to its call while preserving an intervening developer message", () => { + const call = { type: "custom_tool_call", call_id: "exec_single", name: "exec", input: "text('ok')" }; + const injected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] LSP diagnostics: none" }], + }; + const output = { type: "custom_tool_call_output", call_id: "exec_single", output: "ok" }; + const body = { input: [call, injected, output] }; + + expect(normalizeResponsesToolResultAdjacency(body)).toEqual({ input: [call, output, injected] }); + }); + + test("keeps call_id pairing and all interleaved history with two outstanding replayed calls", () => { + const priorUser = { + type: "message", + role: "user", + content: [{ type: "input_text", text: "inspect both files" }], + }; + const callA = { type: "function_call", call_id: "call_a", name: "read_file", arguments: "{\"path\":\"a\"}" }; + const firstInjected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] first diagnostic" }], + }; + const callB = { type: "custom_tool_call", call_id: "call_b", name: "exec", input: "text('b')" }; + const secondInjected = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "[hook] second diagnostic" }], + }; + const outputA = { type: "function_call_output", call_id: "call_a", output: "A" }; + const outputB = { type: "custom_tool_call_output", call_id: "call_b", output: "B" }; + const nextTurn = { + type: "message", + role: "user", + content: [{ type: "input_text", text: "continue" }], + }; + + const normalized = normalizeResponsesToolResultAdjacency({ + input: [priorUser, callA, firstInjected, callB, secondInjected, outputA, outputB, nextTurn], + }); + + expect(normalized).toEqual({ + input: [priorUser, callA, callB, outputA, outputB, firstInjected, secondInjected, nextTurn], + }); + }); + + test("leaves an already-adjacent call and result input untouched", () => { + const call = { type: "custom_tool_call", call_id: "exec_adjacent", name: "exec", input: "text('ok')" }; + const output = { type: "custom_tool_call_output", call_id: "exec_adjacent", output: "ok" }; + const tail = { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "retained context" }], + }; + const body = { input: [call, output, tail] }; + + expect(normalizeResponsesToolResultAdjacency(body)).toBe(body); + }); +}); diff --git a/tests/providers/provider-model-discovery-contract.test.ts b/tests/providers/provider-model-discovery-contract.test.ts index 89cfef37b75..9a4a6fae256 100644 --- a/tests/providers/provider-model-discovery-contract.test.ts +++ b/tests/providers/provider-model-discovery-contract.test.ts @@ -12,6 +12,7 @@ import { deriveKeyLoginMap, providerConfigSeed } from "../../src/providers/deriv import { extractProviderModelItems, isRegistryModelDiscoveryUrl, + providerModelsUrl, providerModelDiscoverySpecError, readBoundedDiscoveryJson, resolveProviderModelDiscovery, @@ -68,6 +69,58 @@ function togetherConfig(overrides: Partial = {}): OcxConfig { } describe("registry-owned provider model discovery", () => { + test("normalizes custom model discovery URLs without collapsing path prefixes (#4724)", () => { + const buildCustom = (baseUrl: string) => buildModelsRequest({ + adapter: "openai-responses", + baseUrl, + authMode: "key", + }, "secret", "custom-gateway").url; + + expect(buildCustom("https://gw.example.com/v1")).toBe("https://gw.example.com/v1/models"); + expect(buildCustom("https://gw.example.com/v1/")).toBe("https://gw.example.com/v1/models"); + expect(buildCustom("https://gw.example.com/v1////")).toBe("https://gw.example.com/v1/models"); + expect(buildCustom("https://gw.example.com/api/openai/v1/")) + .toBe("https://gw.example.com/api/openai/v1/models"); + expect(buildCustom("https://gw.example.com/tenant/acme/api/openai/v1///")) + .toBe("https://gw.example.com/tenant/acme/api/openai/v1/models"); + expect(buildCustom("https://gw.example.com/v1/models")) + .toBe("https://gw.example.com/v1/models"); + expect(buildCustom("https://gw.example.com/v1/models/")) + .toBe("https://gw.example.com/v1/models"); + }); + + test("keeps registry path discovery independent of default URL normalization (#4724)", () => { + const url = resolveProviderModelDiscoveryUrl( + "cloudflare-workers-ai", + { + adapter: "openai-chat", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/", + }, + "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/", + providerModelsUrl("https://api.cloudflare.com/client/v4/accounts/acct/ai/v1/"), + ); + expect(url).toBe( + "https://api.cloudflare.com/client/v4/accounts/acct/ai/models/search?format=openrouter&per_page=1000", + ); + }); + + test("keeps absolute and relative discovery endpoint overrides unchanged (#4724)", async () => { + await withTogetherDiscovery({ + url: "https://catalog.example.test/custom/models?source=registry", + }, () => { + expect(buildModelsRequest(togetherConfig().providers.together!, "secret", "together").url) + .toBe("https://catalog.example.test/custom/models?source=registry"); + }); + + await withTogetherDiscovery({ path: "catalog/models" }, () => { + expect(buildModelsRequest( + togetherConfig({ baseUrl: "https://api.together.xyz/v1/" }).providers.together!, + "secret", + "together", + ).url).toBe("https://api.together.xyz/v1/catalog/models"); + }); + }); + test("keeps every registry discovery contract inside static safety bounds", () => { for (const entry of PROVIDER_REGISTRY) { if (!entry.modelDiscovery) continue; @@ -170,6 +223,20 @@ describe("registry-owned provider model discovery", () => { }); }); + test("normalizes a custom API-key validation discovery URL (#4724)", async () => { + globalThis.fetch = (async (input, init) => { + expect(String(input)).toBe("https://custom.example/api/openai/v1/models"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer secret"); + expect(init?.redirect).toBe("error"); + return Response.json({ data: [] }); + }) as typeof fetch; + + expect(await validateApiKey("custom-gateway", { + ...KEY_LOGIN_PROVIDERS.together!, + baseUrl: "https://custom.example/api/openai/v1///", + }, "secret")).toBe(true); + }); + test("pins fixed OAuth discovery before resolving relative and default endpoints", async () => { const staleConfig: OcxProviderConfig = { adapter: "openai-chat", diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index 21912afd0d5..8aada987db5 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -462,7 +462,13 @@ describe("GET /api/client-config", () => { const config = baseConfig({ providers: { ...baseConfig().providers, - openai: { authMode: "forward", liveModels: false, models: [] }, + // Spelled out the same way as the other `openai` fixture in this file: `adapter` and + // `baseUrl` are required by the persisted-config schema, so a row without them is not a + // state configuration loading can produce, and discovery builds a request from it. + openai: { + adapter: "openai-responses", authMode: "forward", liveModels: false, + baseUrl: "https://chatgpt.com/backend-api/codex", models: [], + }, }, }); const response = await clientConfigApi(config, "?client=opencode"); diff --git a/tests/server/model-discovery-management-api.test.ts b/tests/server/model-discovery-management-api.test.ts index ad132847bb7..dc8dea96fe0 100644 --- a/tests/server/model-discovery-management-api.test.ts +++ b/tests/server/model-discovery-management-api.test.ts @@ -4,7 +4,18 @@ import type { OcxConfig } from "../../src/types"; import { ManagementRequest as Request } from "../helpers/management-auth"; function config(): OcxConfig { - return { port: 10100, defaultProvider: "vendor", providers: { vendor: { liveModels: false, models: ["known"] } }, disabledModels: ["vendor/new"] }; + // `adapter` and `baseUrl` are both required by the persisted-config schema, so a row without + // them cannot reach this handler in production. The fixture used to omit them and still crossed + // into the real catalog gather path, which builds a discovery request before the static-provider + // branch returns; that is how it reached a URL join at all. + return { + port: 10100, + defaultProvider: "vendor", + providers: { + vendor: { adapter: "openai-chat", baseUrl: "https://vendor.example/v1", liveModels: false, models: ["known"] }, + }, + disabledModels: ["vendor/new"], + }; } async function call(live: OcxConfig, path: string, method = "GET", body?: unknown) {