diff --git a/docs-site/src/content/docs/fr/guides/pi.md b/docs-site/src/content/docs/fr/guides/pi.md index 030d91679c..14acc0db35 100644 --- a/docs-site/src/content/docs/fr/guides/pi.md +++ b/docs-site/src/content/docs/fr/guides/pi.md @@ -28,7 +28,8 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ d’exportation de la variable d’environnement et le nombre de modèles dotés Les fournisseurs Pi générés activent `compat.sendSessionAffinityHeaders`. Conservez ce réglage lors de la fusion ou de la modification manuelle du fournisseur : Pi transmet un identifiant de session stable, dont OpenCodex dérive l’affinité pour la destination canonique OpenCode Go. Pi peut omettre cet identifiant lorsque `cacheRetention` vaut `none`. +Les fournisseurs Pi générés définissent aussi `compat.supportsDeveloperRole` à `false` : Pi envoie alors son prompt système avec le rôle `system` au lieu de `developer`. OpenCodex transmet les rôles Chat Completions tels quels, et plusieurs amonts compatibles OpenAI refusent `developer` avec une erreur 400 ; tous acceptent `system`. + Les identifiants de modèle sont les sélecteurs canoniques du proxy : les modèles routés apparaissent donc sous la forme `provider/model` (`anthropic/claude-opus-5`) et les slugs natifs OpenAI restent sans préfixe (`gpt-5.6-sol`). Le `name` suffixe — `(anthropic)`, `(native)`, `(routed)` — permet de distinguer, dans le sélecteur de Pi, deux modèles de même nom diff --git a/docs-site/src/content/docs/guides/pi.md b/docs-site/src/content/docs/guides/pi.md index f8a00f7b3f..b5a92212c8 100644 --- a/docs-site/src/content/docs/guides/pi.md +++ b/docs-site/src/content/docs/guides/pi.md @@ -28,7 +28,8 @@ export line, and how many models carry authoritative context limits. "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ export line, and how many models carry authoritative context limits. Generated Pi providers enable `compat.sendSessionAffinityHeaders`. Keep this flag when merging or manually editing the provider: Pi supplies a stable session identity and OpenCodex derives canonical OpenCode Go affinity from it. Pi may omit the identity when `cacheRetention` is `none`. +Generated Pi providers also set `compat.supportsDeveloperRole` to `false`, so Pi sends its system prompt as `system` instead of `developer`. OpenCodex forwards Chat Completions roles as sent, and several OpenAI-compatible upstreams reject `developer` with a 400; every upstream accepts `system`. + Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` (`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed (`gpt-5.6-sol`). The `name` suffix — `(anthropic)`, `(native)`, `(routed)` — is what makes two same-named models from diff --git a/docs-site/src/content/docs/ja/guides/pi.md b/docs-site/src/content/docs/ja/guides/pi.md index 67e55107e0..37980f9745 100644 --- a/docs-site/src/content/docs/ja/guides/pi.md +++ b/docs-site/src/content/docs/ja/guides/pi.md @@ -24,7 +24,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -42,6 +43,8 @@ ocx export --client pi 生成される Pi プロバイダーでは `compat.sendSessionAffinityHeaders` が有効です。設定をマージしたり手動で編集したりする際も、このフラグを保持してください。Pi が送る安定したセッション識別子から、OpenCodex が正規の OpenCode Go 接続先用の affinity を生成します。`cacheRetention` が `none` の場合、Pi は識別子を送信しないことがあります。 +生成される Pi プロバイダーでは `compat.supportsDeveloperRole` も `false` に設定され、Pi はシステムプロンプトを `developer` ではなく `system` ロールで送ります。OpenCodex は Chat Completions のロールを受け取ったまま転送しますが、OpenAI 互換のアップストリームの中には `developer` を 400 で拒否するものがあります。`system` はすべてのアップストリームが受け付けます。 + モデル ID はプロキシの正規セレクターであるため、ルーティングされたモデルは `provider/model` (`anthropic/claude-opus-5`) として表示され、ネイティブ OpenAI スラグはプレフィックスなし (`gpt-5.6-sol`) のままになります。 `name` サフィックス (`(anthropic)`、`(native)`、`(routed)`) により、異なるアップストリームの 2 つの同じ名前のモデルが Pi のピッカーで区別できるようになります。 ## どこへ行くのか diff --git a/docs-site/src/content/docs/ko/guides/pi.md b/docs-site/src/content/docs/ko/guides/pi.md index a2c7be8d5f..3de309f770 100644 --- a/docs-site/src/content/docs/ko/guides/pi.md +++ b/docs-site/src/content/docs/ko/guides/pi.md @@ -28,7 +28,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ ocx export --client pi 생성된 Pi provider에는 `compat.sendSessionAffinityHeaders`가 활성화됩니다. provider를 병합하거나 직접 수정할 때 이 설정을 유지하세요. Pi가 안정적인 세션 식별자를 보내면 OpenCodex가 이를 바탕으로 정규 OpenCode Go 대상의 affinity를 계산합니다. `cacheRetention`이 `none`이면 Pi가 식별자를 보내지 않을 수 있습니다. +생성된 Pi provider는 `compat.supportsDeveloperRole`도 `false`로 설정합니다. 그래서 Pi는 시스템 프롬프트를 `developer`가 아닌 `system` 역할로 보냅니다. OpenCodex는 Chat Completions 역할을 받은 그대로 전달하는데, OpenAI 호환 업스트림 중 일부는 `developer`를 400으로 거부합니다. `system`은 모든 업스트림이 받습니다. + 모델 id는 프록시의 정규 선택자이므로, 라우팅된 모델은 `provider/model` (`anthropic/claude-opus-5`) 형태로 나타나고, 네이티브 OpenAI slug는 접두사 없이 (`gpt-5.6-sol`) 유지됩니다. `name` 접미사인 `(anthropic)`, `(native)`, `(routed)`는 diff --git a/docs-site/src/content/docs/ru/guides/pi.md b/docs-site/src/content/docs/ru/guides/pi.md index f6cb3b141d..c93286ea75 100644 --- a/docs-site/src/content/docs/ru/guides/pi.md +++ b/docs-site/src/content/docs/ru/guides/pi.md @@ -28,7 +28,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -46,6 +47,8 @@ ocx export --client pi В создаваемой конфигурации Pi включён `compat.sendSessionAffinityHeaders`. Сохраняйте этот флаг при объединении или ручном редактировании провайдера: Pi передаёт стабильный идентификатор сессии, из которого OpenCodex формирует affinity для канонического OpenCode Go. При `cacheRetention: none` Pi может не передавать идентификатор. +Создаваемая конфигурация Pi также задаёт `compat.supportsDeveloperRole` равным `false`, поэтому Pi отправляет системный промпт с ролью `system`, а не `developer`. OpenCodex передаёт роли Chat Completions без изменений, а часть OpenAI-совместимых провайдеров отклоняет `developer` с ошибкой 400; роль `system` принимают все. + Id моделей — это канонические селекторы прокси, поэтому маршрутизируемые модели появляются как `provider/model` (`anthropic/claude-opus-5`), а нативные slug OpenAI остаются без префикса (`gpt-5.6-sol`). Суффикс в `name` — `(anthropic)`, `(native)`, `(routed)` — как раз и позволяет diff --git a/docs-site/src/content/docs/tr/guides/pi.md b/docs-site/src/content/docs/tr/guides/pi.md index dc1ba48e10..e04cd9326e 100644 --- a/docs-site/src/content/docs/tr/guides/pi.md +++ b/docs-site/src/content/docs/tr/guides/pi.md @@ -32,7 +32,8 @@ export line, and how many models carry authoritative context limits. "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -50,6 +51,8 @@ export line, and how many models carry authoritative context limits. Oluşturulan Pi sağlayıcılarında `compat.sendSessionAffinityHeaders` etkinleştirilir. Sağlayıcıyı birleştirirken veya elle düzenlerken bu ayarı koruyun: Pi sabit bir oturum kimliği gönderir ve OpenCodex bu kimlikten kanonik OpenCode Go hedefi için oturum yakınlığı üretir. `cacheRetention` değeri `none` olduğunda Pi kimliği göndermeyebilir. +Oluşturulan Pi sağlayıcıları ayrıca `compat.supportsDeveloperRole` değerini `false` yapar; böylece Pi sistem istemini `developer` yerine `system` rolüyle gönderir. OpenCodex Chat Completions rollerini olduğu gibi iletir ve OpenAI uyumlu bazı sağlayıcılar `developer` rolünü 400 hatasıyla reddeder; `system` rolünü hepsi kabul eder. + Model ids are the proxy's canonical selectors, so routed models appear as `provider/model` (`anthropic/claude-opus-5`) and native OpenAI slugs stay unprefixed diff --git a/docs-site/src/content/docs/zh-cn/guides/pi.md b/docs-site/src/content/docs/zh-cn/guides/pi.md index f83ba873fe..8358cc8267 100644 --- a/docs-site/src/content/docs/zh-cn/guides/pi.md +++ b/docs-site/src/content/docs/zh-cn/guides/pi.md @@ -24,7 +24,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -42,6 +43,8 @@ ocx export --client pi 生成的 Pi 提供方配置启用了 `compat.sendSessionAffinityHeaders`。合并或手动编辑提供方时请保留该设置:Pi 提供稳定的会话标识,OpenCodex 据此为规范的 OpenCode Go 目标生成会话亲和标识。`cacheRetention` 为 `none` 时,Pi 可能不发送会话标识。 +生成的 Pi 提供方配置还会把 `compat.supportsDeveloperRole` 设为 `false`,使 Pi 以 `system` 而不是 `developer` 角色发送系统提示词。OpenCodex 按原样转发 Chat Completions 角色,而部分 OpenAI 兼容上游会以 400 拒绝 `developer`;所有上游都接受 `system`。 + 模型 id 是代理的规范选择器,因此已路由模型会显示为 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 会保持不带前缀(`gpt-5.6-sol`)。`name` 后缀 - `(anthropic)`、`(native)`、`(routed)` - 负责让两个同名但来自不同上游的模型在 Pi 的选择器中可区分。 ## 放置位置 diff --git a/docs-site/src/content/docs/zh-tw/guides/pi.md b/docs-site/src/content/docs/zh-tw/guides/pi.md index 9d64b8ef5f..09e057ae95 100644 --- a/docs-site/src/content/docs/zh-tw/guides/pi.md +++ b/docs-site/src/content/docs/zh-tw/guides/pi.md @@ -24,7 +24,8 @@ ocx export --client pi "api": "openai-completions", "apiKey": "$OPENCODEX_API_KEY", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { @@ -42,6 +43,8 @@ ocx export --client pi 產生的 Pi 供應商設定會啟用 `compat.sendSessionAffinityHeaders`。合併或手動編輯供應商時請保留此設定:Pi 提供穩定的工作階段識別碼,OpenCodex 據此為標準 OpenCode Go 目標產生工作階段親和識別碼。當 `cacheRetention` 為 `none` 時,Pi 可能不傳送識別碼。 +產生的 Pi 供應商設定也會把 `compat.supportsDeveloperRole` 設為 `false`,讓 Pi 以 `system` 而非 `developer` 角色傳送系統提示詞。OpenCodex 會照原樣轉送 Chat Completions 角色,而部分 OpenAI 相容上游會以 400 拒絕 `developer`;所有上游都接受 `system`。 + 模型 id 是代理的規範選擇器,因此路由模型顯示為 `provider/model`(`anthropic/claude-opus-5`),而原生 OpenAI slug 保持無前綴(`gpt-5.6-sol`)。`name` 後綴 — `(anthropic)`、`(native)`、`(routed)` — 正是讓來自不同上游的兩個同名模型在 Pi 的 picker 中可區分的關鍵。 ## 放置位置 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 518fa8c1b7..0e68d87846 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1159,7 +1159,11 @@ "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", + "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", + "responses-chat-tool-call-content.test.ts": "responses", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", "openai-chat-video-part.test.ts": "adapters/openai", diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index dbc1ce7695..5fb1775a94 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -45,6 +45,7 @@ import { messagesToChatFormat } from "./openai-chat/messages"; import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry"; import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; +import { reconcileSerializedToolCallEvents, reconcileStructuredToolCall, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content"; export { stripBracketedModelSuffix } from "./openai-chat/wire"; export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; @@ -299,6 +300,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd sawArgumentsString: boolean; } const pendingToolCalls: PendingToolCall[] = []; + const toolCallContent = new SerializedToolCallContentBuffer(budget); + const heldText = (): AdapterEvent[] => toolCallContent.drain([]); let toolCallSeq = 0; const closeToolCalls = (): PendingToolCall[] => { const calls = [...pendingToolCalls]; @@ -310,7 +313,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd pendingToolCalls.length > 0 && pendingToolCalls.every(call => { if (call.name.trim().length === 0 || !call.sawArgumentsString || call.args.length === 0) return false; try { - const parsed = JSON.parse(call.args) as unknown; + const parsed = JSON.parse(reconcileStructuredToolCall(call.name, toolNames.restore(call.name), call.args, toolCallContent.current()).argumentsText) as unknown; return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); } catch { return false; @@ -320,7 +323,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first, // so budget reservations are released for every pending call even on the early return. const flushToolCalls = function* (): Generator { - for (const call of closeToolCalls()) { + const calls = closeToolCalls(); + for (const call of calls) { // Ingest already proved `name` is a string; the typeof guard keeps this branch // total so a future ingest change cannot turn a malformed name into a throw. if (typeof call.name !== "string" || call.name.trim().length === 0) { @@ -328,9 +332,14 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd hadId: call.id.length > 0, argsBytes: call.argsBytes, }); - yield unnamedToolCallEvent(pendingUsage); - return "terminate"; + return yield* terminateWithError(unnamedToolCallEvent(pendingUsage)); } + } + // Held serialized markup is released only now, reconciled against the calls it may duplicate. + const references = calls.map(call => reconcileStructuredToolCall(call.name, toolNames.restore(call.name), call.args, toolCallContent.current())); + calls.forEach((call, index) => { call.args = references[index]!.argumentsText; }); + yield* toolCallContent.drain(references); + for (const call of calls) { if (!call.id) call.id = `call_${++toolCallSeq}`; 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 }; @@ -342,6 +351,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd event: Extract, ): Generator { closeToolCalls(); + yield* heldText(); // Pending tools are not dispatched, so held text stays visible. yield event; return "terminate"; }; @@ -360,7 +370,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // blocks, which would otherwise render as the answer. Passthrough unless opted in. const inlineThink = createInlineThinkContentSplitter(provider.inlineThinkTagModels, lastRequestedModelId, budget); const emitContent = function* (events: AdapterEvent[]): Generator { - for (const event of events) { if (event.type === "text_delta") sawUserFacingOutput = true; yield event; } + for (const event of events) { + // Any other event keeps its place behind held text instead of overtaking it. + if (event.type !== "text_delta") { yield* toolCallContent.hold(event); continue; } + sawUserFacingOutput = true; + const text = toolCallContent.ingest(event.text); + yield text.length > 0 ? { type: "text_delta", text } : { type: "heartbeat" }; + } }; const handleDataLine = function* (line: string): Generator { @@ -381,8 +397,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd parsed = JSON.parse(payload); } catch { tierMetadata?.markResponseUnparseable(); - yield { type: "error", message: "malformed upstream SSE data frame" }; - return "terminate"; + return yield* terminateWithError({ type: "error", message: "malformed upstream SSE data frame" }); } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue"; const chunk = parsed as Record; @@ -423,11 +438,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (detailSegments.length > 0) { for (const segment of detailSegments) { const reasoningDelta = reasoningDetailTracker.ingest(segment); - if (reasoningDelta !== null) yield { type: "reasoning_raw_delta", text: reasoningDelta }; + if (reasoningDelta !== null) yield* toolCallContent.hold({ type: "reasoning_raw_delta", text: reasoningDelta }); } } else { const reasoningText = reasoningTextFrom(delta); - if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText }; + if (reasoningText !== undefined) yield* toolCallContent.hold({ type: "reasoning_raw_delta", text: reasoningText }); } if (typeof delta.content === "string" && delta.content.length > 0) { yield* emitContent(inlineThink.feed(delta.content)); @@ -630,7 +645,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd hadUsage: pendingUsage !== undefined, pendingToolCalls: pendingToolCalls.length, }); - yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" }; + yield* terminateWithError({ type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" }); return; } if (!sawFinish && !sawUserFacingOutput) { @@ -638,13 +653,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd finishReason: finishReason ?? null, hadUsage: pendingUsage !== undefined, }); - yield { type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" }; + yield* terminateWithError({ type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" }); return; } if ((yield* flushToolCalls()) === "terminate") return; const stopReason = stopReasonFor(finishReason); yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) }; } catch (error) { + closeToolCalls(); + yield* heldText(); if (isTranslatorBudgetExceededError(error) || (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit")) { yield { @@ -662,6 +679,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd budget.releaseRetained(bufferBytes, { kind: "live_transient" }); reasoningDetailTracker.release(); inlineThink.dispose(); + toolCallContent.dispose(); closeToolCalls(); reader.releaseLock(); } @@ -748,7 +766,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd if (segments.length > 0) reasoningText = segments.map(s => s.text).join(""); } if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); + const contentStart = events.length; if (typeof msg.content === "string") events.push(...splitInlineThinkContent(provider.inlineThinkTagModels, lastRequestedModelId, budget, msg.content)); + const contentEnd = events.length; + const answerText = events.slice(contentStart).map(event => (event.type === "text_delta" ? event.text : "")).join(""); + const references: ReturnType[] = []; const rawToolCalls = msg.tool_calls; if (rawToolCalls !== undefined && rawToolCalls !== null) { if (!Array.isArray(rawToolCalls)) { @@ -771,11 +793,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd logInvalidToolCalls("response", rawToolCalls); return [invalidToolCallsEvent(rawToolCalls, "response", usage)]; } + references.push(reconcileStructuredToolCall(name, toolNames.restore(name), args, answerText)); events.push({ type: "tool_call_start", id, name: toolNames.restore(name) }); - events.push({ type: "tool_call_delta", arguments: args }); + events.push({ type: "tool_call_delta", arguments: references.at(-1)!.argumentsText }); events.push({ type: "tool_call_end" }); } } + reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget); const stopReason = stopReasonFor(choice.finish_reason); events.push({ type: "done", diff --git a/src/adapters/openai-chat/serialized-tool-call-content.ts b/src/adapters/openai-chat/serialized-tool-call-content.ts new file mode 100644 index 0000000000..d874849a45 --- /dev/null +++ b/src/adapters/openai-chat/serialized-tool-call-content.ts @@ -0,0 +1,354 @@ +import type { TranslatorBudget } from "../../lib/translator-budget"; +import type { AdapterEvent } from "../../types"; + +const OPEN_TAG = ""; +const FUNCTION_TAG = "; + argumentsText: string; +} + +/** Finds complete bare blocks outside literal Markdown; ambiguous outer blocks stop the scan. */ +function callsIn(text: string, context: TextContext = { fence: null, lineStart: true }): SerializedToolCall[] { + const pattern = /\s*\r\n]+)>([\s\S]*?)(?:<\/parameter>)?\s*<\/function>\s*<\/tool_call>/y; + const calls: SerializedToolCall[] = []; + let offset = 0; + while (offset < text.length) { + const split = splitAtPossibleSerializedToolCall(text.slice(offset), context, true); + offset += split.emit.length; + if (!split.hasOpenTag) break; + pattern.lastIndex = offset; + const match = pattern.exec(text); + if (!match) break; // An incomplete/ambiguous outer block cannot authorize an inner call. + calls.push({ + name: match[1]!.trim(), + body: match[2]!, + start: match.index, + end: match.index + match[0].length, + }); + offset = pattern.lastIndex; + context = { fence: null, lineStart: false }; + } + return calls; +} + +/** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */ +export function splitAtPossibleSerializedToolCall( + text: string, + initialContext: TextContext = { fence: null, lineStart: true }, + final = false, +): { + emit: string; + defer: string; + hasOpenTag: boolean; + context: TextContext; +} { + const context = { ...initialContext }; + const split = (at: number, hasOpenTag = false) => ({ + emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context, + }); + for (let index = 0; index < text.length; index++) { + if (context.lineStart) { + const rest = text.slice(index); + const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest); + if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0] + && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) { + if (!final && !rest.includes("\n")) return split(index); + context.fence = context.fence ? null : fence[1]!; + index += fence[0].length - 1; + context.lineStart = false; + continue; + } + if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index); + // Only bare control markup qualifies. Prose, quotes, indented examples and + // fenced code stay user-visible even when their body matches a real call. + if (!context.fence && !context.inlineTicks) { + if (rest.startsWith(OPEN_TAG)) { + const header = rest.slice(OPEN_TAG.length).trimStart(); + if (/^\r\n]+>/.test(header)) return split(index, true); + if (!final && (FUNCTION_TAG.startsWith(header) + || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) { + return split(index); + } + } else if (!final && OPEN_TAG.startsWith(rest)) return split(index); + } + } + if (!context.fence && text[index] === "`") { + let end = index + 1; + while (text[end] === "`") end++; + if (!final && end === text.length) return split(index); + const ticks = end - index; + if (!context.inlineTicks) context.inlineTicks = ticks; + else if (context.inlineTicks === ticks) context.inlineTicks = undefined; + index = end - 1; + } + context.lineStart = text[index] === "\n"; + } + return split(text.length); +} + +/** + * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so + * the scan runs through the whole text instead of stopping at the first opening tag; a block ends + * mid-line, which is exactly what the neutral spelling reports too. + */ +function contextAfter(text: string, context: TextContext): TextContext { + if (text.length === 0) return context; + return splitAtPossibleSerializedToolCall(text.replaceAll(OPEN_TAG, ""), context, true).context; +} + +/** + * Holds possible duplicate text within the shared translator budget until the dispatch outcome is + * known. While a block candidate is open, any other event (reasoning) is queued at its position in + * the held text rather than overtaking it or forcing the block out early, and `drain` restores the + * original order. + */ +export class SerializedToolCallContentBuffer { + private text = ""; + private bytes = 0; + private hasOpenTag = false; + private context: TextContext = { fence: null, lineStart: true }; + private queued: { offset: number; event: AdapterEvent }[] = []; + + constructor(private readonly budget: TranslatorBudget) {} + + /** Reserves the replacement before releasing the old text, preserving it if the budget rejects growth. */ + private replace(next: string, hasOpenTag: boolean): void { + const nextBytes = Buffer.byteLength(next); + const reservation = this.budget.reserveTransient(nextBytes, { kind: "live_transient" }); + try { + reservation.commitRetained(); + this.budget.releaseRetained(this.bytes, { kind: "live_transient" }); + this.text = next; + this.bytes = nextBytes; + this.hasOpenTag = hasOpenTag; + } catch (error) { + reservation.release(); + throw error; + } + } + + /** Charges only the appended bytes, so holding an open block never needs twice its retained size. */ + private append(delta: string): void { + const deltaBytes = Buffer.byteLength(delta); + this.budget.reserveTransient(deltaBytes, { kind: "live_transient" }).commitRetained(); + this.text += delta; + this.bytes += deltaBytes; + } + + /** Returns immediately safe text and retains only the suffix that still needs reconciliation. */ + ingest(delta: string): string { + if (this.hasOpenTag) { + this.append(delta); + return ""; + } + const split = splitAtPossibleSerializedToolCall(this.text + delta, this.context); + this.replace(split.defer, split.hasOpenTag); + this.context = split.context; + return split.emit; + } + + /** Exposes held text as evidence for narrowly repairing duplicated argument prefixes. */ + current(): string { + return this.text; + } + + /** + * Passes a non-text event through, in order. With an open block candidate held, the event is + * queued behind the held text and a heartbeat stands in for it; with only a partial prefix held (no complete opening tag yet), + * that prefix cannot be a whole duplicate and is released ahead of the event. + */ + hold(event: AdapterEvent): AdapterEvent[] { + if (!this.hasOpenTag) return [...this.drain([]), event]; + const eventBytes = Buffer.byteLength(JSON.stringify(event)); + this.budget.reserveTransient(eventBytes, { kind: "live_transient" }).commitRetained(); + this.bytes += eventBytes; + this.queued.push({ offset: this.text.length, event }); + // The consumer still sees activity, so a stall watchdog never mistakes a held turn for a dead one. + return [{ type: "heartbeat" }]; + } + + /** + * Drains held text and queued events in their original order, suppressing only blocks that + * duplicate a dispatched call; pass an empty list on failure to preserve everything. + */ + drain(structuredCalls: readonly StructuredToolCallReference[]): AdapterEvent[] { + const removed = duplicatedSerializedToolCallRanges(this.text, structuredCalls, this.context); + const kept = (from: number, to: number): string => { + let piece = ""; + let cursor = from; + for (const range of removed) { + if (range.end <= cursor || range.start >= to) continue; + piece += this.text.slice(cursor, Math.max(cursor, range.start)); + cursor = Math.min(to, range.end); + } + return piece + this.text.slice(cursor, to); + }; + const out: AdapterEvent[] = []; + let cursor = 0; + for (const boundary of [...this.queued, { offset: this.text.length, event: undefined }]) { + const text = kept(cursor, boundary.offset); + if (text.length > 0) out.push({ type: "text_delta", text }); + if (boundary.event) out.push(boundary.event); + cursor = boundary.offset; + } + // Later text continues after what was drained, so its line and fence state carry forward. + this.context = contextAfter(this.text, this.context); + this.queued = []; + this.replace("", false); + return out; + } + + /** Text-only drain for callers that never queued an event. */ + flush(structuredCalls: readonly StructuredToolCallReference[]): string { + return this.drain(structuredCalls) + .map(event => (event.type === "text_delta" ? event.text : "")) + .join(""); + } + + /** Releases retained bytes when the stream ends or its consumer cancels iteration. */ + dispose(): void { + this.budget.releaseRetained(this.bytes, { kind: "live_transient" }); + this.text = ""; + this.bytes = 0; + this.hasOpenTag = false; + this.queued = []; + } +} + +/** Reads only a string input from a JSON object; other argument shapes cannot prove duplication. */ +function inputFromArguments(argumentsText: string): string | undefined { + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const input = (parsed as Record).input; + return typeof input === "string" ? input : undefined; + } catch { + return undefined; + } +} + +/** The `[start, end)` ranges of blocks whose function identity and freeform input match a dispatched call. */ +function duplicatedSerializedToolCallRanges( + text: string, + structuredCalls: readonly StructuredToolCallReference[], + context?: TextContext, +): { start: number; end: number }[] { + if (structuredCalls.length === 0) return []; + return callsIn(text, context).filter(call => { + const body = call.body.trimEnd(); + return structuredCalls.some(structured => + structured.names.has(call.name) && inputFromArguments(structured.argumentsText)?.trimEnd() === body); + }); +} + +/** Removes eligible blocks only when both the function identity and freeform input match a dispatched call. */ +export function stripDuplicatedSerializedToolCalls( + text: string, + structuredCalls: readonly StructuredToolCallReference[], + context?: TextContext, +): string { + let result = ""; + let cursor = 0; + for (const range of duplicatedSerializedToolCallRanges(text, structuredCalls, context)) { + result += text.slice(cursor, range.start); + cursor = range.end; + } + return result + text.slice(cursor); +} + +/** Removes a malformed argument prefix only when a bare block and the JSON suffix prove identical input. */ +export function repairArgumentsDuplicatedBesideSerializedCall( + argumentsText: string, + functionNames: ReadonlySet, + serializedText: string, +): string { + try { + JSON.parse(argumentsText); + return argumentsText; + } catch { + // Continue only for the exact duplication shape emitted by some Chat gateways. + } + + const bodies = callsIn(serializedText) + .filter(call => functionNames.has(call.name)) + .map(call => call.body.trimEnd()); + if (bodies.length === 0) return argumentsText; + + for (const body of bodies) { + if (!argumentsText.startsWith(body)) continue; + let start = body.length; + while (start < argumentsText.length && /\s/.test(argumentsText[start]!)) start += 1; + const candidate = argumentsText.slice(start); + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const input = (parsed as Record).input; + if (typeof input !== "string") continue; + if (body !== input.trimEnd()) continue; + return candidate; + } + return argumentsText; +} + +/** + * One structured call as the reconciler sees it: both the wire name and its restored client name + * identify it, and its arguments are repaired against the visible text the same response carried. + */ +export function reconcileStructuredToolCall( + wireName: string, + restoredName: string, + argumentsText: string, + serializedText: string, +): StructuredToolCallReference { + const names = new Set([wireName, restoredName]); + return { names, argumentsText: repairArgumentsDuplicatedBesideSerializedCall(argumentsText, names, serializedText) }; +} + +/** + * Buffered-response counterpart of the streaming path, applied in place to the content events in + * `events[start, end)`. It replays them through the same buffer the stream uses, so both paths + * share one rule set: text carries its line and fence context across events (the inline-think + * splitter may cut one answer into several), any other event keeps its place relative to held + * text, and only text still held at the end is matched against the structured calls. + */ +export function reconcileSerializedToolCallEvents( + events: AdapterEvent[], + start: number, + end: number, + structuredCalls: readonly StructuredToolCallReference[], + budget: TranslatorBudget, +): void { + if (structuredCalls.length === 0) return; + const buffer = new SerializedToolCallContentBuffer(budget); + const reconciled: AdapterEvent[] = []; + try { + for (const event of events.slice(start, end)) { + if (event.type !== "text_delta") { reconciled.push(...buffer.hold(event).filter(held => held.type !== "heartbeat")); continue; } + const text = buffer.ingest(event.text); + if (text.length > 0) reconciled.push({ type: "text_delta", text }); + } + reconciled.push(...buffer.drain(structuredCalls)); + } finally { + buffer.dispose(); + } + events.splice(start, end - start, ...reconciled); +} diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 6c9da37fad..f6ae1d1c17 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -812,10 +812,34 @@ export interface PiProviderBlock { baseUrl: string; api: string; apiKey: string; - compat?: { sendSessionAffinityHeaders: boolean }; + compat?: PiProviderCompat; models: PiModelEntry[]; } +/** + * The subset of Pi's per-provider `compat` block this export writes. Both keys are part of + * Pi's own model-config schema; an unknown key there would empty the whole config, so nothing + * outside this set is ever emitted. + */ +export interface PiProviderCompat { + sendSessionAffinityHeaders?: boolean; + supportsDeveloperRole?: boolean; +} + +interface PiExportOptions { + sendSessionAffinityHeaders?: boolean; + /** + * Tell Pi to send its system prompt as `system` rather than `developer` (#5664). + * + * Pi sends `developer` for reasoning models by default. On `/v1/chat/completions` the proxy + * forwards the caller's roles verbatim unless a destination has recorded + * `foldDeveloperRoleToSystem`, and many OpenAI-compatible upstreams reject `developer` with a + * 400. `system` is accepted by every destination behind this one provider block, so the export + * states it rather than leaving each user to hand-edit a block the next export rewrites. + */ + foldDeveloperRole?: boolean; +} + export interface PiGeneratedConfig { providers: Record; } @@ -935,7 +959,7 @@ export interface GajaeGeneratedConfig { * model. The rest of this contract (omitting `cost`) is still ours rather than * a claim about Pi's acceptance. */ -function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = false): PiGeneratedConfig { +function buildPiClientConfig(ctx: ExportContext, options: PiExportOptions = {}): PiGeneratedConfig { const models: PiModelEntry[] = []; for (const model of normalizeExportModels(ctx.models)) { // Text is the one modality every routed model supports; anything richer must come @@ -972,19 +996,30 @@ function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = fa } models.push(entry); } + const compat: PiProviderCompat = { + ...(options.sendSessionAffinityHeaders ? { sendSessionAffinityHeaders: true } : {}), + ...(options.foldDeveloperRole ? { supportsDeveloperRole: false } : {}), + }; return { providers: { [OPENCODE_PROVIDER_ID]: { baseUrl: ctx.baseUrl, api: PI_API_DIALECT, apiKey: LOOPBACK_API_KEY_PLACEHOLDER, - ...(sendSessionAffinityHeaders ? { compat: { sendSessionAffinityHeaders: true } } : {}), + ...(Object.keys(compat).length > 0 ? { compat } : {}), models, }, }, }; } +/** + * Pi's export options, shared by `ocx export --client pi` and the managed contribution so the two + * never drift apart at the first refresh. omo uses the same options: senpi documents both keys in + * its models.json `compat` block (docs/models.md, docs/custom-provider.md). + */ +const PI_EXPORT_OPTIONS: PiExportOptions = { sendSessionAffinityHeaders: true, foldDeveloperRole: true }; + /** Do not let provider-controlled catalog text become an environment lookup. */ function containsEnvInterpolation(value: string): boolean { return value.includes("${"); @@ -1162,7 +1197,7 @@ function buildOpencodeContribution(ctx: ExportContext): ManagedContribution { } function buildPiContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx, true); + const doc = buildPiClientConfig(ctx, PI_EXPORT_OPTIONS); return singleFragment("pi", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } @@ -1254,7 +1289,7 @@ function buildAsideContribution(ctx: ExportContext): ManagedContribution { * the two would drift apart at the first refresh. */ function buildOmoContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx, true); + const doc = buildPiClientConfig(ctx, PI_EXPORT_OPTIONS); return singleFragment("omo", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } @@ -1296,7 +1331,7 @@ export const EXPORT_CLIENTS: Record = { destination: env => piConfigPath(env), apiKeyEnv: "", exportHint: "Pi reads a non-secret placeholder from models.json; loopback needs no key.", - build: ctx => buildPiClientConfig(ctx, true), + build: ctx => buildPiClientConfig(ctx, PI_EXPORT_OPTIONS), format: "json", summarize: summarizePi, buildContribution: buildPiContribution, @@ -1479,7 +1514,7 @@ export const EXPORT_CLIENTS: Record = { destination: env => omoConfigPath(env), apiKeyEnv: "", exportHint: "omo reads a non-secret placeholder from models.json; loopback needs no key.", - build: ctx => buildPiClientConfig(ctx, true), + build: ctx => buildPiClientConfig(ctx, PI_EXPORT_OPTIONS), format: "json", summarize: summarizePi, buildContribution: buildOmoContribution, diff --git a/structure/decisions/ADR-5548-serialized-tool-call-content.md b/structure/decisions/ADR-5548-serialized-tool-call-content.md new file mode 100644 index 0000000000..b0677e485f --- /dev/null +++ b/structure/decisions/ADR-5548-serialized-tool-call-content.md @@ -0,0 +1,12 @@ +# ADR-5548 — decision recorded under "Serialized tool-call content" + +- Contract owner: [providers/chat-compat.md](../providers/chat-compat.md#serialized-tool-call-content) + +## Decision record + +- Intent: Prevent a gateway's duplicated serialized tool-call markup from becoming visible assistant text or malformed executable input. +- Prior constraint: Chat content is user-visible and must otherwise stream without delay; structured tool-call arguments are upstream-owned bytes. +- Alternatives considered: Drop all tool-call-looking content, add a provider-specific switch, or reconcile serialized blocks with structured calls at the Chat adapter boundary. +- Choice: Hold only a possible complete markup block and suppress or repair it only when the function name and duplicated body agree with a structured call in the same response. +- Why: Agreement between both representations is deterministic and avoids changing ordinary commentary, mismatched markup, or unrelated providers' valid text. +- Consequences: Matching calls no longer appear twice; same-name/different-body examples remain visible; the small held region is translator-budgeted and emits heartbeats while held; terminal failures retain held text without dispatching tools; malformed concatenated arguments are repaired only for the exact duplicated wrapper shape. diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index a548a7eb84..4b37c35334 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -46,7 +46,7 @@ only canonical Fable, Opus, or Sonnet labels after removing terminal controls; u | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/declaration-carrier.ts`, `src/adapters/input-media-guard.ts` | Default-deny allowlists for constraints the normalized request carries but a wire may not be able to express: `tools[*].allowed_callers`, which fences a tool off from callers, and inline document bytes. Both are refused with a 400 at the single guard every registered adapter passes through, rather than left to each adapter, because an adapter that never learned about the carrier rebuilds without it and answers normally. `allowed_callers` reaches the `anthropic` wire; document bytes reach `anthropic`, `openai-chat` and `google`; the `openai-responses` wire is exempt from the whole guard because it forwards the original body. Adding an `AdapterWire` member makes the omission visible in these lists instead of at a customer's upstream. The unrestricted `["direct"]` caller default is not a restriction. | diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 2b50d3f084..f595d2fff5 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -317,6 +317,29 @@ lookalike hosts, and custom proxy paths fail validation. A model override replac merges the provider-wide default, keeping precedence deterministic. With no preference configured, the request body is byte-for-byte unchanged in this area and OpenRouter retains its default routing. +## Serialized tool-call content + +Some Chat gateways expose one model-produced call twice: as a complete +`…` content block and as a structured `tool_calls` +entry. `src/adapters/openai-chat/serialized-tool-call-content.ts` recognizes bare blocks at the +start of a line outside Markdown fences; inline, quoted and indented examples remain unchanged. +It holds a possible serialized block, resumes ordinary text delivery when the header cannot match, +and removes the block only when its function name and +freeform body match a structured call's parsed `input` in the same response. If the gateway also prefixes the structured call's JSON +arguments with the same freeform body, the adapter keeps the JSON suffix only when the block body, +prefix, and wrapper's `input` value all agree. Mismatched markup and arguments remain byte-exact. +Silent held-content frames emit adapter heartbeats. Terminal errors and transport read failures +drain all held text, including matching serialized blocks, because pending tools are not dispatched. +The held bytes use the shared translator budget. For a model opted into inline `` splitting, +reconciliation sees only the answer text the splitter emits. A reasoning event that arrives while +a block candidate is held waits behind it and is released in its original position, so event order +never changes and a duplicate is not exposed early; line and fence context carry across the +answer text on both sides of a think section. Streaming and buffered responses use the same +matching and repair rules; regression coverage enters through `/v1/responses` in +`tests/responses/responses-chat-tool-call-content.test.ts`. + +> Decision record: [ADR-5548](../decisions/ADR-5548-serialized-tool-call-content.md) + ## Kimi Coding Plan prompt-cache affinity The canonical `kimi` OAuth and `kimi-code` API-key presets opt into forwarding the internal diff --git a/tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts b/tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts new file mode 100644 index 0000000000..e0d5e772ec --- /dev/null +++ b/tests/adapters/openai/openai-chat-sanitization-review-regressions.test.ts @@ -0,0 +1,338 @@ +import { expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { AdapterEvent, OcxParsedRequest, OcxTool } from "../../../src/types"; +import { namespacedToolName } from "../../../src/types/tools"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const provider = { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "fixture-key", +} as const; + +const frame = (delta: Record, finishReason?: string) => ({ + choices: [{ delta, ...(finishReason ? { finish_reason: finishReason } : {}) }], +}); +const sse = (value: unknown): string => `data: ${JSON.stringify(value)}\n\n`; +const textOf = (events: AdapterEvent[]): string => events + .filter((event): event is Extract => event.type === "text_delta") + .map(event => event.text).join(""); +const argsOf = (events: AdapterEvent[]): string => events + .filter((event): event is Extract => event.type === "tool_call_delta") + .map(event => event.arguments).join(""); +const tool = (input: string) => ({ + index: 0, + id: "call_exec", + function: { name: "exec", arguments: input }, +}); +const block = (name: string, body: string): string => + `${body}\n`; + +async function buffered(content: string, argumentsText: string): Promise { + return createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { content, tool_calls: [tool(argumentsText)] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); +} + +async function streamed(frames: unknown[], done = true): Promise { + const response = new Response(frames.map(sse).join("") + (done ? "data: [DONE]\n\n" : "")); + const result: AdapterEvent[] = []; + for await (const event of createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget())) { + result.push(event); + } + return result; +} + +test("review control: reported duplicated input is repaired in buffered and streaming modes", async () => { + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const content = "Running it.\n" + block("exec", script); + const bufferedEvents = await buffered(content, script + argumentsText); + const streamingEvents = await streamed([ + frame({ content }), + frame({ tool_calls: [tool(script + argumentsText)] }), + frame({}, "tool_calls"), + ]); + for (const events of [bufferedEvents, streamingEvents]) { + expect(textOf(events)).toBe("Running it.\n"); + expect(argsOf(events)).toBe(argumentsText); + expect(events.some(event => event.type === "error")).toBe(false); + } +}); + +test("review P1: held content frames must still yield adapter activity", async () => { + const script = "text('ok');"; + const chunks = ["Running it.\n", "", "", script, ""]; + let controller!: ReadableStreamDefaultController; + const encoder = new TextEncoder(); + const stream = new ReadableStream({ start(value) { controller = value; } }); + const events: AdapterEvent[] = []; + const progressByChunk: boolean[] = []; + const pump = (async () => { + for await (const event of createOpenAIChatAdapter(provider).parseStream!(new Response(stream), createTestTranslatorBudget())) { + events.push(event); + } + })(); + + try { + for (const content of chunks) { + const before = events.length; + controller.enqueue(encoder.encode(sse(frame({ content })))); + await new Promise(resolve => setTimeout(resolve, 0)); + progressByChunk.push(events.slice(before).some(event => + event.type === "text_delta" || event.type === "heartbeat")); + } + controller.enqueue(encoder.encode( + sse(frame({ tool_calls: [tool(JSON.stringify({ input: script }))] })) + + sse(frame({}, "tool_calls")) + + "data: [DONE]\n\n", + )); + } finally { + controller.close(); + await pump; + } + expect(textOf(events)).toBe("Running it.\n"); + expect(progressByChunk).toEqual(chunks.map(() => true)); +}); + +test("review P2: a same-name different-body fenced example is not a duplicate", async () => { + const example = "Example only:\n```xml\ntext('example');\n```\nActual call follows."; + const argumentsText = JSON.stringify({ input: "text('actual');" }); + const bufferedEvents = await buffered(example, argumentsText); + const streamingEvents = await streamed([ + frame({ content: example }), + frame({ tool_calls: [tool(argumentsText)] }), + frame({}, "tool_calls"), + ]); + for (const events of [bufferedEvents, streamingEvents]) { + expect(textOf(events)).toBe(example); + expect(argsOf(events)).toBe(argumentsText); + } +}); + +test("review P2: previously received nonduplicate text survives a terminal upstream error", async () => { + const chunks = ["Explanation: ", "", " is an XML-like marker. This is ordinary text."]; + const events = await streamed([ + ...chunks.map(content => frame({ content })), + { error: { message: "fixture error" } }, + ], false); + expect(events.some(event => event.type === "error")).toBe(true); + expect(events.some(event => event.type === "tool_call_start")).toBe(false); + expect(events.some(event => event.type === "done")).toBe(false); + expect(textOf(events)).toBe(chunks.join("")); +}); + +test("review control: unmatched marker content survives normal completion", async () => { + const content = "Explanation: is just a literal marker."; + const events = await streamed([frame({ content }), frame({}, "stop")]); + expect(textOf(events)).toBe(content); + expect(events.some(event => event.type === "done")).toBe(true); +}); + +test("only the matching block is removed when several blocks use the same name", async () => { + const example = block("exec", "text('example');"); + const actual = block("exec", "text('actual');"); + const content = `Example:\n${example}\nActual:\n${actual}`; + const argumentsText = JSON.stringify({ input: "text('actual');" }); + const expected = `Example:\n${example}\nActual:\n`; + for (const events of [ + await buffered(content, argumentsText), + await streamed([frame({ content }), frame({ tool_calls: [tool(argumentsText)] }), frame({}, "tool_calls")]), + ]) { + expect(textOf(events)).toBe(expected); + expect(argsOf(events)).toBe(argumentsText); + } +}); + +test("held ordinary text survives malformed SSE and pending-call truncation", async () => { + const prefix = "Explanation: is ordinary text."; + const malformedEvents: AdapterEvent[] = []; + const malformed = new Response(sse(frame({ content: prefix })) + "data: {bad json\n\n"); + for await (const event of createOpenAIChatAdapter(provider).parseStream!(malformed, createTestTranslatorBudget())) { + malformedEvents.push(event); + } + expect(textOf(malformedEvents)).toBe(prefix); + expect(malformedEvents.some(event => event.type === "error")).toBe(true); + + const truncatedEvents = await streamed([ + frame({ content: prefix }), + frame({ tool_calls: [tool('{"input":"unfinished"')] }), + ], false); + expect(textOf(truncatedEvents)).toBe(prefix); + expect(truncatedEvents.some(event => event.type === "error")).toBe(true); + expect(truncatedEvents.some(event => event.type === "tool_call_start")).toBe(false); +}); + +test.each([ + ["upstream error", sse({ error: { message: "fixture error" } })], + ["error finish reason", sse({ choices: [{ finish_reason: "error", error: { message: "fixture error" } }] })], + ["malformed SSE", "data: {bad json\n\n"], + ["invalid choices", sse({ choices: {} })], + ["invalid tool calls", sse(frame({ tool_calls: {} }))], + ["truncated stream", ""], +])("terminal %s retains serialized text without dispatching its pending tool", async (_, terminal) => { + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const content = "Running it.\n" + block("exec", script); + const response = new Response(sse(frame({ content })) + + sse(frame({ tool_calls: [tool(argumentsText)] })) + terminal); + const events: AdapterEvent[] = []; + for await (const event of createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget())) events.push(event); + expect(textOf(events)).toBe(content); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type === "error")).toBe(true); + expect(events.some(event => event.type.startsWith("tool_call_") || event.type === "done")).toBe(false); +}); + +test("an unnamed pending call cannot hide text for a later undispatched call", async () => { + const script = "text('ok');"; + const content = block("exec", script); + const events = await streamed([ + frame({ content }), + frame({ tool_calls: [ + { index: 0, id: "unnamed", function: { arguments: "{}" } }, + { ...tool(JSON.stringify({ input: script })), index: 1 }, + ] }), + ]); + expect(textOf(events)).toBe(content); + expect(events.at(-1)?.type).toBe("error"); + expect(events.some(event => event.type.startsWith("tool_call_") || event.type === "done")).toBe(false); +}); + +test("tolerant EOF evaluates narrowly repaired arguments before rejecting the call", async () => { + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const tolerantProvider = { ...provider, openaiChatEofTolerance: true }; + const response = new Response( + sse(frame({ content: block("exec", script) })) + + sse(frame({ tool_calls: [tool(script + argumentsText)] })), + ); + const events: AdapterEvent[] = []; + for await (const event of createOpenAIChatAdapter(tolerantProvider).parseStream!(response, createTestTranslatorBudget())) { + events.push(event); + } + expect(textOf(events)).toBe(""); + expect(argsOf(events)).toBe(argumentsText); + expect(events.some(event => event.type === "done")).toBe(true); + expect(events.some(event => event.type === "error")).toBe(false); +}); + +test("wire aliases use the restored identity for suppression and argument repair", async () => { + const namespace = "mcp__codex_apps__codex_document_control"; + const name = "execute_document_command"; + const originalName = namespacedToolName(namespace, name); + const declared: OcxTool = { namespace, name, description: "fixture", parameters: { type: "object" } }; + const parsed: OcxParsedRequest = { + modelId: "test-model", + stream: false, + options: {}, + context: { tools: [declared], messages: [{ role: "user", content: "run", timestamp: 0 }] }, + }; + const adapter = createOpenAIChatAdapter(provider); + const request = adapter.buildRequest(parsed, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }); + if (request instanceof Promise) throw new Error("unexpected async request"); + const alias = (JSON.parse(request.body) as { tools: Array<{ function: { name: string } }> }).tools[0]!.function.name; + expect(alias).not.toBe(originalName); + + const script = "text('ok');"; + const argumentsText = JSON.stringify({ input: script }); + const events = await adapter.parseResponse!(Response.json({ + choices: [{ + message: { content: block(originalName, script), tool_calls: [{ ...tool(script + argumentsText), function: { name: alias, arguments: script + argumentsText } }] }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + expect(textOf(events)).toBe(""); + expect(argsOf(events)).toBe(argumentsText); +}); + +test("ambiguous raw closing delimiters are preserved instead of partially suppressed", async () => { + const script = "text('');"; + const content = block("exec", script); + const argumentsText = script + JSON.stringify({ input: script }); + const events = await buffered(content, argumentsText); + expect(textOf(events)).toBe(content); + expect(argsOf(events)).toBe(argumentsText); +}); + +test("quoted and fenced copies remain visible beside an identical actual call, at every split", async () => { + const script = "text('ok');"; + const actual = block("exec", script); + const argumentsText = JSON.stringify({ input: script }); + for (const example of [ + `Example: \`${actual}\`\n`, + `Example: \`\`\n${actual}\n\`\`\n`, + `> ${actual}\n`, + `\`\`\`xml\n${actual}\n\`\`\`\n`, + ` ~~~~xml\n${actual}\n~~~\n${actual}\n~~~~\n`, + ]) { + const content = example + actual; + expect(textOf(await buffered(content, argumentsText))).toBe(example); + for (let split = 0; split <= content.length; split++) { + const events = await streamed([ + frame({ content: content.slice(0, split) }), + frame({ content: content.slice(split) }), + frame({ tool_calls: [tool(argumentsText)] }), + frame({}, "tool_calls"), + ]); + expect(textOf(events)).toBe(example); + expect(argsOf(events)).toBe(argumentsText); + } + } +}); + +test("a quoted example cannot authorize argument repair", async () => { + const script = "text('ok');"; + const content = `\`\`\`xml\n${block("exec", script)}\n\`\`\``; + const argumentsText = script + JSON.stringify({ input: script }); + for (const events of [ + await buffered(content, argumentsText), + await streamed([frame({ content }), frame({ tool_calls: [tool(argumentsText)] }), frame({}, "tool_calls")]), + ]) { + expect(textOf(events)).toBe(content); + expect(argsOf(events)).toBe(argumentsText); + } +}); + +test("literal markers resume text delivery before the terminal frame", async () => { + const chunks = ["", " is an XML-like marker.", " More ordinary text."]; + const response = new Response(chunks.map(content => sse(frame({ content }))).join("") + sse(frame({}, "stop"))); + const iterator = createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget()); + try { + expect((await iterator.next()).value).toEqual({ type: "heartbeat" }); + expect((await iterator.next()).value).toEqual({ type: "text_delta", text: chunks[0]! + chunks[1]! }); + expect((await iterator.next()).value).toEqual({ type: "text_delta", text: chunks[2]! }); + } finally { + await iterator.return(); + } +}); + +test.each([false, true])("transport read failures retain held text with a pending matching call: %s", async (pendingCall) => { + const script = "text('ok');"; + const content = block("exec", script); + const failure = new Error("fixture read failure"); + let sent = false; + const response = new Response(new ReadableStream({ + pull(controller) { + if (sent) controller.error(failure); + else { + sent = true; + controller.enqueue(new TextEncoder().encode(sse(frame({ content })) + + (pendingCall ? sse(frame({ tool_calls: [tool(JSON.stringify({ input: script }))] })) : ""))); + } + }, + })); + const events: AdapterEvent[] = []; + let caught: unknown; + try { + for await (const event of createOpenAIChatAdapter(provider).parseStream!(response, createTestTranslatorBudget())) events.push(event); + } catch (error) { + caught = error; + } + expect(caught).toBe(failure); + expect(textOf(events)).toBe(content); + expect(events.some(event => event.type === "tool_call_start" || event.type === "done")).toBe(false); +}); diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts new file mode 100644 index 0000000000..e1e64287df --- /dev/null +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-content.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; +import { createTestTranslatorBudget } from "../../helpers/translator-budget"; + +const provider = { adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", apiKey: "key" } as const; + +test("buffered Chat responses reconcile matching serialized and structured tool calls", async () => { + const script = "text('ok');"; + const content = `Running it.\n${script}\n`; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [{ + id: "call_exec", + function: { name: "exec", arguments: script + JSON.stringify({ input: script }) }, + }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.filter(event => event.type === "text_delta")).toEqual([ + { type: "text_delta", text: "Running it.\n" }, + ]); + expect(events.find(event => event.type === "tool_call_delta")).toEqual({ + type: "tool_call_delta", + arguments: JSON.stringify({ input: script }), + }); +}); + +test("buffered Chat responses preserve serialized markup for a different function", async () => { + const content = "literal example"; + const events = await createOpenAIChatAdapter(provider).parseResponse!(Response.json({ + choices: [{ + message: { + content, + tool_calls: [{ id: "call_exec", function: { name: "exec", arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }], + }), createTestTranslatorBudget()); + + expect(events.find(event => event.type === "text_delta")).toEqual({ type: "text_delta", text: content }); +}); + +test("an open serialized block charges only its appended bytes", () => { + const open = ""; + const body = "x".repeat(open.length); + // Rebuilding the whole buffer would reserve the new total beside the retained + // text, so this exact budget only admits the append when it charges the delta. + const budget = createTestTranslatorBudget({ maxTurnBytes: open.length + body.length }); + const buffer = new SerializedToolCallContentBuffer(budget); + + expect(buffer.ingest(open)).toBe(""); + expect(buffer.ingest(body)).toBe(""); + expect(buffer.current()).toBe(open + body); + expect(budget.snapshot()).toMatchObject({ currentBytes: open.length + body.length, overflows: 0 }); + + expect(buffer.flush([])).toBe(open + body); + expect(budget.snapshot()).toMatchObject({ currentBytes: 0, overflows: 0 }); +}); diff --git a/tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts b/tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts new file mode 100644 index 0000000000..4a65bc199e --- /dev/null +++ b/tests/adapters/openai/openai-chat-serialized-tool-call-think.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../../../src/adapters/openai-chat"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import { SerializedToolCallContentBuffer } from "../../../src/adapters/openai-chat/serialized-tool-call-content"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; + +/** + * Serialized tool-call reconciliation (#5548) composed with inline splitting: the + * reconciler only ever sees answer text, so reasoning is never held or stripped, and a block + * whose input differs from the structured call stays visible byte for byte. + */ +const MODEL = "mimo-v2.6-flash"; +const SCRIPT = "text('ok');"; +const block = (body: string): string => `${body}\n`; +const call = (input: string) => ({ index: 0, id: "call_exec", function: { name: "exec", arguments: JSON.stringify({ input }) } }); + +function adapterFor(inlineThink: boolean) { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://gateway.example.test/v1", + apiKey: "key", + ...(inlineThink ? { inlineThinkTagModels: [MODEL] } : {}), + }; + const adapter = withTestTranslatorBudget(createOpenAIChatAdapter(provider)); + const parsed: OcxParsedRequest = { + modelId: MODEL, + stream: true, + options: {}, + context: { messages: [{ role: "user", content: "ping", timestamp: 0 }] }, + }; + adapter.buildRequest(parsed); + return adapter; +} + +async function streamed(inlineThink: boolean, content: string[], input: string): Promise { + const frames = [ + ...content.map(text => ({ choices: [{ delta: { content: text } }] })), + { choices: [{ delta: { tool_calls: [call(input)] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapterFor(inlineThink).parseStream(new Response(body))) { + if (event.type !== "heartbeat") events.push(event); + } + return events; +} + +async function buffered(inlineThink: boolean, content: string, input: string): Promise { + return adapterFor(inlineThink).parseResponse!(Response.json({ + choices: [{ message: { content, tool_calls: [call(input)] }, finish_reason: "tool_calls" }], + })); +} + +const joined = (events: AdapterEvent[], type: "text_delta" | "reasoning_raw_delta"): string => events + .filter((event): event is Extract => event.type === type) + .map(event => event.text) + .join(""); +const argsOf = (events: AdapterEvent[]): string => events + .filter((event): event is Extract => event.type === "tool_call_delta") + .map(event => event.arguments) + .join(""); +const toolStarts = (events: AdapterEvent[]): number => events.filter(event => event.type === "tool_call_start").length; + +describe("serialized tool-call reconciliation behind inline splitting", () => { + test("a duplicated block after a think block is removed while reasoning is kept, streamed and buffered", async () => { + const content = "plan the callRunning it.\n" + block(SCRIPT); + const runs = [ + await streamed(true, ["plan the ", "callRunning it.\n", block(SCRIPT)], SCRIPT), + await buffered(true, content, SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "reasoning_raw_delta")).toBe("plan the call"); + expect(joined(events, "text_delta")).toBe("Running it.\n"); + expect(argsOf(events)).toBe(JSON.stringify({ input: SCRIPT })); + expect(toolStarts(events)).toBe(1); + expect(events.some(event => event.type === "error")).toBe(false); + const reasoningAt = events.findIndex(event => event.type === "reasoning_raw_delta"); + const textAt = events.findIndex(event => event.type === "text_delta"); + expect(reasoningAt).toBeGreaterThanOrEqual(0); + expect(reasoningAt).toBeLessThan(textAt); + } + }); + + test("a block whose input differs from the structured call stays visible byte for byte", async () => { + const shown = "Example:\n" + block("text('example');"); + const runs = [ + await streamed(true, ["x", shown], SCRIPT), + await buffered(true, "x" + shown, SCRIPT), + await streamed(false, [shown], SCRIPT), + await buffered(false, shown, SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "text_delta")).toBe(shown); + expect(argsOf(events)).toBe(JSON.stringify({ input: SCRIPT })); + expect(toolStarts(events)).toBe(1); + } + }); + + test("without the inline-think opt-in a literal think tag stays answer text and the duplicate is still removed", async () => { + const lead = "not parsed hereRunning it.\n"; + for (const events of [await streamed(false, [lead, block(SCRIPT)], SCRIPT), await buffered(false, lead + block(SCRIPT), SCRIPT)]) { + expect(joined(events, "reasoning_raw_delta")).toBe(""); + expect(joined(events, "text_delta")).toBe(lead); + expect(toolStarts(events)).toBe(1); + } + }); +}); + + +describe("review folds: order and line context across interleaved think sections", () => { + test("a block that follows visible prose on the same line is kept even when a think section sits between them", async () => { + const content = "r1prefix r2" + block(SCRIPT); + const runs = [ + await buffered(true, content, SCRIPT), + await streamed(true, ["r1prefix ", "r2", block(SCRIPT)], SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "reasoning_raw_delta")).toBe("r1r2"); + expect(joined(events, "text_delta")).toBe("prefix " + block(SCRIPT)); + expect(toolStarts(events)).toBe(1); + } + }); + + test("held text is released before later reasoning, so events keep their original order", async () => { + const shown = "\n" + block("text('example');"); + const runs = [ + await streamed(true, ["first", shown, "second", "answer"], SCRIPT), + await buffered(true, "first" + shown + "secondanswer", SCRIPT), + ]; + for (const events of runs) { + const order = events + .filter(event => event.type === "text_delta" || event.type === "reasoning_raw_delta") + .map(event => (event.type === "text_delta" ? "T:" : "R:") + (event as { text: string }).text); + const firstShown = order.findIndex(item => item.startsWith("T:") && item.includes("")); + const secondReasoning = order.findIndex(item => item.startsWith("R:") && item.includes("second")); + expect(order[0]).toBe("R:first"); + expect(firstShown).toBeGreaterThan(0); + expect(firstShown).toBeLessThan(secondReasoning); + expect(joined(events, "text_delta")).toBe(shown + "answer"); + } + }); +}); + + +describe("review folds, round two", () => { + test("a duplicate between two think sections stays suppressed when its structured call arrives later", async () => { + const runs = [ + await streamed(true, ["r1", block(SCRIPT), "r2"], SCRIPT), + await buffered(true, "r1" + block(SCRIPT) + "r2", SCRIPT), + ]; + for (const events of runs) { + expect(joined(events, "text_delta")).not.toContain(""); + const reasoning = events.filter(event => event.type === "reasoning_raw_delta").map(event => (event as { text: string }).text); + expect(reasoning.join("")).toBe("r1r2"); + expect(toolStarts(events)).toBe(1); + expect(argsOf(events)).toBe(JSON.stringify({ input: SCRIPT })); + } + }); + + test("after a drain, a second block on the same visible line is not treated as line-start markup", () => { + const buffer = new SerializedToolCallContentBuffer(createTestTranslatorBudget()); + try { + const first = block("text('a');"); + expect(buffer.ingest(first)).toBe(""); + expect(buffer.flush([])).toBe(first); + const second = block(SCRIPT); + // Mid-line after the first block, so it is ordinary text and cannot be held or removed. + expect(buffer.ingest(second)).toBe(second); + const names = new Set(["exec"]); + expect(buffer.flush([{ names, argumentsText: JSON.stringify({ input: SCRIPT }) }])).toBe(""); + } finally { + buffer.dispose(); + } + }); +}); + + +describe("review folds, round three", () => { + test("every reasoning frame that arrives behind a held block still yields adapter activity", async () => { + const frames = [ + { choices: [{ delta: { content: "" + SCRIPT } }] }, + { choices: [{ delta: { reasoning_content: "one" } }] }, + { choices: [{ delta: { reasoning_content: "two" } }] }, + { choices: [{ delta: { content: "\n" } }] }, + { choices: [{ delta: { tool_calls: [call(SCRIPT)] } }] }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ]; + const body = frames.map(frame => "data: " + JSON.stringify(frame) + "\n\n").join("") + "data: [DONE]\n\n"; + const events: AdapterEvent[] = []; + for await (const event of adapterFor(false).parseStream(new Response(body))) events.push(event); + const heartbeats = events.filter(event => event.type === "heartbeat").length; + // One for the held content frame, one per queued reasoning frame, one for the closing content frame. + expect(heartbeats).toBeGreaterThanOrEqual(4); + expect(joined(events, "reasoning_raw_delta")).toBe("onetwo"); + expect(joined(events, "text_delta")).toBe(""); + expect(toolStarts(events)).toBe(1); + }); +}); diff --git a/tests/clients/omo-client.test.ts b/tests/clients/omo-client.test.ts index aac1cffbcb..bab9af682c 100644 --- a/tests/clients/omo-client.test.ts +++ b/tests/clients/omo-client.test.ts @@ -43,14 +43,15 @@ describe("omo client config", () => { * Prime and Aside reuse Pi's builder with the session-affinity flag left at * its default, because nobody has verified that their engines read it. omo's * engine WAS verified: senpi's compiled validator accepts `compat` with - * `sendSessionAffinityHeaders`, so omo opts in and the generated provider is + * `sendSessionAffinityHeaders`, and senpi documents `supportsDeveloperRole` + * in the same block (#5664), so omo opts in and the generated provider is * byte-identical to Pi's. */ test("is Pi's document including the session-affinity opt-in", () => { const omo = buildClientConfig("omo", context()) as PiGeneratedConfig; const pi = buildClientConfig("pi", context()) as PiGeneratedConfig; expect(omo).toEqual(pi); - expect(omo.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true }); + expect(omo.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true, supportsDeveloperRole: false }); }); /** diff --git a/tests/clients/prime-client.test.ts b/tests/clients/prime-client.test.ts index 6c7c0a2f76..eaae451d17 100644 --- a/tests/clients/prime-client.test.ts +++ b/tests/clients/prime-client.test.ts @@ -40,7 +40,7 @@ describe("Prime Agent client config", () => { test("shares Pi's model contract without opting Prime into session headers", () => { const prime = buildClientConfig("prime", context()) as PiGeneratedConfig; const pi = buildClientConfig("pi", context()) as PiGeneratedConfig; - expect(pi.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true }); + expect(pi.providers[OPENCODE_PROVIDER_ID]!.compat).toEqual({ sendSessionAffinityHeaders: true, supportsDeveloperRole: false }); delete pi.providers[OPENCODE_PROVIDER_ID]!.compat; expect(prime).toEqual(pi); expect(buildClientContribution("prime", context()).fragments[0]!.value) diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 1884dc941c..332f6b19d0 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -320,6 +320,17 @@ describe("Pi serializer (accept criterion 2)", () => { expect(buildClientContribution("pi", ctx()).fragments[0]!.value).toEqual(provider); }); + test("tells Pi to send system instead of developer, on export and contribution alike (#5664)", () => { + // Pi sends `developer` for reasoning models; the native Chat route forwards roles verbatim, + // and upstreams such as DashScope compatible-mode reject that role with a 400. + expect(piConfig().providers.opencodex!.compat).toEqual({ + sendSessionAffinityHeaders: true, + supportsDeveloperRole: false, + }); + expect(buildClientContribution("pi", ctx()).fragments[0]!.value) + .toHaveProperty("compat.supportsDeveloperRole", false); + }); + test("cost is omitted on every entry — zeros would assert routed models are free", () => { for (const model of piConfig().providers.opencodex!.models) { expect(model).not.toHaveProperty("cost"); @@ -941,7 +952,8 @@ describe("EXPORT_CLIENTS registry", () => { "api": "openai-completions", "apiKey": "opencodex-loopback", "compat": { - "sendSessionAffinityHeaders": true + "sendSessionAffinityHeaders": true, + "supportsDeveloperRole": false }, "models": [ { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 53d5351739..99ba01c343 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -991,7 +991,11 @@ "openai-chat-parallel-stream.test.ts": "adapters/openai", "openai-chat-path-override.test.ts": "adapters/openai", "openai-chat-reasoning-wire-policy.test.ts": "adapters/openai", + "openai-chat-sanitization-review-regressions.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-content.test.ts": "adapters/openai", + "openai-chat-serialized-tool-call-think.test.ts": "adapters/openai", "openai-chat-system-order.test.ts": "adapters/openai", + "responses-chat-tool-call-content.test.ts": "responses", "openai-chat-tool-result-images.test.ts": "adapters/openai", "openai-chat-url.test.ts": "adapters/openai", "openai-chat-video-part.test.ts": "adapters/openai", diff --git a/tests/responses/responses-chat-tool-call-content.test.ts b/tests/responses/responses-chat-tool-call-content.test.ts new file mode 100644 index 0000000000..c9b876a184 --- /dev/null +++ b/tests/responses/responses-chat-tool-call-content.test.ts @@ -0,0 +1,88 @@ +import { afterEach, expect, test } from "bun:test"; +import { handleResponses } from "../../src/server/responses"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +let releaseSpendHome: (() => void) | undefined; + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; +}); + +test("/v1/responses suppresses OpenAI Chat tool-call markup duplicated by a structured call", async () => { + const savedFetch = globalThis.fetch; + const script = "const result = await tools.exec_command({cmd: \"pwd\"});\ntext(result.output);"; + const leaked = `${script}\n`; + const commentary = "I'll run it now.\n"; + const content = commentary + leaked; + const split = commentary.length + 5; + const frames = [ + { choices: [{ delta: { content: content.slice(0, split) } }] }, + { choices: [{ delta: { content: content.slice(split) } }] }, + { + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_exec", + function: { name: "exec", arguments: script + JSON.stringify({ input: script }) }, + }], + }, + }], + }, + { choices: [{ delta: {}, finish_reason: "tool_calls" }] }, + ].map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("") + "data: [DONE]\n\n"; + + globalThis.fetch = (async () => new Response(frames, { + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + releaseSpendHome = acquireOwnedSpendHome(); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/xiaomi-mimo-v2.6-pro", + stream: true, + input: "run pwd", + tools: [{ + type: "custom", + name: "exec", + description: "Run JavaScript", + format: { type: "grammar", syntax: "lark" }, + }], + }), + }), config, { model: "", provider: "" }); + const body = await response.text(); + const payloads = body.split("\n") + .filter(line => line.startsWith("data: {") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice("data: ".length)) as Record); + const inputDone = payloads.find(payload => payload.type === "response.custom_tool_call_input.done"); + const outputText = payloads + .filter(payload => payload.type === "response.output_text.delta") + .map(payload => payload.delta) + .join(""); + + expect(response.status).toBe(200); + expect(body).toContain('"type":"custom_tool_call"'); + expect(inputDone).toMatchObject({ input: script }); + expect(outputText).toBe(commentary); + expect(body).not.toContain(""); + } finally { + globalThis.fetch = savedFetch; + } +});