From 2e2c70ffc15facf32e652d4da78b1abac9e3e648 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:19:57 +0800 Subject: [PATCH 01/23] docs: design DeepSeek Responses terminal repair --- ...ponses-streaming-terminal-repair-design.md | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md diff --git a/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md b/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md new file mode 100644 index 000000000..a377c4867 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md @@ -0,0 +1,315 @@ +# DeepSeek Responses Streaming Terminal Repair Design + +## Goal + +Restore progressive Responses streaming for the built-in `deepseek-v4-flash` +route while keeping Codex turns bounded and terminal-complete when an upstream +stream finishes its output items but omits or indefinitely delays the protocol +terminal event. + +The fix must remove the current slow-JSON failure mode without weakening +failure honesty: only a fully closed, structurally valid output graph may be +promoted to a synthetic `response.completed` event. + +## Context and evidence + +The built-in DeepSeek registry currently declares +`modelResponsesUpstreamStreaming["deepseek-v4-flash"] = false`. Final route +normalization therefore changes `stream:true` to `stream:false` before sending +the request to `POST https://api.deepseek.com/responses`. For an HTTP client +that requested streaming, opencodex waits for the complete JSON response and +only then reframes it as SSE. + +That compatibility policy was introduced for a historical DeepSeek stream that +could deliver output without closing on a Responses terminal event. It now has +two observable costs: + +1. Codex receives no progressive deltas. +2. Non-streaming JSON is guarded by a 30-second body inactivity deadline. Long + context or high-reasoning turns can cross that boundary and fail with + `upstream JSON response stalled before completing` even while the upstream + is still generating a legitimate response. + +A live, minimal capture against the official DeepSeek endpoint on 2026-08-06 +showed that the current `deepseek-v4-flash` stream emits the complete native +Responses lifecycle: + +1. `response.created` +2. reasoning item and reasoning deltas +3. `response.output_item.done` for reasoning +4. function-call argument deltas and `response.function_call_arguments.done` +5. `response.output_item.done` for the function call +6. `response.completed` + +The captured function-call turn completed in roughly eight seconds. This +supports restoring upstream streaming while retaining a provider-scoped repair +for regressions in terminal delivery. + +## Scope + +### In scope + +- The official built-in DeepSeek provider when the resolved model is + `deepseek-v4-flash` and the resolved wire is `openai-responses`. +- HTTP/SSE and Codex WebSocket clients. +- Native reasoning, message, and function-call output items. +- Existing client-facing item-id repair, continuation-state recording, usage + inspection, cancellation, and failed-tail behavior. +- A five-second post-completion grace window for a missing terminal event. + +### Out of scope + +- Changing Chat Completions behavior for DeepSeek, Claude Code, or other + OpenAI-compatible clients. +- Changing the global 30-second bounded-JSON inactivity limit. +- Removing the transport-neutral bounded-JSON capability; other providers may + still need it. +- Treating silence, partial output, malformed tool calls, or unknown output item + types as success. +- Retrying a committed upstream generation. +- Adding a user-facing configuration option in this change. + +## Options considered + +### Remove the non-streaming override only + +This restores progressive output with the smallest diff, but a future terminal +regression would again leave Codex waiting after otherwise complete output. + +### Repair terminal events in the shared relay for every provider + +This covers more gateways but changes global Responses semantics. A heuristic +safe for DeepSeek may be incorrect for another provider, so the blast radius is +not justified. + +### Provider-scoped streaming terminal repair + +This is the selected design. DeepSeek returns to native streaming, while a +registry-only model capability opts its stream into a narrowly defined repair +state machine. Other providers and custom DeepSeek-compatible endpoints remain +unchanged unless they match the built-in registry transport and model. + +## Architecture + +### Registry policy + +Replace DeepSeek's forced non-streaming entry with a registry-only terminal +repair policy for `deepseek-v4-flash`. The policy carries the five-second grace +duration and is resolved only when `providerMatchesRegistryTransport()` accepts +the built-in provider transport. + +The policy is intentionally not persisted into user configuration. It is a +compatibility fact about the official endpoint, analogous to the existing +registry-only wire and streaming hints. + +### Terminal repair stream + +Add a focused module under `src/server/` that wraps a native Responses SSE body. +It has one responsibility: relay complete SSE blocks while tracking whether a +safe synthetic terminal can be emitted. + +The wrapper runs before the body is split for client delivery and background +inspection. Consequently, both branches observe the same real or synthetic +terminal. Existing item-id repair, lifecycle snapshot repair, request logging, +continuation recording, HTTP/SSE delivery, and WebSocket reframing remain +downstream and keep their current ownership. + +The wrapper must use existing SSE framing helpers and the per-turn translator +budget. It may retain only the response-created metadata and completed output +items required to construct a terminal response. Retained state is released on +every terminal, EOF, cancellation, error, and disposal path. + +### Data flow + +```text +DeepSeek Responses SSE + -> provider-scoped terminal repair + -> existing payload/block rewrites (item ids, image calls, snapshots) + -> existing failed-tail and terminal-boundary relay + -> Codex HTTP/SSE or WebSocket client + +The repaired stream is also inspected for: + -> request outcome and usage metadata + -> completed-response continuation state +``` + +## Completion state machine + +### Tracked state + +- The most recent valid `response.created.response` object. +- The highest valid numeric `sequence_number` seen. +- Every valid `response.output_item.added`, keyed by `output_index`. +- Every valid `response.output_item.done`, keyed by `output_index`. +- Whether a real `response.completed`, `response.failed`, or + `response.incomplete` event has arrived. +- Whether a real `data: [DONE]` event has arrived. +- One generation token for the active grace timer, preventing a stale timer + from committing after later activity. + +### Candidate-complete predicate + +A stream is eligible for synthetic success only when all conditions hold: + +1. No real Responses terminal has been observed. +2. At least one `response.output_item.done` has been observed. +3. Every added output index has exactly one corresponding done item. +4. No done item exists for an index whose lifecycle is contradictory or + tainted by malformed duplicate events. +5. Every retained item has `status: "completed"`. +6. Item types are limited to `reasoning`, `message`, and `function_call`. +7. A function call has a non-empty `name`, non-empty `call_id`, string + `arguments`, and arguments that parse as JSON. +8. A message contains only completed output content carried by its done item. +9. The retained state remains inside the existing per-turn translator budget. + +Any malformed, contradictory, oversized, or unsupported item permanently +taints synthetic success for that stream. A later real upstream terminal stays +authoritative and is still relayed. + +### Grace behavior + +When the candidate-complete predicate first becomes true, arm a five-second +timer. Any subsequent non-terminal SSE event invalidates that timer generation, +updates the state, and re-evaluates the predicate. If the stream remains a +complete candidate for the entire grace window, emit one synthetic +`response.completed` event and close the repaired source. + +The synthetic response is based on the created response metadata, with: + +- `status: "completed"` +- `completed_at` set from the injected clock +- `output` set to completed items ordered by `output_index` +- `usage` left unchanged when known and otherwise absent or null +- `sequence_number` set to the next valid sequence number + +After emitting the terminal, cancel the upstream reader. The existing +terminal-boundary relay appends exactly one `[DONE]` sentinel when necessary. + +### EOF and `[DONE]` + +- If EOF or `[DONE]` arrives with a complete candidate and no real terminal, + emit the synthetic completed terminal immediately before closing. +- If EOF or `[DONE]` arrives without a complete candidate, emit + `response.incomplete`, never `response.completed`. +- A mid-stream read error remains owned by the existing failed-tail relay and + becomes `response.failed`. + +### Terminal races + +A real terminal event always wins over the grace timer. Terminal commitment is +guarded by a single boolean transition, and both the timer callback and stream +reader re-check it immediately before enqueueing. Late events after terminal +commitment are dropped, and the upstream reader is cancelled. + +## Error and cancellation behavior + +- A client cancellation follows the existing client-gone and bounded drain + behavior. It never triggers synthetic success. +- A server shutdown abort suppresses synthetic terminal generation. +- A translator-budget overflow fails through the existing typed failure path; + retained repair state is released. +- A malformed SSE block is relayed according to existing passthrough behavior + but taints synthetic success when it affects lifecycle state. +- A real upstream `response.failed` or `response.incomplete` is byte-preserved + apart from already configured downstream rewrites. +- The repair never resends a request after output has been committed. + +## Integration details + +The implementation is expected to touch these responsibility boundaries: + +- `src/providers/registry.ts`: registry-only DeepSeek terminal-repair policy; + remove the official model's forced bounded-JSON streaming override. +- `src/server/responses-terminal-repair.ts`: bounded state machine and stream + wrapper. +- `src/server/responses/core.ts`: resolve the provider policy and wrap the SSE + body before transport-specific relay branches. +- `tests/responses-terminal-repair.test.ts`: unit state-machine coverage. +- `tests/deepseek-inbound-wire.test.ts`: end-to-end wire, progressive delivery, + repair composition, and WebSocket/HTTP activation. +- Existing relay and item-id tests only where an explicit integration contract + needs to be pinned. +- `structure/04_transports-and-sidecars.md`: replace the bounded-JSON-only + DeepSeek description with the streaming plus provider-scoped repair policy. + +No unrelated refactor of `core.ts`, the shared relays, or provider configuration +is part of this change. + +## Test design + +### Unit activation + +1. A healthy captured-shape stream containing reasoning, a function call, and a + real `response.completed` is relayed without a synthetic terminal. +2. A complete reasoning plus function-call sequence that goes silent produces + one synthetic `response.completed` after the injected five-second deadline. +3. A new item during the grace window invalidates the old timer and restarts the + deadline only after the new item completes. +4. A clean EOF and a `[DONE]` event synthesize success immediately only for a + complete candidate. +5. Open items, invalid function arguments, unknown item types, contradictory + indices, malformed lifecycle frames, and budget overflow never synthesize + success. +6. Real completed, failed, and incomplete terminals beat the timer and remain + singular. +7. Client cancellation, upstream reset, and shutdown abort preserve their + existing accounting and terminal behavior. +8. Fragmented UTF-8 and SSE block boundaries produce the same state as a + single-chunk stream. + +Tests use an injected clock/timer seam rather than wall-clock sleeps. + +### Integration activation + +1. A Codex Responses request sends `stream:true` to the official DeepSeek + `/responses` endpoint. +2. The first reasoning or output delta is observable before the upstream emits + its terminal event. +3. A terminal-less, complete function-call fixture closes within the injected + grace deadline and preserves `call_id`, `name`, and `arguments`. +4. HTTP/SSE and WebSocket clients receive equivalent item and terminal + lifecycles. +5. UUID message and reasoning ids remain normalized consistently in added, + delta, done, and synthetic terminal payloads. +6. Chat and Anthropic inbound requests continue to use + `/chat/completions` with no terminal repair activation. + +### Verification gates + +- Focused terminal-repair, DeepSeek wire, relay, WebSocket, item-id, reasoning + replay, and continuation-state tests. +- `bun run typecheck` +- `bun run test` +- `bun run privacy:scan` +- `bun run prepush` +- One minimal live official-DeepSeek streaming smoke test, with no private + prompt content and no credential output. + +## Rollout and compatibility + +The change is provider- and model-scoped. Official DeepSeek Responses clients +gain progressive streaming; Chat and Anthropic clients are unchanged. Custom +providers that happen to use the name `deepseek` but do not match the registry +transport do not inherit the repair. + +The bounded-JSON machinery remains available as a rollback path. If live or CI +evidence reveals an unsafe terminal synthesis condition, the registry can +restore `modelResponsesUpstreamStreaming: false` without changing shared relay +behavior. + +## Acceptance criteria + +- Official `deepseek-v4-flash` Responses requests remain `stream:true` + upstream. +- Codex receives progressive reasoning, text, and function-call deltas. +- Normal live streams preserve the upstream terminal without duplication. +- A fully complete terminal-less output graph closes after five seconds with + exactly one synthetic `response.completed` and one `[DONE]`. +- Partial, malformed, tainted, or unsupported output never becomes synthetic + success. +- Function calls remain executable and continuation state retains completed + reasoning and output items. +- HTTP/SSE and WebSocket behavior agree. +- Existing providers and DeepSeek Chat Completions behavior remain unchanged. +- Focused and full repository verification gates pass. From e019b14346762048d07678c2d3407cec4e8f9b5f Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:24:29 +0800 Subject: [PATCH 02/23] docs: tighten terminal repair completion predicate --- ...sponses-streaming-terminal-repair-design.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md b/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md index a377c4867..9acf1aab7 100644 --- a/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md +++ b/docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md @@ -152,16 +152,18 @@ The repaired stream is also inspected for: A stream is eligible for synthetic success only when all conditions hold: 1. No real Responses terminal has been observed. -2. At least one `response.output_item.done` has been observed. -3. Every added output index has exactly one corresponding done item. -4. No done item exists for an index whose lifecycle is contradictory or +2. A valid `response.created` event with an object-valued response snapshot + has been observed. +3. At least one `response.output_item.done` has been observed. +4. Every added output index has exactly one corresponding done item. +5. No done item exists for an index whose lifecycle is contradictory or tainted by malformed duplicate events. -5. Every retained item has `status: "completed"`. -6. Item types are limited to `reasoning`, `message`, and `function_call`. -7. A function call has a non-empty `name`, non-empty `call_id`, string +6. Every retained item has `status: "completed"`. +7. Item types are limited to `reasoning`, `message`, and `function_call`. +8. A function call has a non-empty `name`, non-empty `call_id`, string `arguments`, and arguments that parse as JSON. -8. A message contains only completed output content carried by its done item. -9. The retained state remains inside the existing per-turn translator budget. +9. A message contains only completed output content carried by its done item. +10. The retained state remains inside the existing per-turn translator budget. Any malformed, contradictory, oversized, or unsupported item permanently taints synthetic success for that stream. A later real upstream terminal stays From 816b53c1a10c287087d2515da14fe106db8e0101 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:29:01 +0800 Subject: [PATCH 03/23] docs: plan DeepSeek Responses terminal repair --- ...eek-responses-streaming-terminal-repair.md | 650 ++++++++++++++++++ 1 file changed, 650 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-deepseek-responses-streaming-terminal-repair.md diff --git a/docs/superpowers/plans/2026-08-06-deepseek-responses-streaming-terminal-repair.md b/docs/superpowers/plans/2026-08-06-deepseek-responses-streaming-terminal-repair.md new file mode 100644 index 000000000..de1983ef8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-deepseek-responses-streaming-terminal-repair.md @@ -0,0 +1,650 @@ +# DeepSeek Responses Streaming Terminal Repair Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore progressive `deepseek-v4-flash` Responses streaming and safely synthesize a single completed terminal only for structurally complete terminal-less output. + +**Architecture:** Replace the built-in DeepSeek forced bounded-JSON hint with a registry-only terminal-repair policy. A bounded SSE wrapper runs before the existing inspection/client split, relays healthy streams unchanged, and uses a five-second post-completion grace timer to synthesize one `response.completed` event when every output item is safely complete. + +**Tech Stack:** Bun-native TypeScript, Web `ReadableStream`, Responses SSE, `bun:test`, existing translator-budget and SSE framing helpers. + +## Global Constraints + +- Work only on `agent/fix-deepseek-responses-streaming`, based on `origin/dev`; do not modify PR #1047's branch. +- Keep the repair registry-only and limited to the official built-in `deepseek-v4-flash` Responses route. +- Do not change DeepSeek Chat Completions, Anthropic replay, global JSON timeouts, or other providers. +- A real upstream terminal is authoritative and must never be duplicated or replaced. +- Never synthesize success for partial, malformed, tainted, oversized, unknown-type, cancelled, or aborted output. +- Use TDD for every behavior change: observe the focused test fail for the expected reason before production edits. +- Preserve existing item-id repair, reasoning replay, continuation-state, WebSocket, cancellation, and failed-tail contracts. +- Do not log prompts, API keys, raw credentials, or private account identifiers. + +--- + +## File responsibility map + +- `src/providers/registry.ts` — declares and resolves the built-in per-model terminal-repair policy. +- `src/server/responses-terminal-repair.ts` — owns SSE lifecycle tracking, bounded retained state, grace scheduling, and synthetic terminal creation. +- `src/server/responses/core.ts` — activates the repair before existing transport-specific relay branches. +- `tests/responses-terminal-repair.test.ts` — unit state-machine and stream-race coverage. +- `tests/deepseek-inbound-wire.test.ts` — end-to-end official DeepSeek wire and HTTP activation. +- `tests/ws-endpoint.test.ts` — WebSocket event parity for real and repaired terminals. +- `structure/04_transports-and-sidecars.md` — architectural contract for the provider-scoped streaming repair. +- `docs/superpowers/specs/2026-08-06-deepseek-responses-streaming-terminal-repair-design.md` — approved design authority; implementation must remain consistent with it. + +--- + +### Task 1: Replace the DeepSeek bounded-JSON hint with a terminal-repair policy + +**Files:** +- Modify: `src/providers/registry.ts:150-170` +- Modify: `src/providers/registry.ts:1143-1162` +- Modify: `src/providers/registry.ts:1834-1842` +- Modify: `tests/deepseek-inbound-wire.test.ts:1-175` +- Modify: `tests/deepseek-responses-item-id-repair.test.ts` + +**Interfaces:** +- Produces: `ResponsesTerminalRepairPolicy` +- Produces: `providerModelResponsesTerminalRepair(id, provider, modelId): ResponsesTerminalRepairPolicy | undefined` +- Preserves: `providerModelResponsesUpstreamStreaming(...)` for providers that still need bounded JSON. + +- [ ] **Step 1: Write failing registry and outbound-wire tests** + +Import the new resolver and change the existing DeepSeek transport expectations: + +```ts +import { + getProviderRegistryEntry, + providerModelResponsesTerminalRepair, +} from "../src/providers/registry"; + +test("the official DeepSeek Responses route opts into terminal repair", () => { + const provider = deepseekProvider(); + expect(providerModelResponsesTerminalRepair("deepseek", provider, MODEL)).toEqual({ graceMs: 5_000 }); + expect(providerModelResponsesTerminalRepair("deepseek", provider, "deepseek-chat")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-deepseek", provider, MODEL)).toBeUndefined(); +}); + +test("Codex HTTP and WebSocket turns keep DeepSeek streaming upstream", async () => { + expect((await drive("responses")).body.stream).toBe(true); + expect((await drive("responses", "websocket")).body.stream).toBe(true); +}); +``` + +Delete or rewrite the tests whose asserted contract is specifically +`stream:false`/bounded JSON for built-in DeepSeek. Keep the bounded-JSON helper +coverage that is provider-neutral. In +`tests/deepseek-responses-item-id-repair.test.ts`, retain the pure +`repairResponsesJsonItemIds()` unit test but remove the built-in-DeepSeek HTTP +activation assertion; Task 4 replaces it with streaming repair composition. + +- [ ] **Step 2: Run the test and verify RED** + +Run: + +```bash +bun test tests/deepseek-inbound-wire.test.ts +``` + +Expected: compile/test failure because `providerModelResponsesTerminalRepair` +does not exist and the current outbound body still contains `stream:false`. + +- [ ] **Step 3: Add the registry policy and resolver** + +Add the exact registry-only type and field: + +```ts +export interface ResponsesTerminalRepairPolicy { + graceMs: number; +} +``` + +Add this exact field inside the existing `ProviderRegistryEntry` interface: + +```ts +modelResponsesTerminalRepair?: Record; +``` + +In the DeepSeek entry, remove the `false` streaming override and declare: + +```ts +modelResponsesTerminalRepair: { + "deepseek-v4-flash": { graceMs: 5_000 }, +}, +``` + +Add the resolver beside `providerModelResponsesUpstreamStreaming`: + +```ts +export function providerModelResponsesTerminalRepair( + id: string, + provider: Pick & Partial>, + modelId: string, +): ResponsesTerminalRepairPolicy | undefined { + const entry = getProviderRegistryEntry(id); + if (!entry?.modelResponsesTerminalRepair || !providerMatchesRegistryTransport(id, provider)) return undefined; + const policy = entry.modelResponsesTerminalRepair[modelId.trim().toLowerCase()]; + if (!policy || !Number.isFinite(policy.graceMs) || policy.graceMs <= 0) return undefined; + return { graceMs: Math.floor(policy.graceMs) }; +} +``` + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: + +```bash +bun test tests/deepseek-inbound-wire.test.ts tests/deepseek-responses-item-id-repair.test.ts tests/provider-registry-parity.test.ts +``` + +Expected: all selected tests pass; captured HTTP and WebSocket request bodies +carry `stream:true`. + +- [ ] **Step 5: Commit** + +```bash +git add src/providers/registry.ts tests/deepseek-inbound-wire.test.ts tests/deepseek-responses-item-id-repair.test.ts +git commit -m "fix(deepseek): restore Responses upstream streaming" +``` + +--- + +### Task 2: Build the healthy-stream and grace-completion core + +**Files:** +- Create: `src/server/responses-terminal-repair.ts` +- Create: `tests/responses-terminal-repair.test.ts` + +**Interfaces:** +- Consumes: `ResponsesTerminalRepairPolicy` +- Consumes: `TranslatorBudget` +- Produces: `ResponsesTerminalRepairScheduler` +- Produces: `relayResponsesSseWithTerminalRepair(body, upstream, policy, budget, scheduler?)` + +- [ ] **Step 1: Write the manual scheduler and two RED tests** + +The test scheduler must advance callbacks synchronously without wall-clock sleep: + +```ts +class ManualScheduler implements ResponsesTerminalRepairScheduler { + private current = 0; + private nextId = 1; + private readonly jobs = new Map void }>(); + + nowMs(): number { return this.current; } + schedule(callback: () => void, delayMs: number): unknown { + const id = this.nextId++; + this.jobs.set(id, { at: this.current + delayMs, callback }); + return id; + } + cancel(handle: unknown): void { this.jobs.delete(handle as number); } + advance(ms: number): void { + this.current += ms; + const due = [...this.jobs.entries()].filter(([, job]) => job.at <= this.current); + for (const [id, job] of due) { + this.jobs.delete(id); + job.callback(); + } + } +} +``` + +Add one test with the live-captured lifecycle shape and a real +`response.completed`; assert output is byte-identical and contains one terminal. +Add one terminal-less fixture containing `response.created`, reasoning added/done, +function-call added/arguments.done/output_item.done; advance 4,999 ms (no +terminal), then one more millisecond and assert exactly one synthetic completed +terminal followed by one `[DONE]`. + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +bun test tests/responses-terminal-repair.test.ts +``` + +Expected: module-not-found failure for +`src/server/responses-terminal-repair.ts`. + +- [ ] **Step 3: Implement the public API and minimal healthy/grace path** + +Create these exact public interfaces: + +```ts +export interface ResponsesTerminalRepairScheduler { + nowMs(): number; + schedule(callback: () => void, delayMs: number): unknown; + cancel(handle: unknown): void; +} + +export function relayResponsesSseWithTerminalRepair( + body: ReadableStream, + upstream: AbortController, + policy: ResponsesTerminalRepairPolicy, + budget: TranslatorBudget, + scheduler: ResponsesTerminalRepairScheduler = systemScheduler, +): ReadableStream; +``` + +The default scheduler wraps `Date.now()`, `setTimeout`, and `clearTimeout`. +The relay must: + +- frame blocks with `nextSseBlock()` and parse payloads with `sseDataPayload()`; +- relay normal blocks with their original delimiter; +- record a valid `response.created.response` snapshot; +- track added and completed items by integer `output_index`; +- arm the grace timer only after the candidate predicate succeeds; +- on timer expiry, enqueue + `event: response.completed\ndata: \n\n`, then close and cancel the + reader; +- rely on the downstream terminal boundary to append `[DONE]` in production; + the unit harness may compose `relaySseWithFailedTail` to assert the final sentinel; +- cancel timers and release all retained budget in one idempotent disposer. + +Charge serialized retained response metadata and completed items under +`{ kind: "retained_collectors" }`; release the previous charge before replacing +an item and release every remaining charge during disposal. + +- [ ] **Step 4: Run the two tests and verify GREEN** + +Run: + +```bash +bun test tests/responses-terminal-repair.test.ts +``` + +Expected: healthy pass-through and five-second grace completion both pass; +translator-budget current bytes return to zero after drain. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/responses-terminal-repair.ts tests/responses-terminal-repair.test.ts +git commit -m "feat(responses): repair complete terminal-less streams" +``` + +--- + +### Task 3: Harden the terminal-repair state machine + +**Files:** +- Modify: `src/server/responses-terminal-repair.ts` +- Modify: `tests/responses-terminal-repair.test.ts` + +**Interfaces:** +- Preserves Task 2 public signatures. +- Adds no user-facing configuration. + +- [ ] **Step 1: Add RED tests for every fail-closed boundary** + +Use the Task 2 `ManualScheduler`, controlled source, SSE block builder, stream +drain, and terminal-type helpers. Add one complete test per row: + +| Test | Exact fixture/action | Required assertions | +|---|---|---| +| New activity resets grace | Complete reasoning item; advance 4,999 ms; add and complete a message item; advance 4,999 then 1 ms | No early terminal; final output includes both ordered items; one completed terminal | +| Complete EOF | Created plus one completed message, then source close | Completed appears immediately before close; source has one terminal | +| Complete `[DONE]` | Created plus one completed message, then `data: [DONE]` | Completed precedes exactly one `[DONE]` | +| Open item EOF | Created plus `output_item.added`, then close | One incomplete terminal; no completed terminal | +| Invalid function arguments | Done function call whose `arguments` is `{broken`, then close | One incomplete terminal; no completed terminal | +| Unknown item | Done item with `type:"computer_call"`, then close | One incomplete terminal; no completed terminal | +| Contradictory index | Two different added items reuse index 0, followed by one done item | State stays tainted; incomplete on close | +| Real terminal precedence | Run completed, failed, and incomplete subcases before grace expiry | Upstream terminal byte-preserved; no synthetic terminal | +| Timer/terminal race | Queue the timer callback, deliver real completed, then execute queued callback | Exactly one real completed terminal | +| Fragmentation | Split a multibyte reasoning delta and `\r\n\r\n` delimiters across chunks | Same terminal sequence and completed output as one-chunk control | +| Cancel/abort | Cancel client before grace; separately abort upstream before grace | No synthetic terminal; timer queue empty; source reader cancelled | +| Budget overflow | Use `createTestTranslatorBudget({ maxTurnBytes: 128 })` and a done item larger than 128 bytes | `translation_buffer_limit`; no completed terminal; retained bytes return to zero | + +Every test must assert the complete terminal type sequence, expected source +cancellation, an empty scheduler queue, and +`budget.snapshot().currentBytes === 0` after teardown. + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +bun test tests/responses-terminal-repair.test.ts +``` + +Expected: the newly added boundary tests fail because Task 2 implements only +the healthy and basic grace paths. + +- [ ] **Step 3: Implement strict item validation and singular terminal commitment** + +Implement these private rules: + +```ts +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCompleteItem(item: Record): boolean { + if (item.status !== "completed") return false; + if (item.type === "reasoning") { + return typeof item.id === "string" && item.id.length > 0 + && Array.isArray(item.content) + && item.content.every(part => isPlainRecord(part) + && part.type === "reasoning_text" && typeof part.text === "string"); + } + if (item.type === "message") { + return typeof item.id === "string" && item.id.length > 0 + && item.role === "assistant" && Array.isArray(item.content) + && item.content.every(part => isPlainRecord(part) + && part.type === "output_text" && typeof part.text === "string"); + } + if (item.type === "function_call") { + if (typeof item.id !== "string" || item.id.length === 0) return false; + if (typeof item.call_id !== "string" || item.call_id.length === 0) return false; + if (typeof item.name !== "string" || item.name.length === 0) return false; + if (typeof item.arguments !== "string") return false; + try { + const parsed = JSON.parse(item.arguments) as unknown; + return isPlainRecord(parsed); + } catch { return false; } + } + return false; +} +``` + +Require a valid created snapshot, at least one done item, exact added/done index +parity, no taint, and all done items passing `isCompleteItem()`. + +Implement one `commitTerminal(kind)` gate. For `completed`, build the response +from created metadata with ordered output, `status:"completed"`, injected +`completed_at`, and next sequence number. For incomplete EOF/DONE, emit a +canonical `response.incomplete` with `incomplete_details.reason` set to +`"missing_terminal_event"`. + +Every new non-terminal event increments a generation counter and cancels the +old timer. Timer callbacks capture the generation and re-check terminal, +abort/cancel, taint, and candidate completeness immediately before enqueueing. + +- [ ] **Step 4: Run the hardening tests and verify GREEN** + +Run: + +```bash +bun test tests/responses-terminal-repair.test.ts tests/sse-failed-tail.test.ts tests/relay-eager.test.ts +``` + +Expected: all tests pass with no duplicate terminal, timer leak, or retained +budget after teardown. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/responses-terminal-repair.ts tests/responses-terminal-repair.test.ts +git commit -m "fix(responses): fail closed on unsafe terminal repair" +``` + +--- + +### Task 4: Integrate repair before HTTP/WebSocket transport branching + +**Files:** +- Modify: `src/server/responses/core.ts:95-115` +- Modify: `src/server/responses/core.ts:2030-2230` +- Modify: `tests/deepseek-inbound-wire.test.ts:115-330` +- Modify: `tests/deepseek-responses-item-id-repair.test.ts` +- Modify: `tests/ws-endpoint.test.ts` + +**Interfaces:** +- Consumes: `providerModelResponsesTerminalRepair(...)` +- Consumes: `relayResponsesSseWithTerminalRepair(...)` +- Adds: `HandleResponsesOptions.responsesTerminalRepairScheduler?` as a narrow + clock/timer dependency injection seam used by deterministic integration tests. +- Preserves: existing payload/block rewrite composition and terminal inspection. + +- [ ] **Step 1: Write failing HTTP progressive-delivery and repair-composition tests** + +Replace the old bounded-JSON DeepSeek fixture with an SSE source that exposes +manual `push()` and `close()` controls. Assert: + +```ts +expect(capturedRequest.body.stream).toBe(true); + +source.push(reasoningDeltaBlock); +const firstRead = await reader.read(); +expect(new TextDecoder().decode(firstRead.value)).toContain("response.reasoning_text.delta"); + +source.push(functionCallDoneBlock); +scheduler.advance(5_000); +const remainder = await drainReader(reader); +expect(remainder).toContain("response.completed"); +expect(remainder).toContain("data: [DONE]"); +``` + +The fixture must use UUID reasoning/message ids and assert that existing item-id +repair rewrites added/delta/done/synthetic-terminal payloads consistently while +leaving `function_call.id` and `call_id` unchanged. + +- [ ] **Step 2: Add a failing WebSocket parity test** + +Drive the same terminal-less complete function call through `/v1/responses` +WebSocket handling. Assert the client receives progressive delta frames, one +`response.output_item.done`, and one `response.completed`, and that a second +`response.create` carrying `function_call_output` remains accepted. + +- [ ] **Step 3: Run tests and verify RED** + +Run: + +```bash +bun test tests/deepseek-inbound-wire.test.ts tests/ws-endpoint.test.ts +``` + +Expected: requests now carry `stream:true` from Task 1 but no provider-scoped +repair is activated, so the terminal-less fixture does not close at the injected +grace boundary. + +- [ ] **Step 4: Wrap the upstream SSE body before existing branches** + +Import both new resolvers and build one body before the eager/tee split: + +```ts +const terminalRepairPolicy = providerModelResponsesTerminalRepair( + route.providerName, + route.provider, + route.modelId, +); +const passthroughSseBody = terminalRepairPolicy + ? relayResponsesSseWithTerminalRepair( + upstreamResponse.body, + upstream, + terminalRepairPolicy, + translatorBudget, + options.responsesTerminalRepairScheduler, + ) + : upstreamResponse.body; +``` + +Add the optional scheduler to `HandleResponsesOptions`: + +```ts +/** Internal deterministic clock/timer seam for provider terminal repair. */ +responsesTerminalRepairScheduler?: ResponsesTerminalRepairScheduler; +``` + +Use `passthroughSseBody` in both: + +- the eager single-reader call to `relaySseEagerBounded()`; +- the default `passthroughSseBody.tee()` path. + +Do not place the wrapper only on the client branch: background inspection and +continuation persistence must see the synthetic terminal too. Leave the +existing JSON branch and bounded-JSON synthesis intact for providers whose +streaming resolver still returns `false`. + +- [ ] **Step 5: Run HTTP/WebSocket and continuation tests and verify GREEN** + +Run: + +```bash +bun test tests/deepseek-inbound-wire.test.ts tests/ws-endpoint.test.ts tests/responses-state.test.ts tests/deepseek-responses-item-id-repair.test.ts tests/deepseek-reasoning-replay.test.ts +``` + +Expected: progressive output precedes terminal, both transports close once, +item ids are stable, and continuation state retains reasoning/function output. + +- [ ] **Step 6: Commit** + +```bash +git add src/server/responses/core.ts tests/deepseek-inbound-wire.test.ts tests/deepseek-responses-item-id-repair.test.ts tests/ws-endpoint.test.ts +git commit -m "fix(deepseek): repair terminal-less Responses streams" +``` + +--- + +### Task 5: Synchronize architecture documentation and remove stale assertions + +**Files:** +- Modify: `structure/04_transports-and-sidecars.md:205-225` +- Modify: `src/providers/registry.ts:1150-1165` + +**Interfaces:** +- Documents the registry-only `modelResponsesTerminalRepair` policy and rollback path. + +- [ ] **Step 1: Find stale policy wording** + +Run: + +```bash +rg -n "DeepSeek.*bounded|bounded.*DeepSeek|modelResponsesUpstreamStreaming|stream:false" structure src tests docs-site +``` + +Expected: identify every statement that specifically claims the built-in +DeepSeek route is forced to bounded JSON. + +- [ ] **Step 2: Update documentation and comments** + +Document that official DeepSeek uses native Responses streaming and a +provider/model-scoped five-second repair only after complete output items. +State that the bounded-JSON mechanism remains available for other providers and +as a rollback capability. + +Do not describe synthetic completion as a generic Responses behavior. + +- [ ] **Step 3: Run repository hygiene checks** + +Run: + +```bash +git diff --check +bun run privacy:scan +``` + +Expected: both exit 0; no local path, credential, raw probe id, or private +prompt appears in tracked files. + +- [ ] **Step 4: Commit** + +```bash +git add structure/04_transports-and-sidecars.md src/providers/registry.ts +git commit -m "docs(deepseek): describe streaming terminal repair" +``` + +--- + +### Task 6: Complete verification and live smoke test + +**Files:** +- No planned source changes; fix only defects exposed by verification, each with a RED test first. + +**Interfaces:** +- Verifies every acceptance criterion in the approved design. + +- [ ] **Step 1: Run the focused regression matrix** + +Run: + +```bash +bun test tests/responses-terminal-repair.test.ts tests/deepseek-inbound-wire.test.ts tests/ws-endpoint.test.ts tests/sse-failed-tail.test.ts tests/relay-eager.test.ts tests/responses-item-id-repair.test.ts tests/deepseek-responses-item-id-repair.test.ts tests/deepseek-reasoning-replay.test.ts tests/responses-state.test.ts +``` + +Expected: 0 failures. + +- [ ] **Step 2: Run typecheck and full repository gates** + +Run: + +```bash +bun run typecheck +bun run test +bun run privacy:scan +bun run prepush +``` + +Expected: every command exits 0. Record exact pass/skip/fail counts from the +fresh `prepush` output. + +- [ ] **Step 3: Run one minimal official-DeepSeek smoke through the new code** + +Start an isolated one-off opencodex server from this worktree on an unused +loopback port, using the existing local config without printing its API key. +Send a prompt containing no private data and a no-op function tool. Verify: + +- the upstream request remains `stream:true`; +- at least one reasoning/function delta reaches the client before terminal; +- exactly one real `response.completed` and one `[DONE]` arrive; +- the request log status is 200 and `firstOutputMs` is populated; +- no `upstream JSON response stalled before completing` error occurs. + +Stop only the one-off process; do not restart or replace the installed service +until the user separately authorizes deployment. + +- [ ] **Step 4: Review final diff and branch state** + +Run: + +```bash +git status -sb +git diff origin/dev...HEAD --check +git diff origin/dev...HEAD --stat +git log --oneline origin/dev..HEAD +``` + +Expected: only the design, plan, targeted source, tests, and architecture doc +are changed; working tree is clean. + +--- + +### Task 7: Publish the independent pull request + +**Files:** +- No local code changes expected. + +**Interfaces:** +- Produces an independent PR targeting `lidge-jun/opencodex:dev`. + +- [ ] **Step 1: Rebase or merge the latest `origin/dev` only if required** + +Fetch current `origin/dev`, check ancestry, and update the branch without +touching PR #1047. If baseline movement creates conflicts, resolve only within +this branch and rerun Task 6 gates. + +- [ ] **Step 2: Push the branch to the user's fork** + +```bash +git push -u fork agent/fix-deepseek-responses-streaming +``` + +- [ ] **Step 3: Open a draft PR using the repository template** + +Target `dev`. The PR summary must state: + +- the observed 30-second bounded-JSON failure mode; +- the 2026-08-06 official-stream capture showing a valid terminal; +- the provider-scoped five-second completion repair; +- fail-closed conditions and transport parity; +- exact local verification counts. + +Do not include API keys, local paths, private prompts, account identifiers, or +the user's production conversation data. + +- [ ] **Step 4: Verify PR state** + +Confirm target branch, head SHA, template completeness, CI/check state, and +that the PR is independent of #1047. Leave it draft until the repository's +review-readiness checklist is satisfied against the exact final SHA. From f868862853c8b7a5d3a6864b977cef01c61a2021 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:35:48 +0800 Subject: [PATCH 04/23] fix(deepseek): restore Responses upstream streaming --- src/providers/registry.ts | 23 +++++++++++++++++++++++ tests/deepseek-inbound-wire.test.ts | 13 ++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1598997eb..da645dfdd 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -32,6 +32,11 @@ export type InboundWire = "responses" | "chat" | "anthropic"; */ export type ModelWireDefault = string | { wire: string; inbound: readonly InboundWire[] }; +export interface ResponsesTerminalRepairPolicy { + /** Quiet time after a structurally complete output graph before synthesizing completion. */ + graceMs: number; +} + export type ProviderModelDiscoveryScalar = string | number | boolean; export type ProviderModelDiscoveryPredicate = @@ -162,6 +167,8 @@ export interface ProviderRegistryEntry { * can omit or indefinitely delay the terminal event. */ modelResponsesUpstreamStreaming?: Record; + /** Registry-only repair for a model whose native Responses stream may omit its terminal. */ + modelResponsesTerminalRepair?: Record; /** * Registry-only client-facing item-id repair policy (#938), filled onto the * runtime provider only when the user has no explicit policy (derive.ts); @@ -1352,6 +1359,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // for providers that need it — re-adding one line here restores the old policy. // Evidence: https://api-docs.deepseek.com/guides/responses_api/ + // devlog/_plan/260807_deepseek_responses_streaming/000_plan.md. + // Current official streams normally carry a real terminal; retain a narrow grace + // repair for the historical shape that closes after a complete graph without one. + modelResponsesTerminalRepair: { "deepseek-v4-flash": { graceMs: 5_000 } }, // DeepSeek's Responses route emits bare UUID item ids, which leave Codex // clients stuck on an uncommitted turn (#938). Client-facing only — raw // continuation snapshots keep the upstream ids. @@ -2380,6 +2390,19 @@ export function providerModelResponsesUpstreamStreaming( return entry.modelResponsesUpstreamStreaming[modelId.trim().toLowerCase()]; } +/** Resolve a registry-only terminal-repair policy for native Responses streams. */ +export function providerModelResponsesTerminalRepair( + id: string, + provider: Pick & Partial>, + modelId: string, +): ResponsesTerminalRepairPolicy | undefined { + const entry = getProviderRegistryEntry(id); + if (!entry?.modelResponsesTerminalRepair || !providerMatchesRegistryTransport(id, provider)) return undefined; + const policy = entry.modelResponsesTerminalRepair[modelId.trim().toLowerCase()]; + if (!policy || !Number.isFinite(policy.graceMs) || policy.graceMs <= 0) return undefined; + return { graceMs: Math.floor(policy.graceMs) }; +} + /** * Effective Codex account mode for a provider. For canonical `openai`, a valid persisted * `codexAccountMode` on the provider config wins and a missing/invalid value defaults to diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index cf9c4df11..7c114b948 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -13,7 +13,11 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; -import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../src/providers/registry"; +import { + getProviderRegistryEntry, + providerModelResponsesTerminalRepair, + PROVIDER_REGISTRY, +} from "../src/providers/registry"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; import { handleResponses } from "../src/server/responses/core"; @@ -68,6 +72,13 @@ describe("DeepSeek wire selection is scoped to the inbound protocol", () => { .toBe("openai-chat"); } }); + + test("the official DeepSeek Responses route opts into terminal repair", () => { + const provider = deepseekProvider(); + expect(providerModelResponsesTerminalRepair("deepseek", provider, MODEL)).toEqual({ graceMs: 5_000 }); + expect(providerModelResponsesTerminalRepair("deepseek", provider, "deepseek-chat")).toBeUndefined(); + expect(providerModelResponsesTerminalRepair("custom-deepseek", provider, MODEL)).toBeUndefined(); + }); }); describe("the inbound scope survives the handleResponses replay", () => { From af1d302d5d2600286ef8f4349910f80b4b5bcafd Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:38:41 +0800 Subject: [PATCH 05/23] feat(responses): repair complete terminal-less streams --- src/server/responses-terminal-repair.ts | 270 ++++++++++++++++++++++++ tests/responses-terminal-repair.test.ts | 208 ++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src/server/responses-terminal-repair.ts create mode 100644 tests/responses-terminal-repair.test.ts diff --git a/src/server/responses-terminal-repair.ts b/src/server/responses-terminal-repair.ts new file mode 100644 index 000000000..9bc7f428a --- /dev/null +++ b/src/server/responses-terminal-repair.ts @@ -0,0 +1,270 @@ +import type { TranslatorBudget } from "../lib/translator-budget"; +import type { ResponsesTerminalRepairPolicy } from "../providers/registry"; +import { nextSseBlock, sseDataPayload } from "./sse-payload-rewrite"; + +export interface ResponsesTerminalRepairScheduler { + nowMs(): number; + schedule(callback: () => void, delayMs: number): unknown; + cancel(handle: unknown): void; +} + +const systemScheduler: ResponsesTerminalRepairScheduler = { + nowMs: () => Date.now(), + schedule(callback, delayMs) { + const handle = setTimeout(callback, delayMs); + (handle as { unref?: () => void }).unref?.(); + return handle; + }, + cancel(handle) { clearTimeout(handle as ReturnType); }, +}; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function outputIndex(value: unknown): number | null { + return Number.isInteger(value) && (value as number) >= 0 ? value as number : null; +} + +function isCompleteItem(item: Record): boolean { + if (item.status !== "completed") return false; + if (item.type === "reasoning") { + return typeof item.id === "string" && item.id.length > 0 + && Array.isArray(item.content) + && item.content.every(part => isPlainRecord(part) + && part.type === "reasoning_text" && typeof part.text === "string"); + } + if (item.type === "message") { + return typeof item.id === "string" && item.id.length > 0 + && item.role === "assistant" && Array.isArray(item.content) + && item.content.every(part => isPlainRecord(part) + && part.type === "output_text" && typeof part.text === "string"); + } + if (item.type === "function_call") { + if (typeof item.id !== "string" || item.id.length === 0) return false; + if (typeof item.call_id !== "string" || item.call_id.length === 0) return false; + if (typeof item.name !== "string" || item.name.length === 0) return false; + if (typeof item.arguments !== "string") return false; + try { + return isPlainRecord(JSON.parse(item.arguments)); + } catch { + return false; + } + } + return false; +} + +/** + * Relay a native Responses SSE body while repairing the narrow DeepSeek shape where every + * output item is complete but the protocol terminal is missing or indefinitely delayed. + */ +export function relayResponsesSseWithTerminalRepair( + body: ReadableStream, + upstream: AbortController, + policy: ResponsesTerminalRepairPolicy, + budget: TranslatorBudget, + scheduler: ResponsesTerminalRepairScheduler = systemScheduler, +): ReadableStream { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const added = new Set(); + const completed = new Map; bytes: number }>(); + let created: Record | null = null; + let createdBytes = 0; + let maxSequence = -1; + let buffer = ""; + let bufferBytes = 0; + let timer: unknown; + let timerGeneration = 0; + let realTerminalSeen = false; + let disposed = false; + let controllerRef: ReadableStreamDefaultController | null = null; + + const releaseRetainedState = (): void => { + if (createdBytes > 0) budget.releaseRetained(createdBytes, { kind: "retained_collectors" }); + createdBytes = 0; + for (const retained of completed.values()) { + budget.releaseRetained(retained.bytes, { kind: "retained_collectors" }); + } + completed.clear(); + created = null; + }; + + const releaseBuffer = (): void => { + if (bufferBytes > 0) budget.releaseRetained(bufferBytes, { kind: "live_transient" }); + buffer = ""; + bufferBytes = 0; + }; + + const cancelTimer = (): void => { + timerGeneration += 1; + if (timer !== undefined) scheduler.cancel(timer); + timer = undefined; + }; + + const dispose = (): void => { + if (disposed) return; + disposed = true; + cancelTimer(); + releaseRetainedState(); + releaseBuffer(); + }; + + const replaceBuffer = (next: string): void => { + const nextBytes = encoder.encode(next).byteLength; + const reservation = budget.reserveTransient(nextBytes, { kind: "live_transient" }); + reservation.commitRetained(); + if (bufferBytes > 0) budget.releaseRetained(bufferBytes, { kind: "live_transient" }); + buffer = next; + bufferBytes = nextBytes; + }; + + const appendBuffer = (fragment: string): void => { + if (!fragment) return; + replaceBuffer(buffer + fragment); + }; + + const retainCreated = (response: Record): void => { + const bytes = encoder.encode(JSON.stringify(response)).byteLength; + budget.chargeRetained(bytes, { kind: "retained_collectors" }); + if (createdBytes > 0) budget.releaseRetained(createdBytes, { kind: "retained_collectors" }); + created = response; + createdBytes = bytes; + }; + + const retainCompleted = (index: number, item: Record): void => { + const bytes = encoder.encode(JSON.stringify(item)).byteLength; + budget.chargeRetained(bytes, { kind: "retained_collectors" }); + const previous = completed.get(index); + if (previous) budget.releaseRetained(previous.bytes, { kind: "retained_collectors" }); + completed.set(index, { item, bytes }); + }; + + const completeCandidate = (): boolean => { + if (realTerminalSeen || !created || completed.size === 0 || added.size !== completed.size) return false; + for (const index of added) { + const retained = completed.get(index); + if (!retained || !isCompleteItem(retained.item)) return false; + } + for (const index of completed.keys()) if (!added.has(index)) return false; + return true; + }; + + const syntheticTerminal = (): Uint8Array => { + const output = [...completed.entries()] + .sort(([left], [right]) => left - right) + .map(([, retained]) => retained.item); + const response = { + ...created!, + status: "completed", + completed_at: Math.floor(scheduler.nowMs() / 1_000), + output, + }; + return encoder.encode(`event: response.completed\ndata: ${JSON.stringify({ + type: "response.completed", + response, + sequence_number: maxSequence + 1, + })}\n\n`); + }; + + const commitSynthetic = (generation: number): void => { + if (disposed || realTerminalSeen || generation !== timerGeneration || !completeCandidate()) return; + timer = undefined; + realTerminalSeen = true; + try { + controllerRef?.enqueue(syntheticTerminal()); + controllerRef?.close(); + } catch { + /* downstream already closed */ + } + reader.cancel("Responses terminal repaired after complete output").catch(() => {}); + dispose(); + }; + + const maybeArmTimer = (): void => { + if (!completeCandidate()) return; + const generation = timerGeneration; + timer = scheduler.schedule(() => commitSynthetic(generation), policy.graceMs); + }; + + const inspectPayload = (payload: string | null): void => { + if (!payload || payload === "[DONE]" || realTerminalSeen) return; + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return; + } + if (!isPlainRecord(parsed)) return; + if (Number.isInteger(parsed.sequence_number)) { + maxSequence = Math.max(maxSequence, parsed.sequence_number as number); + } + const type = parsed.type; + if (type === "response.completed" || type === "response.failed" || type === "response.incomplete") { + realTerminalSeen = true; + cancelTimer(); + releaseRetainedState(); + return; + } + + cancelTimer(); + if (type === "response.created" && isPlainRecord(parsed.response)) { + retainCreated(parsed.response); + } else if (type === "response.output_item.added") { + const index = outputIndex(parsed.output_index); + if (index !== null) added.add(index); + } else if (type === "response.output_item.done") { + const index = outputIndex(parsed.output_index); + if (index !== null && isPlainRecord(parsed.item)) retainCompleted(index, parsed.item); + } + maybeArmTimer(); + }; + + const emitBlocks = (controller: ReadableStreamDefaultController): void => { + let next: ReturnType; + while ((next = nextSseBlock(buffer))) { + replaceBuffer(next.rest); + inspectPayload(sseDataPayload(next.block)); + controller.enqueue(encoder.encode(next.block + next.delimiter)); + } + }; + + const pump = async (controller: ReadableStreamDefaultController): Promise => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (disposed) return; + if (done) { + appendBuffer(decoder.decode()); + if (buffer.length > 0) { + inspectPayload(sseDataPayload(buffer)); + controller.enqueue(encoder.encode(buffer)); + } + releaseBuffer(); + dispose(); + controller.close(); + return; + } + appendBuffer(decoder.decode(value, { stream: true })); + emitBlocks(controller); + } + } catch (error) { + if (disposed) return; + dispose(); + controller.error(error); + } + }; + + return new ReadableStream({ + start(controller) { + controllerRef = controller; + void pump(controller); + }, + cancel(reason) { + dispose(); + upstream.abort(reason); + return reader.cancel(reason); + }, + }); +} diff --git a/tests/responses-terminal-repair.test.ts b/tests/responses-terminal-repair.test.ts new file mode 100644 index 000000000..db84c4730 --- /dev/null +++ b/tests/responses-terminal-repair.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; +import type { ResponsesTerminalRepairPolicy } from "../src/providers/registry"; +import { relaySseWithFailedTail } from "../src/server/relay"; +import { + relayResponsesSseWithTerminalRepair, + type ResponsesTerminalRepairScheduler, +} from "../src/server/responses-terminal-repair"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const POLICY: ResponsesTerminalRepairPolicy = { graceMs: 5_000 }; + +class ManualScheduler implements ResponsesTerminalRepairScheduler { + private current = 0; + private nextId = 1; + private readonly jobs = new Map void }>(); + + nowMs(): number { return this.current; } + + schedule(callback: () => void, delayMs: number): unknown { + const id = this.nextId++; + this.jobs.set(id, { at: this.current + delayMs, callback }); + return id; + } + + cancel(handle: unknown): void { + this.jobs.delete(handle as number); + } + + advance(ms: number): void { + this.current += ms; + for (;;) { + const due = [...this.jobs.entries()] + .filter(([, job]) => job.at <= this.current) + .sort((left, right) => left[1].at - right[1].at); + if (due.length === 0) return; + for (const [id, job] of due) { + if (!this.jobs.delete(id)) continue; + job.callback(); + } + } + } + + pending(): number { return this.jobs.size; } +} + +function sse(event: Record): string { + return `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`; +} + +function streamFromText(text: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +function controlledSource(): { + stream: ReadableStream; + push(text: string): void; + close(): void; + cancelled(): boolean; +} { + let controller: ReadableStreamDefaultController | null = null; + let wasCancelled = false; + return { + stream: new ReadableStream({ + start(next) { controller = next; }, + cancel() { wasCancelled = true; }, + }), + push(text) { controller?.enqueue(encoder.encode(text)); }, + close() { controller?.close(); }, + cancelled: () => wasCancelled, + }; +} + +async function readAll(stream: ReadableStream): Promise { + const reader = stream.getReader(); + let out = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) return out + decoder.decode(); + out += decoder.decode(value, { stream: true }); + } +} + +async function settle(): Promise { + await Promise.resolve(); + await Bun.sleep(0); +} + +function capturedToolCallLifecycle(): string { + return [ + sse({ + type: "response.created", + response: { id: "resp_probe", object: "response", status: "in_progress", output: [] }, + sequence_number: 0, + }), + sse({ + type: "response.output_item.added", + item: { type: "reasoning", id: "rs_probe", status: "in_progress", content: [], summary: [] }, + output_index: 0, + sequence_number: 1, + }), + sse({ + type: "response.output_item.done", + item: { + type: "reasoning", + id: "rs_probe", + status: "completed", + content: [{ type: "reasoning_text", text: "Call the probe tool." }], + summary: [], + }, + output_index: 0, + sequence_number: 2, + }), + sse({ + type: "response.output_item.added", + item: { + type: "function_call", + id: "fc_probe", + status: "in_progress", + arguments: "", + call_id: "call_probe", + name: "probe", + }, + output_index: 1, + sequence_number: 3, + }), + sse({ + type: "response.function_call_arguments.done", + arguments: "{\"text\":\"OK\"}", + item_id: "fc_probe", + output_index: 1, + sequence_number: 4, + }), + sse({ + type: "response.output_item.done", + item: { + type: "function_call", + id: "fc_probe", + status: "completed", + arguments: "{\"text\":\"OK\"}", + call_id: "call_probe", + name: "probe", + }, + output_index: 1, + sequence_number: 5, + }), + ].join(""); +} + +describe("DeepSeek Responses terminal repair", () => { + test("a healthy stream with a real terminal is relayed byte-identical", async () => { + const lifecycle = capturedToolCallLifecycle(); + const terminal = sse({ + type: "response.completed", + response: { id: "resp_probe", object: "response", status: "completed", output: [] }, + sequence_number: 6, + }); + const upstream = lifecycle + terminal + "data: [DONE]\n\n"; + const budget = createTestTranslatorBudget(); + + const output = await readAll(relayResponsesSseWithTerminalRepair( + streamFromText(upstream), + new AbortController(), + POLICY, + budget, + )); + + expect(output).toBe(upstream); + expect(output.match(/"type":"response\.completed"/g)?.length).toBe(1); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("a complete terminal-less tool call commits only after the grace period", async () => { + const source = controlledSource(); + const scheduler = new ManualScheduler(); + const budget = createTestTranslatorBudget(); + const upstream = new AbortController(); + const repaired = relayResponsesSseWithTerminalRepair(source.stream, upstream, POLICY, budget, scheduler); + let resolved = false; + const outputPromise = readAll(relaySseWithFailedTail(repaired, upstream)).then(output => { + resolved = true; + return output; + }); + + source.push(capturedToolCallLifecycle()); + await settle(); + expect(scheduler.pending()).toBe(1); + scheduler.advance(4_999); + await settle(); + expect(resolved).toBe(false); + + scheduler.advance(1); + const output = await outputPromise; + expect(resolved).toBe(true); + expect(output.match(/"type":"response\.completed"/g)?.length).toBe(1); + expect(output.endsWith("data: [DONE]\n\n")).toBe(true); + expect(output).toContain('"call_id":"call_probe"'); + expect(source.cancelled()).toBe(true); + expect(scheduler.pending()).toBe(0); + expect(budget.snapshot().currentBytes).toBe(0); + }); +}); From 896943bf9ea7964fc92c937c1a3364cc9330a3e9 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:42:26 +0800 Subject: [PATCH 06/23] fix(responses): fail closed on unsafe terminal repair --- src/server/responses-terminal-repair.ts | 104 +++++++-- tests/responses-terminal-repair.test.ts | 281 ++++++++++++++++++++++++ 2 files changed, 363 insertions(+), 22 deletions(-) diff --git a/src/server/responses-terminal-repair.ts b/src/server/responses-terminal-repair.ts index 9bc7f428a..886e5158a 100644 --- a/src/server/responses-terminal-repair.ts +++ b/src/server/responses-terminal-repair.ts @@ -68,7 +68,7 @@ export function relayResponsesSseWithTerminalRepair( const reader = body.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); - const added = new Set(); + const added = new Map(); const completed = new Map; bytes: number }>(); let created: Record | null = null; let createdBytes = 0; @@ -78,9 +78,17 @@ export function relayResponsesSseWithTerminalRepair( let timer: unknown; let timerGeneration = 0; let realTerminalSeen = false; + let tainted = false; let disposed = false; let controllerRef: ReadableStreamDefaultController | null = null; + const onUpstreamAbort = (): void => { + if (disposed) return; + dispose(); + reader.cancel(upstream.signal.reason).catch(() => {}); + try { controllerRef?.close(); } catch { /* already closed */ } + }; + const releaseRetainedState = (): void => { if (createdBytes > 0) budget.releaseRetained(createdBytes, { kind: "retained_collectors" }); createdBytes = 0; @@ -106,6 +114,7 @@ export function relayResponsesSseWithTerminalRepair( const dispose = (): void => { if (disposed) return; disposed = true; + upstream.signal.removeEventListener("abort", onUpstreamAbort); cancelTimer(); releaseRetainedState(); releaseBuffer(); @@ -142,8 +151,8 @@ export function relayResponsesSseWithTerminalRepair( }; const completeCandidate = (): boolean => { - if (realTerminalSeen || !created || completed.size === 0 || added.size !== completed.size) return false; - for (const index of added) { + if (realTerminalSeen || tainted || !created || completed.size === 0 || added.size !== completed.size) return false; + for (const index of added.keys()) { const retained = completed.get(index); if (!retained || !isCompleteItem(retained.item)) return false; } @@ -151,30 +160,44 @@ export function relayResponsesSseWithTerminalRepair( return true; }; - const syntheticTerminal = (): Uint8Array => { + const syntheticTerminal = (kind: "completed" | "incomplete"): Uint8Array => { const output = [...completed.entries()] .sort(([left], [right]) => left - right) .map(([, retained]) => retained.item); const response = { - ...created!, - status: "completed", + ...(created ?? {}), + status: kind, completed_at: Math.floor(scheduler.nowMs() / 1_000), output, + ...(kind === "incomplete" + ? { incomplete_details: { reason: "missing_terminal_event" } } + : {}), }; - return encoder.encode(`event: response.completed\ndata: ${JSON.stringify({ - type: "response.completed", + const type = `response.${kind}`; + return encoder.encode(`event: ${type}\ndata: ${JSON.stringify({ + type, response, sequence_number: maxSequence + 1, })}\n\n`); }; + const emitSynthetic = ( + kind: "completed" | "incomplete", + controller: ReadableStreamDefaultController, + ): boolean => { + if (disposed || realTerminalSeen) return false; + realTerminalSeen = true; + cancelTimer(); + controller.enqueue(syntheticTerminal(kind)); + releaseRetainedState(); + return true; + }; + const commitSynthetic = (generation: number): void => { if (disposed || realTerminalSeen || generation !== timerGeneration || !completeCandidate()) return; timer = undefined; - realTerminalSeen = true; try { - controllerRef?.enqueue(syntheticTerminal()); - controllerRef?.close(); + if (controllerRef && emitSynthetic("completed", controllerRef)) controllerRef.close(); } catch { /* downstream already closed */ } @@ -188,15 +211,20 @@ export function relayResponsesSseWithTerminalRepair( timer = scheduler.schedule(() => commitSynthetic(generation), policy.graceMs); }; - const inspectPayload = (payload: string | null): void => { - if (!payload || payload === "[DONE]" || realTerminalSeen) return; + const inspectPayload = (payload: string | null): "done" | "ordinary" => { + if (payload === "[DONE]") return "done"; + if (!payload || realTerminalSeen) return "ordinary"; let parsed: unknown; try { parsed = JSON.parse(payload); } catch { - return; + tainted = true; + return "ordinary"; + } + if (!isPlainRecord(parsed)) { + tainted = true; + return "ordinary"; } - if (!isPlainRecord(parsed)) return; if (Number.isInteger(parsed.sequence_number)) { maxSequence = Math.max(maxSequence, parsed.sequence_number as number); } @@ -205,7 +233,7 @@ export function relayResponsesSseWithTerminalRepair( realTerminalSeen = true; cancelTimer(); releaseRetainedState(); - return; + return "ordinary"; } cancelTimer(); @@ -213,21 +241,42 @@ export function relayResponsesSseWithTerminalRepair( retainCreated(parsed.response); } else if (type === "response.output_item.added") { const index = outputIndex(parsed.output_index); - if (index !== null) added.add(index); + if (index === null || !isPlainRecord(parsed.item) || added.has(index) || completed.has(index)) { + tainted = true; + } else { + added.set(index, { type: parsed.item.type, id: parsed.item.id }); + } } else if (type === "response.output_item.done") { const index = outputIndex(parsed.output_index); - if (index !== null && isPlainRecord(parsed.item)) retainCompleted(index, parsed.item); + if (index === null || !isPlainRecord(parsed.item) || !added.has(index) || completed.has(index)) { + tainted = true; + } else { + const opened = added.get(index)!; + if (opened.type !== parsed.item.type || opened.id !== parsed.item.id) tainted = true; + retainCompleted(index, parsed.item); + } } maybeArmTimer(); + return "ordinary"; }; - const emitBlocks = (controller: ReadableStreamDefaultController): void => { + const emitBlocks = (controller: ReadableStreamDefaultController): boolean => { let next: ReturnType; while ((next = nextSseBlock(buffer))) { replaceBuffer(next.rest); - inspectPayload(sseDataPayload(next.block)); + const kind = inspectPayload(sseDataPayload(next.block)); + if (kind === "done" && !realTerminalSeen) { + emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller); + } controller.enqueue(encoder.encode(next.block + next.delimiter)); + if (kind === "done") { + reader.cancel("Responses stream ended with DONE").catch(() => {}); + dispose(); + controller.close(); + return true; + } } + return false; }; const pump = async (controller: ReadableStreamDefaultController): Promise => { @@ -238,16 +287,22 @@ export function relayResponsesSseWithTerminalRepair( if (done) { appendBuffer(decoder.decode()); if (buffer.length > 0) { - inspectPayload(sseDataPayload(buffer)); + const kind = inspectPayload(sseDataPayload(buffer)); + if (kind === "done" && !realTerminalSeen) { + emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller); + } controller.enqueue(encoder.encode(buffer)); } + if (!realTerminalSeen) { + emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller); + } releaseBuffer(); dispose(); controller.close(); return; } appendBuffer(decoder.decode(value, { stream: true })); - emitBlocks(controller); + if (emitBlocks(controller)) return; } } catch (error) { if (disposed) return; @@ -259,6 +314,11 @@ export function relayResponsesSseWithTerminalRepair( return new ReadableStream({ start(controller) { controllerRef = controller; + if (upstream.signal.aborted) { + onUpstreamAbort(); + return; + } + upstream.signal.addEventListener("abort", onUpstreamAbort, { once: true }); void pump(controller); }, cancel(reason) { diff --git a/tests/responses-terminal-repair.test.ts b/tests/responses-terminal-repair.test.ts index db84c4730..d2b781f94 100644 --- a/tests/responses-terminal-repair.test.ts +++ b/tests/responses-terminal-repair.test.ts @@ -153,6 +153,59 @@ function capturedToolCallLifecycle(): string { ].join(""); } +function completedMessageLifecycle(text = "hello"): string { + return [ + sse({ + type: "response.created", + response: { id: "resp_message", object: "response", status: "in_progress", output: [] }, + sequence_number: 0, + }), + sse({ + type: "response.output_item.added", + item: { type: "message", id: "msg_probe", role: "assistant", status: "in_progress", content: [] }, + output_index: 0, + sequence_number: 1, + }), + sse({ + type: "response.output_item.done", + item: { + type: "message", + id: "msg_probe", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }, + output_index: 0, + sequence_number: 2, + }), + ].join(""); +} + +function terminalTypes(text: string): string[] { + return [...text.matchAll(/"type":"(response\.(?:completed|failed|incomplete))"/g)] + .map(match => match[1]!); +} + +async function repairClosedText(text: string): Promise<{ output: string; cancelled: boolean }> { + let cancelled = false; + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + cancel() { cancelled = true; }, + }); + const upstream = new AbortController(); + const repaired = relayResponsesSseWithTerminalRepair( + source, + upstream, + POLICY, + createTestTranslatorBudget(), + new ManualScheduler(), + ); + return { output: await readAll(relaySseWithFailedTail(repaired, upstream)), cancelled }; +} + describe("DeepSeek Responses terminal repair", () => { test("a healthy stream with a real terminal is relayed byte-identical", async () => { const lifecycle = capturedToolCallLifecycle(); @@ -205,4 +258,232 @@ describe("DeepSeek Responses terminal repair", () => { expect(scheduler.pending()).toBe(0); expect(budget.snapshot().currentBytes).toBe(0); }); + + test("new activity invalidates the old grace generation and waits after the new item", async () => { + const source = controlledSource(); + const scheduler = new ManualScheduler(); + const budget = createTestTranslatorBudget(); + const upstream = new AbortController(); + const repaired = relayResponsesSseWithTerminalRepair(source.stream, upstream, POLICY, budget, scheduler); + let resolved = false; + const outputPromise = readAll(relaySseWithFailedTail(repaired, upstream)).then(output => { + resolved = true; + return output; + }); + + source.push(completedMessageLifecycle("first")); + await settle(); + scheduler.advance(4_999); + source.push([ + sse({ + type: "response.output_item.added", + item: { type: "message", id: "msg_second", role: "assistant", status: "in_progress", content: [] }, + output_index: 1, + sequence_number: 3, + }), + sse({ + type: "response.output_item.done", + item: { + type: "message", + id: "msg_second", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "second", annotations: [] }], + }, + output_index: 1, + sequence_number: 4, + }), + ].join("")); + await settle(); + scheduler.advance(1); + await settle(); + expect(resolved).toBe(false); + + scheduler.advance(4_999); + const output = await outputPromise; + expect(terminalTypes(output)).toEqual(["response.completed"]); + expect(output).toContain('"text":"first"'); + expect(output).toContain('"text":"second"'); + expect(scheduler.pending()).toBe(0); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("EOF synthesizes completed only for a complete candidate", async () => { + const { output } = await repairClosedText(completedMessageLifecycle()); + expect(terminalTypes(output)).toEqual(["response.completed"]); + expect(output.endsWith("data: [DONE]\n\n")).toBe(true); + }); + + test("DONE is replaced by completed then one DONE only for a complete candidate", async () => { + const { output } = await repairClosedText(completedMessageLifecycle() + "data: [DONE]\n\n"); + expect(terminalTypes(output)).toEqual(["response.completed"]); + expect(output.match(/data: \[DONE\]/g)?.length).toBe(1); + expect(output.indexOf("response.completed")).toBeLessThan(output.indexOf("data: [DONE]")); + }); + + test("open output items end as incomplete, never completed", async () => { + const input = [ + sse({ type: "response.created", response: { id: "resp_open", status: "in_progress", output: [] }, sequence_number: 0 }), + sse({ + type: "response.output_item.added", + item: { type: "message", id: "msg_open", role: "assistant", status: "in_progress", content: [] }, + output_index: 0, + sequence_number: 1, + }), + ].join(""); + const { output } = await repairClosedText(input); + expect(terminalTypes(output)).toEqual(["response.incomplete"]); + expect(output).not.toContain('"type":"response.completed"'); + }); + + test("invalid function arguments end as incomplete, never completed", async () => { + const input = capturedToolCallLifecycle().replaceAll('{\\"text\\":\\"OK\\"}', "{broken"); + const { output } = await repairClosedText(input); + expect(terminalTypes(output)).toEqual(["response.incomplete"]); + }); + + test("unknown output item types taint synthetic success", async () => { + const input = [ + sse({ type: "response.created", response: { id: "resp_unknown", status: "in_progress", output: [] }, sequence_number: 0 }), + sse({ type: "response.output_item.added", item: { type: "computer_call", id: "cmp_1", status: "in_progress" }, output_index: 0, sequence_number: 1 }), + sse({ type: "response.output_item.done", item: { type: "computer_call", id: "cmp_1", status: "completed" }, output_index: 0, sequence_number: 2 }), + ].join(""); + const { output } = await repairClosedText(input); + expect(terminalTypes(output)).toEqual(["response.incomplete"]); + }); + + test("duplicate or contradictory output indices taint synthetic success", async () => { + const input = [ + sse({ type: "response.created", response: { id: "resp_duplicate", status: "in_progress", output: [] }, sequence_number: 0 }), + sse({ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", status: "in_progress", content: [] }, output_index: 0, sequence_number: 1 }), + sse({ type: "response.output_item.added", item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] }, output_index: 0, sequence_number: 2 }), + sse({ + type: "response.output_item.done", + item: { type: "message", id: "msg_1", role: "assistant", status: "completed", content: [{ type: "output_text", text: "x" }] }, + output_index: 0, + sequence_number: 3, + }), + ].join(""); + const { output } = await repairClosedText(input); + expect(terminalTypes(output)).toEqual(["response.incomplete"]); + }); + + test("real completed failed and incomplete terminals beat the timer", async () => { + for (const terminal of ["completed", "failed", "incomplete"] as const) { + const scheduler = new ManualScheduler(); + const suffix = sse({ + type: `response.${terminal}`, + response: { id: `resp_${terminal}`, status: terminal }, + sequence_number: 3, + }); + const input = completedMessageLifecycle() + suffix + "data: [DONE]\n\n"; + const budget = createTestTranslatorBudget(); + const output = await readAll(relayResponsesSseWithTerminalRepair( + streamFromText(input), + new AbortController(), + POLICY, + budget, + scheduler, + )); + scheduler.advance(5_000); + expect(terminalTypes(output)).toEqual([`response.${terminal}`]); + expect(scheduler.pending()).toBe(0); + expect(budget.snapshot().currentBytes).toBe(0); + } + }); + + test("a timer racing a real terminal commits exactly one terminal", async () => { + const source = controlledSource(); + const scheduler = new ManualScheduler(); + const budget = createTestTranslatorBudget(); + const outputPromise = readAll(relayResponsesSseWithTerminalRepair( + source.stream, + new AbortController(), + POLICY, + budget, + scheduler, + )); + source.push(completedMessageLifecycle()); + await settle(); + source.push(sse({ type: "response.completed", response: { id: "resp_message", status: "completed" }, sequence_number: 3 })); + source.close(); + scheduler.advance(5_000); + const output = await outputPromise; + expect(terminalTypes(output)).toEqual(["response.completed"]); + expect(scheduler.pending()).toBe(0); + }); + + test("fragmented UTF-8 and SSE delimiters preserve lifecycle state", async () => { + const input = completedMessageLifecycle("你好") + + sse({ type: "response.completed", response: { id: "resp_message", status: "completed" }, sequence_number: 3 }) + + "data: [DONE]\n\n"; + const bytes = encoder.encode(input); + const chunks = [bytes.subarray(0, 37), bytes.subarray(37, 91), bytes.subarray(91, 173), bytes.subarray(173)]; + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }); + const output = await readAll(relayResponsesSseWithTerminalRepair( + source, + new AbortController(), + POLICY, + createTestTranslatorBudget(), + new ManualScheduler(), + )); + expect(output).toBe(input); + expect(terminalTypes(output)).toEqual(["response.completed"]); + }); + + test("client cancel and upstream abort suppress synthetic completion", async () => { + const cancelSource = controlledSource(); + const cancelScheduler = new ManualScheduler(); + const cancelUpstream = new AbortController(); + const cancelled = relayResponsesSseWithTerminalRepair( + cancelSource.stream, + cancelUpstream, + POLICY, + createTestTranslatorBudget(), + cancelScheduler, + ); + cancelSource.push(completedMessageLifecycle()); + await settle(); + await cancelled.cancel("client gone"); + cancelScheduler.advance(5_000); + expect(cancelSource.cancelled()).toBe(true); + expect(cancelScheduler.pending()).toBe(0); + + const abortSource = controlledSource(); + const abortScheduler = new ManualScheduler(); + const abortUpstream = new AbortController(); + const aborted = relayResponsesSseWithTerminalRepair( + abortSource.stream, + abortUpstream, + POLICY, + createTestTranslatorBudget(), + abortScheduler, + ); + abortSource.push(completedMessageLifecycle()); + await settle(); + abortUpstream.abort("shutdown"); + await settle(); + expect(abortSource.cancelled()).toBe(true); + expect(abortScheduler.pending()).toBe(0); + await aborted.cancel("test cleanup"); + }); + + test("retained-state overflow throws translation_buffer_limit and releases budget", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 128 }); + const source = streamFromText(completedMessageLifecycle("x".repeat(512))); + const repaired = relayResponsesSseWithTerminalRepair( + source, + new AbortController(), + POLICY, + budget, + new ManualScheduler(), + ); + await expect(readAll(repaired)).rejects.toMatchObject({ code: "translation_buffer_limit" }); + expect(budget.snapshot().currentBytes).toBe(0); + }); }); From 3813c99f72276c17ebefb995fe3528186a1882b9 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:48:56 +0800 Subject: [PATCH 07/23] fix(responses): preserve terminal repair backpressure --- src/server/responses-terminal-repair.ts | 24 ++++++++++++++++++------ tests/responses-terminal-repair.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/server/responses-terminal-repair.ts b/src/server/responses-terminal-repair.ts index 886e5158a..4d6749a91 100644 --- a/src/server/responses-terminal-repair.ts +++ b/src/server/responses-terminal-repair.ts @@ -81,6 +81,7 @@ export function relayResponsesSseWithTerminalRepair( let tainted = false; let disposed = false; let controllerRef: ReadableStreamDefaultController | null = null; + let activeRead: Promise | null = null; const onUpstreamAbort = (): void => { if (disposed) return; @@ -260,8 +261,11 @@ export function relayResponsesSseWithTerminalRepair( return "ordinary"; }; - const emitBlocks = (controller: ReadableStreamDefaultController): boolean => { + const emitBlocks = ( + controller: ReadableStreamDefaultController, + ): { closed: boolean; emitted: boolean } => { let next: ReturnType; + let emitted = false; while ((next = nextSseBlock(buffer))) { replaceBuffer(next.rest); const kind = inspectPayload(sseDataPayload(next.block)); @@ -269,17 +273,18 @@ export function relayResponsesSseWithTerminalRepair( emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller); } controller.enqueue(encoder.encode(next.block + next.delimiter)); + emitted = true; if (kind === "done") { reader.cancel("Responses stream ended with DONE").catch(() => {}); dispose(); controller.close(); - return true; + return { closed: true, emitted }; } } - return false; + return { closed: false, emitted }; }; - const pump = async (controller: ReadableStreamDefaultController): Promise => { + const readOnce = async (controller: ReadableStreamDefaultController): Promise => { try { for (;;) { const { done, value } = await reader.read(); @@ -302,7 +307,8 @@ export function relayResponsesSseWithTerminalRepair( return; } appendBuffer(decoder.decode(value, { stream: true })); - if (emitBlocks(controller)) return; + const result = emitBlocks(controller); + if (result.closed || result.emitted) return; } } catch (error) { if (disposed) return; @@ -319,7 +325,13 @@ export function relayResponsesSseWithTerminalRepair( return; } upstream.signal.addEventListener("abort", onUpstreamAbort, { once: true }); - void pump(controller); + }, + pull(controller) { + if (disposed) return; + if (!activeRead) { + activeRead = readOnce(controller).finally(() => { activeRead = null; }); + } + return activeRead; }, cancel(reason) { dispose(); diff --git a/tests/responses-terminal-repair.test.ts b/tests/responses-terminal-repair.test.ts index d2b781f94..22feb27ec 100644 --- a/tests/responses-terminal-repair.test.ts +++ b/tests/responses-terminal-repair.test.ts @@ -486,4 +486,26 @@ describe("DeepSeek Responses terminal repair", () => { await expect(readAll(repaired)).rejects.toMatchObject({ code: "translation_buffer_limit" }); expect(budget.snapshot().currentBytes).toBe(0); }); + + test("the wrapper does not continuously read ahead without downstream pulls", async () => { + let pulls = 0; + const source = new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls <= 5) controller.enqueue(encoder.encode(`: heartbeat ${pulls}\n\n`)); + }, + }); + const repaired = relayResponsesSseWithTerminalRepair( + source, + new AbortController(), + POLICY, + createTestTranslatorBudget(), + new ManualScheduler(), + ); + + await settle(); + // One chunk may be prefetched by each Web Stream layer; continuous read-ahead must stop there. + expect(pulls).toBeLessThanOrEqual(2); + await repaired.cancel("test cleanup"); + }); }); From bf678b1b9b71301cd95bf9fc9ac2bce56bd7b1ba Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:54:35 +0800 Subject: [PATCH 08/23] fix(deepseek): repair terminal-less Responses streams --- src/server/responses/core.ts | 30 ++- tests/deepseek-inbound-wire.test.ts | 310 ++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+), 3 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6b5431022..c6e55ebe7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -109,7 +109,11 @@ import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../provid import { isUsageDebugEnabled } from "../../usage/debug"; import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress"; import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; -import { providerModelResponsesUpstreamStreaming, type InboundWire } from "../../providers/registry"; +import { + providerModelResponsesTerminalRepair, + providerModelResponsesUpstreamStreaming, + type InboundWire, +} from "../../providers/registry"; import type { AdapterRequest } from "../../adapters/base"; import { hasKeyPoolFailover, @@ -165,6 +169,10 @@ import { sanitizePassthroughHeaders, } from "../relay"; import { relaySseEagerBounded } from "../relay-eager"; +import { + relayResponsesSseWithTerminalRepair, + type ResponsesTerminalRepairScheduler, +} from "../responses-terminal-repair"; import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps"; import { cancelBodyOnAbort } from "../../lib/abort"; import { @@ -636,6 +644,8 @@ export interface HandleResponsesOptions { setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void; onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void; onNativePassthroughCancel?: () => void; + /** Internal deterministic clock/timer seam for provider terminal repair. */ + responsesTerminalRepairScheduler?: ResponsesTerminalRepairScheduler; /** * When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort * (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity. @@ -2249,6 +2259,20 @@ async function handleResponsesInner( // devlog/_fin/260731_macos_rss_retention/100_darwin_eager_optin.md). // The bundled known-bad runtime remains on tee by default on both platforms. if (isEventStream && upstreamResponse.body) { + const terminalRepairPolicy = providerModelResponsesTerminalRepair( + route.providerName, + route.provider, + route.modelId, + ); + const passthroughSseBody = terminalRepairPolicy + ? relayResponsesSseWithTerminalRepair( + upstreamResponse.body, + upstream, + terminalRepairPolicy, + translatorBudget, + options.responsesTerminalRepairScheduler, + ) + : upstreamResponse.body; const repairConfig = route.provider.responsesItemIdRepair; const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair); const githubCopilotRepairEnabled = route.providerName === "github-copilot"; @@ -2334,7 +2358,7 @@ async function handleResponsesInner( onFirstOutput: options.onFirstOutput, pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled, }); - const eagerBody = relaySseEagerBounded(upstreamResponse.body, turnAc, { + const eagerBody = relaySseEagerBounded(passthroughSseBody, turnAc, { inspectChunk: chunk => inspector.feed(chunk), finishInspection: () => inspector.finish(), disposeInspection: () => inspector.dispose(), @@ -2370,7 +2394,7 @@ async function handleResponsesInner( })), ); } - const [nativeBody, inspectBody] = upstreamResponse.body.tee(); + const [nativeBody, inspectBody] = passthroughSseBody.tee(); const turnAc = new AbortController(); const clientGone = new AbortController(); linkAbortSignal(upstream, turnAc.signal); diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 7c114b948..3c6c4751a 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -22,6 +22,8 @@ import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterP import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; import { handleResponses } from "../src/server/responses/core"; import { MAX_SYNTHESIZED_OUTPUT_ITEMS } from "../src/server/responses-json-events"; +import type { ResponsesTerminalRepairScheduler } from "../src/server/responses-terminal-repair"; +import { sendResponseToWebSocket } from "../src/server/ws-bridge"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -29,6 +31,68 @@ const createResponsesPassthroughAdapter = (...args: Parameters void }>(); + + nowMs(): number { return this.current; } + schedule(callback: () => void, delayMs: number): unknown { + const id = this.nextId++; + this.jobs.set(id, { at: this.current + delayMs, callback }); + return id; + } + cancel(handle: unknown): void { this.jobs.delete(handle as number); } + advance(ms: number): void { + this.current += ms; + for (const [id, job] of [...this.jobs.entries()]) { + if (job.at > this.current || !this.jobs.delete(id)) continue; + job.callback(); + } + } +} + +function sse(event: Record): string { + return `event: ${String(event.type)}\ndata: ${JSON.stringify(event)}\n\n`; +} + +function controlledSse(): { + stream: ReadableStream; + push(text: string): void; + cancel(): void; +} { + let controller: ReadableStreamDefaultController | null = null; + return { + stream: new ReadableStream({ start(next) { controller = next; } }), + push(text) { controller?.enqueue(encoder.encode(text)); }, + cancel() { try { controller?.close(); } catch { /* already closed */ } }, + }; +} + +async function readUntil( + reader: ReadableStreamDefaultReader, + pattern: string, +): Promise { + let out = ""; + while (!out.includes(pattern)) { + const { done, value } = await reader.read(); + if (done) throw new Error(`stream closed before ${pattern}`); + out += decoder.decode(value, { stream: true }); + } + return out; +} + +async function drainReader(reader: ReadableStreamDefaultReader): Promise { + let out = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) return out + decoder.decode(); + out += decoder.decode(value, { stream: true }); + } +} function deepseekProvider(): OcxProviderConfig { return { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "sk-test" }; @@ -151,6 +215,186 @@ describe("the inbound scope survives the handleResponses replay", () => { expect(request.body.stream).toBe(true); }); + test("HTTP streams a DeepSeek delta before safely repairing a missing terminal", async () => { + const source = controlledSse(); + const scheduler = new ManualTerminalScheduler(); + const requestBodies: Record[] = []; + const testAbort = new AbortController(); + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return new Response(source.stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + const options = { + abortSignal: testAbort.signal, + responsesTerminalRepairScheduler: scheduler, + } as Parameters[3]; + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), + }), + config, + { model: "", provider: "" }, + options, + ); + const reader = response.body!.getReader(); + try { + source.push([ + sse({ type: "response.created", response: { id: "resp_http", status: "in_progress", output: [] }, sequence_number: 0 }), + sse({ type: "response.output_item.added", item: { type: "reasoning", id: "rs_http", status: "in_progress", content: [] }, output_index: 0, sequence_number: 1 }), + sse({ type: "response.reasoning_text.delta", item_id: "rs_http", output_index: 0, delta: "thinking", sequence_number: 2 }), + ].join("")); + const first = await readUntil(reader, "response.reasoning_text.delta"); + expect(first).toContain("thinking"); + expect(requestBodies[0]?.stream).toBe(true); + + source.push([ + sse({ + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_http", status: "completed", content: [{ type: "reasoning_text", text: "thinking" }], summary: [] }, + output_index: 0, + sequence_number: 3, + }), + sse({ type: "response.output_item.added", item: { type: "function_call", id: "fc_http", status: "in_progress", arguments: "", call_id: "call_http", name: "probe" }, output_index: 1, sequence_number: 4 }), + sse({ type: "response.function_call_arguments.done", item_id: "fc_http", output_index: 1, arguments: "{\"text\":\"OK\"}", sequence_number: 5 }), + sse({ + type: "response.output_item.done", + item: { type: "function_call", id: "fc_http", status: "completed", arguments: "{\"text\":\"OK\"}", call_id: "call_http", name: "probe" }, + output_index: 1, + sequence_number: 6, + }), + ].join("")); + await Bun.sleep(0); + scheduler.advance(5_000); + const remainder = await Promise.race([ + drainReader(reader), + new Promise((_, reject) => setTimeout(() => reject(new Error("terminal repair did not close")), 200)), + ]); + expect(remainder).toContain("response.completed"); + expect(remainder).toContain("data: [DONE]"); + expect(remainder).toContain('"call_id":"call_http"'); + } finally { + testAbort.abort("test cleanup"); + source.cancel(); + try { await reader.cancel(); } catch { /* already closed */ } + } + }); + + test("WebSocket delivery preserves progressive DeepSeek frames and accepts the repaired tool result", async () => { + const source = controlledSse(); + const scheduler = new ManualTerminalScheduler(); + const requestBodies: Record[] = []; + let requestNumber = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + requestNumber += 1; + if (requestNumber === 1) { + return new Response(source.stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + return Response.json({ + id: "resp_ws_followup", + object: "response", + status: "completed", + output: [], + }); + }) as typeof fetch; + const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + const abort = new AbortController(); + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), + }), + config, + { model: "", provider: "" }, + { + abortSignal: abort.signal, + inboundTransport: "websocket", + responsesTerminalRepairScheduler: scheduler, + }, + ); + const sent: string[] = []; + const ws = { + readyState: 1, + data: {}, + send(message: string) { sent.push(message); return 1; }, + } as Parameters[0]; + try { + const pump = sendResponseToWebSocket(ws, response, () => true); + source.push([ + sse({ type: "response.created", response: { id: "resp_ws", status: "in_progress", output: [] }, sequence_number: 0 }), + sse({ type: "response.output_item.added", item: { type: "reasoning", id: "rs_ws", status: "in_progress", content: [] }, output_index: 0, sequence_number: 1 }), + sse({ type: "response.reasoning_text.delta", item_id: "rs_ws", output_index: 0, delta: "thinking", sequence_number: 2 }), + ].join("")); + for (let i = 0; i < 20 && !sent.some(frame => JSON.parse(frame).type === "response.reasoning_text.delta"); i += 1) { + await Bun.sleep(0); + } + expect(sent.some(frame => JSON.parse(frame).type === "response.reasoning_text.delta")).toBe(true); + expect(sent.some(frame => JSON.parse(frame).type === "response.completed")).toBe(false); + + source.push([ + sse({ + type: "response.output_item.done", + item: { type: "reasoning", id: "rs_ws", status: "completed", content: [{ type: "reasoning_text", text: "thinking" }], summary: [] }, + output_index: 0, + sequence_number: 3, + }), + sse({ type: "response.output_item.added", item: { type: "function_call", id: "fc_ws", status: "in_progress", arguments: "", call_id: "call_ws", name: "probe" }, output_index: 1, sequence_number: 4 }), + sse({ type: "response.function_call_arguments.done", item_id: "fc_ws", output_index: 1, arguments: "{\"text\":\"OK\"}", sequence_number: 5 }), + sse({ + type: "response.output_item.done", + item: { type: "function_call", id: "fc_ws", status: "completed", arguments: "{\"text\":\"OK\"}", call_id: "call_ws", name: "probe" }, + output_index: 1, + sequence_number: 6, + }), + ].join("")); + for (let i = 0; i < 20 && !sent.some(frame => JSON.parse(frame).type === "response.output_item.done"); i += 1) { + await Bun.sleep(0); + } + scheduler.advance(5_000); + await pump; + + const eventTypes = sent.map(frame => JSON.parse(frame).type as string); + expect(eventTypes.filter(type => type === "response.completed")).toHaveLength(1); + expect(eventTypes).toContain("response.output_item.done"); + + const followup = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: MODEL, + input: [ + { type: "function_call", id: "fc_ws", call_id: "call_ws", name: "probe", arguments: "{\"text\":\"OK\"}" }, + { type: "function_call_output", call_id: "call_ws", output: "OK" }, + ], + stream: true, + }), + }), + config, + { model: "", provider: "" }, + { inboundTransport: "websocket" }, + ); + await followup.text(); + expect(requestBodies[1]?.input).toEqual([ + { type: "function_call", call_id: "call_ws", name: "probe", arguments: "{\"text\":\"OK\"}" }, + { type: "function_call_output", call_id: "call_ws", output: "OK" }, + ]); + } finally { + abort.abort("test cleanup"); + source.cancel(); + } + }); + test("a documented no-[DONE] DeepSeek stream relays live and closes with a synthesized [DONE]", async () => { // DeepSeek's Responses guide: the stream ends with response.completed / // response.incomplete / response.failed — "there is no data: [DONE] message." @@ -251,6 +495,72 @@ describe("the inbound scope survives the handleResponses replay", () => { expect(text).toContain("data: [DONE]"); }); + test("streaming terminal repair composes with canonical item-id repair", async () => { + const source = controlledSse(); + const scheduler = new ManualTerminalScheduler(); + globalThis.fetch = (async () => new Response(source.stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + })) as typeof fetch; + const config = { providers: { deepseek: deepseekProvider() } } as unknown as OcxConfig; + const abort = new AbortController(); + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: MODEL, input: "ping", stream: true }), + }), + config, + { model: "", provider: "" }, + { abortSignal: abort.signal, responsesTerminalRepairScheduler: scheduler }, + ); + const reader = response.body!.getReader(); + const uuidReasoning = "1b9d6bcd-bbfd-4b2d-9b9d-5c0a2fb41a1b"; + const uuidMessage = "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d"; + const uuidFunction = "550e8400-e29b-41d4-a716-446655440000"; + try { + source.push([ + sse({ type: "response.created", response: { id: "resp_ids", status: "in_progress", output: [] }, sequence_number: 0 }), + sse({ type: "response.output_item.added", item: { type: "reasoning", id: uuidReasoning, status: "in_progress", content: [] }, output_index: 0, sequence_number: 1 }), + sse({ type: "response.output_item.done", item: { type: "reasoning", id: uuidReasoning, status: "completed", content: [{ type: "reasoning_text", text: "thinking" }], summary: [] }, output_index: 0, sequence_number: 2 }), + sse({ type: "response.output_item.added", item: { type: "message", id: uuidMessage, role: "assistant", status: "in_progress", content: [] }, output_index: 1, sequence_number: 3 }), + sse({ type: "response.output_text.delta", item_id: uuidMessage, output_index: 1, delta: "hello", sequence_number: 4 }), + sse({ type: "response.output_item.done", item: { type: "message", id: uuidMessage, role: "assistant", status: "completed", content: [{ type: "output_text", text: "hello" }] }, output_index: 1, sequence_number: 5 }), + sse({ type: "response.output_item.added", item: { type: "function_call", id: uuidFunction, status: "in_progress", arguments: "", call_id: "call_stream", name: "probe" }, output_index: 2, sequence_number: 6 }), + sse({ type: "response.function_call_arguments.done", item_id: uuidFunction, output_index: 2, arguments: "{\"text\":\"OK\"}", sequence_number: 7 }), + sse({ type: "response.output_item.done", item: { type: "function_call", id: uuidFunction, status: "completed", arguments: "{\"text\":\"OK\"}", call_id: "call_stream", name: "probe" }, output_index: 2, sequence_number: 8 }), + ].join("")); + const prefix = await readUntil(reader, '"sequence_number":8'); + scheduler.advance(5_000); + const text = prefix + await drainReader(reader); + const payloads = text + .split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6)) as Record); + const reasoningAdded = payloads.find(event => event.type === "response.output_item.added" && event.output_index === 0)!; + const reasoningDone = payloads.find(event => event.type === "response.output_item.done" && event.output_index === 0)!; + const messageAdded = payloads.find(event => event.type === "response.output_item.added" && event.output_index === 1)!; + const messageDone = payloads.find(event => event.type === "response.output_item.done" && event.output_index === 1)!; + const completed = payloads.find(event => event.type === "response.completed")!; + const output = (completed.response as { output: Array<{ id: string; call_id?: string }> }).output; + const reasoningId = (reasoningAdded.item as { id: string }).id; + const messageId = (messageAdded.item as { id: string }).id; + expect(reasoningId).toMatch(/^rs_ocx_/); + expect(messageId).toMatch(/^msg_ocx_/); + expect((reasoningDone.item as { id: string }).id).toBe(reasoningId); + expect((messageDone.item as { id: string }).id).toBe(messageId); + expect(output[0]?.id).toBe(reasoningId); + expect(output[1]?.id).toBe(messageId); + expect(output[2]).toMatchObject({ id: uuidFunction, call_id: "call_stream" }); + expect(text).not.toContain(uuidReasoning); + expect(text).not.toContain(uuidMessage); + } finally { + abort.abort("test cleanup"); + source.cancel(); + try { await reader.cancel(); } catch { /* already closed */ } + } + }); + }); /** From 2becae40812806b9c3ec5ab81ca42e198716e1c8 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 09:55:22 +0800 Subject: [PATCH 09/23] docs: describe DeepSeek streaming terminal repair --- structure/04_transports-and-sidecars.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 23cb53570..7225acdcb 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -228,6 +228,17 @@ rollback for upstreams that regress, kept suite-reachable by a synthetic-registr Synthesized output is capped at 10,000 items across HTTP and WebSocket reframing. HTTP frames are encoded incrementally, so bounded upstream JSON cannot expand into an unbounded event array or SSE string. +DeepSeek V4 Flash keeps native Responses streaming for progressive reasoning, text, and tool-call +delivery. Its registry entry enables a model-scoped terminal repair before the existing +inspection/client split. A real `response.completed`, `response.failed`, or `response.incomplete` +event always passes through unchanged. If every opened output item has a structurally complete +`output_item.done` and no real terminal arrives for five seconds, the repair emits exactly one +`response.completed` snapshot and closes the upstream reader. EOF or `[DONE]` uses the same strict +completion check; open, malformed, duplicate, contradictory, or unknown output graphs fail closed +as `response.incomplete`, never synthetic success. The repair shares the per-turn translator byte +budget, preserves backpressure, and composes ahead of item-id/snapshot rewrites so HTTP/SSE and +WebSocket clients observe the same canonical lifecycle. + `ws-bridge.ts` preserves upstream `failed` and `incomplete` status values in the final WebSocket frame rather than always emitting `response.completed`. If the response status is `failed`, a `response.failed` frame is sent; otherwise `response.completed` carries through the original status. From 157c3e424af11c925da58525bc5964cd2fc3a7e4 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 10:02:21 +0800 Subject: [PATCH 10/23] docs: document DeepSeek Responses streaming --- docs-site/src/content/docs/guides/providers.md | 4 ++++ docs-site/src/content/docs/zh-cn/guides/providers.md | 3 +++ 2 files changed, 7 insertions(+) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 917645092..81f3bbdf2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -291,6 +291,10 @@ wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). Volcengine Agent Plan uses its native Responses endpoint through `openai-responses`. +The built-in DeepSeek preset also routes `deepseek-v4-flash` over its native Responses endpoint and +keeps upstream SSE streaming enabled. If that model finishes every output item but omits the final +Responses event, opencodex applies a five-second model-scoped grace repair; malformed or partial +streams close as incomplete rather than being reported as successful. > **Three Volcengine billing routes:** `volcengine` is the pay-as-you-go Ark API, > `volcengine-coding-plan` consumes Coding Plan quota, and `volcengine-agent-plan` consumes Agent diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index cb5a48a80..c6b106854 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -198,6 +198,9 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 大多数使用带 bearer 密钥的 `openai-chat` adapter;少数仅暴露 Anthropic 兼容端点的提供商(例如 **Xiaomi MiMo**)使用 `anthropic` adapter(`x-api-key`)。 火山方舟 Agent Plan 通过 `openai-responses` adapter 使用原生 Responses 端点。 +内置 DeepSeek preset 同样会让 `deepseek-v4-flash` 使用原生 Responses 端点,并保留上游 SSE +流式输出。如果该模型已经完成全部输出项却缺少最终 Responses 事件,opencodex 会应用模型级 +5 秒宽限修复;不完整或格式异常的流会以 incomplete 结束,不会被误报为成功。 > **三条火山方舟计费线路:**`volcengine` 是按量付费方舟 API,`volcengine-coding-plan` > 消耗 Coding Plan 额度,`volcengine-agent-plan` 消耗 Agent Plan 额度。密钥与端点需要属于 From e7e38771d0c0de2ec48c400bf251a40b7f011c27 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Thu, 6 Aug 2026 10:17:54 +0800 Subject: [PATCH 11/23] test(responses): track provider-scoped SSE relay --- tests/passthrough-abort.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/passthrough-abort.test.ts b/tests/passthrough-abort.test.ts index c38e29f73..2b98ce91a 100644 --- a/tests/passthrough-abort.test.ts +++ b/tests/passthrough-abort.test.ts @@ -46,7 +46,10 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { capsSource.indexOf("export function selectEagerPath"), ); - expect(sseBranch).toContain("upstreamResponse.body.tee()"); + expect(sseBranch).toContain("const terminalRepairPolicy = providerModelResponsesTerminalRepair("); + expect(sseBranch).toContain("const passthroughSseBody = terminalRepairPolicy"); + expect(sseBranch).toContain(": upstreamResponse.body;"); + expect(sseBranch).toContain("passthroughSseBody.tee()"); // Rewrite traffic is derived from the finalized block chain so every // provider-specific transform participates in the platform gate. expect(sseBranch).toContain("const repairConfig = route.provider.responsesItemIdRepair;"); @@ -71,7 +74,7 @@ describe("passthrough relayWithAbort (RC2, passthrough path)", () => { expect(sseBranch).toContain("config.streamMode ?? \"auto\","); expect(selector).toContain('platform !== "win32" && platform !== "darwin"'); expect(selector).toContain('decision.reason === "config-eager"'); - expect(sseBranch).toContain("relaySseEagerBounded(upstreamResponse.body, turnAc,"); + expect(sseBranch).toContain("relaySseEagerBounded(passthroughSseBody, turnAc,"); expect(sseBranch).not.toContain("relaySseWithHeartbeat("); expect(sseBranch).not.toContain("trackStreamLifetime("); expect(logWrapper.indexOf("isNativePassthroughSseResponse(response)")).toBeGreaterThanOrEqual(0); From 243ee6de25c37144b5084b83fa4e0fdcc64548de Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 09:47:19 +0800 Subject: [PATCH 12/23] docs: design routed computer use and browser support --- ...8-09-routed-computer-use-browser-design.md | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md diff --git a/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md b/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md new file mode 100644 index 000000000..ecee2105a --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md @@ -0,0 +1,252 @@ +# Routed Computer Use and Browser Design + +## Goal + +Make Codex Computer Use and Browser available to every opencodex routed model +that can emit function/tool calls, without changing native OpenAI model behavior +or reimplementing Codex's local executors inside the proxy. + +The first acceptance target is `deepseek/deepseek-v4-flash`, but the catalog +policy applies consistently to all non-native routed rows. + +The delivered local runtime must also integrate and preserve the user's +previously validated capability stack: Responses vision fallback, image +generation bridging, and progressive DeepSeek Responses streaming. Computer Use +and Browser are an addition to that stack, not a replacement build from plain +`dev` that drops earlier fixes. + +## Context and evidence + +Codex Computer Use and Browser are local plugin capabilities. Both currently +execute through the `node_repl` MCP server: + +- Computer Use imports `@oai/sky` and invokes methods such as + `sky.list_apps()` or `sky.get_app_state()`. +- Browser loads the bundled browser client and invokes `agent.browsers.*`. + +They are not DeepSeek-hosted tools and must not be forwarded to the provider as +native `computer_use_preview` or browser APIs. + +The local executors are healthy on the affected machine: + +- `sky.list_apps()` returned 27 applications. +- The bundled browser client selected the Codex in-app browser and returned its + complete runtime documentation. + +The failure occurs before either executor is called. A real Codex CLI turn using +the routed DeepSeek model loaded both plugin skills but could not see or invoke +`mcp__node_repl__js`; it fell back to searching for an executable through shell +commands. + +The model catalog explains the difference: + +- Native GPT-5.6 entries use `tool_mode: "code_mode_only"`. +- `normalizeRoutedCatalogEntry()` deletes `tool_mode` from routed rows. +- In the installed Codex runtime, the global `code_mode` and + `code_mode_only` feature flags are disabled, so an omitted model selector + resolves to direct mode. +- Current Codex defers MCP tools behind the code-mode executor. Direct-mode + routed turns therefore lose the path to `node_repl`, even though the plugins, + skills, MCP configuration, and provider function calling are otherwise + healthy. + +## Scope + +### In scope + +- Every non-native model row generated by opencodex, including provider/model + routes, account-qualified routes, and combo routes. +- Template-derived and fallback catalog entries. +- Live `/v1/models` output and on-disk Codex catalog/cache synchronization. +- Existing custom/freeform tool translation used by the Codex `exec` entrypoint. +- HTTP/SSE and WebSocket response delivery after the routed model calls `exec`. +- End-to-end read-only validation with DeepSeek, Computer Use, and Browser. +- Integration and regression coverage for the existing Responses vision + fallback, image-generation bridge, and DeepSeek Responses streaming repair. + +### Out of scope + +- Implementing Computer Use or Browser execution inside opencodex. +- Sending OpenAI hosted `computer_use_preview` to non-OpenAI providers. +- Making a model without function/tool calling support execute tools. +- Changing plugin enablement, local permissions, browser extension setup, or + Computer Use confirmation policy. +- Changing native OpenAI catalog metadata. +- Changing multi-agent selection or `multi_agent_version`. +- Replacing the existing vision or image-generation architecture with a new + Computer Use implementation. + +## Options considered + +### Set routed rows to `code_mode_only` + +This is the selected design. It restores Codex's official `exec -> node_repl` +path with one catalog policy and reuses the proxy's existing custom-tool +translation. It has the smallest runtime and security blast radius. + +### Add a per-provider or per-model setting + +An override would be flexible but would make the working behavior opt-in and +create a new configuration failure mode. The requested behavior is a Codex host +compatibility requirement shared by routed models, not a provider preference. + +### Add Computer Use and Browser sidecars + +Wrapping proprietary local executors as opencodex function tools would duplicate +Codex's plugin lifecycle, confirmations, browser-session ownership, and update +compatibility. It would also move local UI authority into a headless proxy. This +option is rejected. + +## Architecture + +### Catalog policy + +Introduce one routed-catalog helper that sets: + +```json +{ "tool_mode": "code_mode_only" } +``` + +Apply it at the common routed-entry boundary used by both template-derived and +fallback rows. Native OpenAI entries retain their upstream value byte-for-byte. + +The policy is explicit rather than inherited from whichever native template was +selected. This preserves the existing normalization principle: routed rows must +not accidentally inherit provider-specific metadata, but may carry a deliberate +opencodex compatibility policy. + +### Request and response flow + +```text +Codex routed model catalog (`tool_mode: code_mode_only`) + -> Codex exposes the `exec` custom tool + -> opencodex parses `exec` as a freeform OcxTool + -> provider receives a normal function tool with one string input + -> routed model calls the function + -> opencodex emits a client-facing `custom_tool_call` + -> Codex runs the code-mode script + -> script invokes nested `mcp__node_repl__js` + -> Computer Use or Browser executes locally + -> `custom_tool_call_output` returns through opencodex + -> routed model continues the turn +``` + +No request body, screenshot, accessibility tree, browser state, or tool output +is logged beyond existing privacy-safe diagnostics. + +### Existing adapter contract + +The Responses parser already converts custom tools into an internal freeform +tool with a single `input` string. The bridge already reconstructs +`custom_tool_call` events and accepts their outputs. The implementation should +reuse those paths and change them only if a failing integration test proves a +missing contract. + +Provider adapters still receive ordinary function calling. No adapter receives +Codex-internal tool names or a proprietary Computer Use schema. + +## Failure behavior + +- A provider that cannot emit function calls cannot use `exec`; opencodex must + report the provider/model limitation honestly rather than fabricate success. +- Malformed `exec` arguments follow the existing custom-tool failure path. +- A missing or unhealthy `node_repl` server remains a Codex-local tool error and + returns to the model as tool output; it must not crash the proxy. +- Browser or Computer Use permission/confirmation requirements remain owned by + the official plugins. +- Client cancellation and upstream failure retain the existing Responses + terminal and cancellation behavior. +- Switching tool mode requires a fresh Codex process or task because model + metadata and tool planning are cached at session startup. + +## Testing strategy + +### Catalog tests + +Drive the current behavior red before changing production code: + +1. Template-derived routed entries must contain + `tool_mode: "code_mode_only"`. +2. Fallback routed entries must contain the same explicit policy. +3. Native OpenAI entries must preserve their upstream `tool_mode` value. +4. Live catalog generation and on-disk synchronization must produce the same + result. +5. Account-qualified and combo routed rows must follow the routed policy. + +### Protocol tests + +Add or extend focused tests proving: + +1. A Codex `exec` custom-tool declaration reaches a routed chat adapter as one + ordinary function tool with a required string input. +2. A streamed provider function call reconstructs one completed + `custom_tool_call` with exact input. +3. A `custom_tool_call_output` re-enters the next provider turn and the model can + complete normally. +4. Empty, fragmented, cancelled, and malformed tool calls retain existing + failure semantics. +5. HTTP/SSE and WebSocket delivery remain equivalent. + +### Regression tests + +Run the focused Computer Use/Browser catalog and protocol tests, then: + +- `bun run typecheck` +- `bun run test` +- `bun run privacy:scan` +- DeepSeek Responses streaming and terminal-repair tests +- Responses vision-sidecar and raw-body image-stripping tests +- Image-generation bridge planning, activation, synthetic tool, replay, and + result-restoration tests +- MCP, `tool_search`, `apply_patch`, shell, and custom-tool tests + +### Local end-to-end acceptance + +After deploying the branch locally and restarting Codex, run a fresh DeepSeek +task that performs only read-only actions: + +1. Load the Computer Use skill and call `sky.list_apps()` through + `mcp__node_repl__js`. +2. Load the Browser skill, connect to the default browser, and report its name + without navigating or clicking. +3. Confirm the opencodex route streams progress and completes with one valid + Responses terminal. +4. Attach an image in a separate fresh task and confirm the existing Responses + vision fallback still works without forwarding raw pixels to a text-only + upstream. +5. Request an image in a separate fresh task and confirm the existing image + bridge invokes its configured image backend, restores the client-facing tool + lifecycle, and returns a renderable generated image. + +## Deployment + +Build from the official `dev` base, which already contains the merged Responses +vision correction and image-generation bridge, plus the user's validated +DeepSeek streaming stack and this routed tool-mode change. Confirm the exact +commits providing all four capabilities before packaging. Before replacing the +installed runtime, back up the opencodex configuration, Codex configuration, +catalog, service definition, and installed runtime provenance. + +Install the validated build, repair/reload the background service, regenerate +the routed catalog, and verify `/healthz`. Restart Codex App and use new tasks so +the new model metadata and tool plan are loaded. Preserve the native OpenAI +model path and confirm it still exposes its original tool behavior. + +## Acceptance criteria + +The work is complete when all of the following are true: + +1. All routed catalog rows explicitly use `code_mode_only`; native rows are + unchanged. +2. DeepSeek can invoke the official local Computer Use and Browser paths through + Codex `exec` and `node_repl`. +3. No opencodex-owned Computer Use/Browser executor is introduced. +4. Focused tests, typecheck, privacy scan, and the full suite pass, or any + pre-existing baseline failure is independently reproduced and documented. +5. DeepSeek Responses streaming and terminal completion remain green. +6. Responses vision fallback remains green, including the previously fixed raw + passthrough-body synchronization and partial-caption failure paths. +7. Image generation remains green through the existing bridge and produces a + renderable image in a real Codex task. +8. The locally installed service survives a restart and Codex App can select and + use both native OpenAI and routed models. From c769ef93e1baa38fe93222e6c6161d1e6afeead1 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 10:22:38 +0800 Subject: [PATCH 13/23] docs: clarify routed tool mode scope --- .../2026-08-09-routed-computer-use-browser-design.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md b/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md index ecee2105a..cbeae3eaa 100644 --- a/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md +++ b/docs/superpowers/specs/2026-08-09-routed-computer-use-browser-design.md @@ -55,7 +55,7 @@ The model catalog explains the difference: ### In scope - Every non-native model row generated by opencodex, including provider/model - routes, account-qualified routes, and combo routes. + routes and combo routes. - Template-derived and fallback catalog entries. - Live `/v1/models` output and on-disk Codex catalog/cache synchronization. - Existing custom/freeform tool translation used by the Codex `exec` entrypoint. @@ -72,6 +72,8 @@ The model catalog explains the difference: - Changing plugin enablement, local permissions, browser extension setup, or Computer Use confirmation policy. - Changing native OpenAI catalog metadata. +- Changing native OpenAI account-qualified rows; they retain the same upstream + `tool_mode` as the corresponding native model. - Changing multi-agent selection or `multi_agent_version`. - Replacing the existing vision or image-generation architecture with a new Computer Use implementation. @@ -171,7 +173,8 @@ Drive the current behavior red before changing production code: 3. Native OpenAI entries must preserve their upstream `tool_mode` value. 4. Live catalog generation and on-disk synchronization must produce the same result. -5. Account-qualified and combo routed rows must follow the routed policy. +5. Combo routed rows must follow the routed policy, while native OpenAI + account-qualified rows preserve the native selector. ### Protocol tests @@ -236,8 +239,8 @@ model path and confirm it still exposes its original tool behavior. The work is complete when all of the following are true: -1. All routed catalog rows explicitly use `code_mode_only`; native rows are - unchanged. +1. All non-native routed catalog rows explicitly use `code_mode_only`; native + OpenAI rows, including account-qualified rows, are unchanged. 2. DeepSeek can invoke the official local Computer Use and Browser paths through Codex `exec` and `node_repl`. 3. No opencodex-owned Computer Use/Browser executor is introduced. From fa17f1bea9d7156a7c436068689e52efd939c450 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 10:24:57 +0800 Subject: [PATCH 14/23] docs: plan routed computer use and browser support --- .../2026-08-09-routed-computer-use-browser.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-routed-computer-use-browser.md diff --git a/docs/superpowers/plans/2026-08-09-routed-computer-use-browser.md b/docs/superpowers/plans/2026-08-09-routed-computer-use-browser.md new file mode 100644 index 000000000..e265c04b9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-routed-computer-use-browser.md @@ -0,0 +1,351 @@ +# Routed Computer Use and Browser Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to execute this plan task-by-task. + +**Goal:** Make every non-OpenAI model exposed through the opencodex Codex catalog use Codex's official code-mode executor so Computer Use and Browser remain callable, while preserving native OpenAI metadata and the existing vision, image-generation, and DeepSeek streaming fixes. + +**Architecture:** Add one explicit routed-catalog compatibility policy, `tool_mode: "code_mode_only"`, at the common normalization boundary. Apply the same policy to the no-template fallback path. Do not add local UI executors to opencodex; the proxy continues translating Codex's `exec` custom tool into ordinary provider function calling and relaying `custom_tool_call` events back to Codex. + +**Tech Stack:** TypeScript, Bun, `bun:test`, Codex model catalog JSON, opencodex Responses adapters, launchd background service. + +--- + +## Task 1: Lock the routed catalog contract with failing tests + +**Files:** +- Modify: `tests/codex-catalog.test.ts:280-300` +- Modify: `tests/codex-catalog.test.ts:1011-1045` +- Modify: `tests/codex-catalog.test.ts:1230-1255` + +**Step 1: Change the combo assertion to require the explicit routed mode** + +Replace the old omission assertion with: + +```ts +expect(row.tool_mode).toBe("code_mode_only"); +``` + +This covers bare and slashed combo aliases. + +**Step 2: Change direct normalization and template-derived routed assertions** + +Use these expectations: + +```ts +expect(entry.tool_mode).toBe("code_mode_only"); +expect(routed?.tool_mode).toBe("code_mode_only"); +``` + +Keep the existing assertions that native-only selectors such as `model_messages`, `use_responses_lite`, and inherited service tiers are removed. + +**Step 3: Add a fallback-row test** + +Add: + +```ts +test("buildCatalogEntries assigns code-only tools to routed fallback rows", () => { + const rows = buildCatalogEntries(null, [], [ + { provider: "deepseek", id: "deepseek-v4-flash", owned_by: "deepseek" }, + ]); + + const routed = rows.find(row => row.slug === "deepseek/deepseek-v4-flash"); + expect(routed?.tool_mode).toBe("code_mode_only"); +}); +``` + +**Step 4: Strengthen native and account-qualified preservation** + +Keep the native `tool_mode: "code"` assertion and add an account selector fixture: + +```ts +const rows = buildCatalogEntries(nativeTemplate(), ["gpt-5.5"], [], undefined, false, "default", new Set(), ["team"]); +expect(rows.find(row => row.slug === "gpt-5.5")?.tool_mode).toBe("code"); +expect(rows.find(row => row.slug === "team/gpt-5.5")?.tool_mode).toBe("code"); +``` + +**Step 5: Run the focused tests and confirm RED** + +Run: + +```bash +bun test tests/codex-catalog.test.ts +``` + +Expected: routed/template/combo/fallback assertions fail because current production code deletes or omits `tool_mode`; native preservation assertions remain green. + +**Step 6: Commit the red tests only** + +```bash +git add tests/codex-catalog.test.ts +git commit -m "test(codex): require code mode for routed models" +``` + +## Task 2: Implement the minimal catalog policy + +**Files:** +- Modify: `src/codex/catalog/parsing.ts:341-375` +- Modify: `src/codex/catalog/sync.ts:33` +- Modify: `src/codex/catalog/sync.ts:276-310` +- Test: `tests/codex-catalog.test.ts` + +**Step 1: Add a named compatibility helper** + +In `src/codex/catalog/parsing.ts`, add: + +```ts +export const ROUTED_CODEX_TOOL_MODE = "code_mode_only"; + +export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry { + entry.tool_mode = ROUTED_CODEX_TOOL_MODE; + return entry; +} +``` + +**Step 2: Make routed normalization explicit rather than inherited** + +Retain the deletion of the native template's selector, then apply the deliberate compatibility policy: + +```ts +delete entry.tool_mode; +applyRoutedCodexToolMode(entry); +``` + +This prevents accidental native-template inheritance while producing a stable routed value. + +**Step 3: Apply the same policy to no-template fallback rows** + +Import `applyRoutedCodexToolMode` in `src/codex/catalog/sync.ts`, then call it in the `isRouted` fallback branch before strict-field normalization: + +```ts +if (isRouted) { + applyRoutedCodexToolMode(entry); + applyReasoningLevels(/* existing arguments */); +} +``` + +Do not call the full routed normalizer from the fallback branch; that would broaden behavior by changing unrelated search and parallel-tool metadata. + +**Step 4: Run the focused tests and confirm GREEN** + +```bash +bun test tests/codex-catalog.test.ts +``` + +Expected: all catalog tests pass, including native/account preservation. + +**Step 5: Run typecheck** + +```bash +bun run typecheck +``` + +Expected: exit 0. + +**Step 6: Commit production code** + +```bash +git add src/codex/catalog/parsing.ts src/codex/catalog/sync.ts +git commit -m "fix(codex): enable code mode for routed models" +``` + +## Task 3: Prove on-disk catalog synchronization preserves the policy + +**Files:** +- Modify: `tests/codex-catalog-sync-hardening.test.ts` +- Test: `tests/codex-catalog-sync-hardening.test.ts` + +**Step 1: Add a real sync fixture assertion** + +Add a focused test that writes a native template with `tool_mode: "code"`, configures one selected DeepSeek model, runs `syncCatalogModels`, and reads the persisted catalog. Assert: + +```ts +expect(rows.find(row => row.slug === "deepseek/deepseek-v4-flash")?.tool_mode) + .toBe("code_mode_only"); +expect(rows.find(row => row.slug === "gpt-5.5")?.tool_mode).toBe("code"); +``` + +Also assert a generated native account-qualified row retains `"code"` if the fixture enables an account namespace. + +**Step 2: Run the sync test** + +```bash +bun test tests/codex-catalog-sync-hardening.test.ts +``` + +Expected: pass and prove the serialized catalog, not merely the in-memory builder. + +**Step 3: Commit the persistence test** + +```bash +git add tests/codex-catalog-sync-hardening.test.ts +git commit -m "test(codex): persist routed code mode policy" +``` + +## Task 4: Document the host-tool contract + +**Files:** +- Modify: `docs-site/src/content/docs/guides/codex-integration.md:170-230` + +**Step 1: Document routed tool mode near the shared catalog section** + +Add a concise subsection explaining: + +```md +### Routed local tools + +Non-OpenAI catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose +its official `exec` entrypoint and nested MCP tools, including Browser and Computer +Use, while opencodex routes only the model's ordinary function call. Tool execution, +permissions, and confirmation remain local to Codex. Providers without function-call +support cannot use these tools. Native OpenAI rows keep their upstream tool mode. +``` + +Mention that Codex App must be restarted and a fresh task opened after catalog synchronization. + +**Step 2: Build the documentation site** + +```bash +cd docs-site +bun install --frozen-lockfile +bun run build +``` + +Expected: build succeeds with no broken content links. + +**Step 3: Commit documentation** + +```bash +git add docs-site/src/content/docs/guides/codex-integration.md +git commit -m "docs(codex): explain routed local tool access" +``` + +## Task 5: Run the five-capability regression matrix + +**Files:** +- Verify only: existing sources and tests + +**Step 1: Verify custom/freeform `exec` protocol coverage** + +Run: + +```bash +bun test tests/responses-parser.test.ts tests/bridge.test.ts tests/multi-agent-compat.test.ts +``` + +Expected: custom declaration, streaming `custom_tool_call`, exact freeform input, and output replay remain green. Add no production protocol change unless one of these tests proves a real gap. + +**Step 2: Verify DeepSeek Responses streaming and cancellation** + +Run: + +```bash +bun test tests/responses-terminal-repair.test.ts tests/deepseek-inbound-wire.test.ts tests/deepseek-responses-item-id-repair.test.ts tests/passthrough-abort.test.ts +``` + +Expected: progressive deltas, strict terminal repair, item IDs, and cancellation pass. + +**Step 3: Verify Responses vision** + +Run: + +```bash +bun test tests/vision-sidecar-e2e.test.ts tests/vision-anthropic.test.ts tests/vision-cache.test.ts tests/vision-fail-closed.test.ts tests/catalog-vision-sidecar-modalities.test.ts tests/openai-responses-passthrough.test.ts +``` + +Expected: captions replace raw image parts in passthrough bodies, empty references do not consume captions, and partial/failure paths omit pixels safely. + +**Step 4: Verify image generation** + +Run: + +```bash +bun test tests/images/plan.test.ts tests/images/synthetic-tool.test.ts tests/images/z-handler-activation.test.ts tests/images/loop-reasoning-replay.test.ts tests/responses-image-gen-repair.test.ts +``` + +Expected: image tool planning, activation, alias restoration, replay, and result repair pass. + +**Step 5: Run repository gates** + +Run: + +```bash +bun run typecheck +bun run privacy:scan +bun run test +``` + +Because this worktree contains pre-existing untracked duplicate `* 2.*` files, if the full-suite source inventory fails only on those files, reproduce the suite from a clean temporary checkout at the same commit and document both results. Do not delete or add the user's duplicate files. + +**Step 6: Record any verification-only outcome** + +No commit is needed unless a failing regression requires a scoped test or production correction. Any such correction must repeat RED -> GREEN and use its own exact-file commit. + +## Task 6: Back up and deploy the validated local build + +**Files:** +- Back up: `~/.opencodex/config.json` +- Back up: `~/.codex/config.toml` +- Back up: `~/.codex/opencodex-catalog.json` +- Back up: `~/Library/LaunchAgents/com.opencodex.proxy.plist` +- Replace: installed `@bitkyc08/opencodex` package/runtime + +**Step 1: Capture provenance and create a timestamped backup** + +Record the installed `ocx` path/version, service PID/runtime path, current git SHA, and SHA-256 hashes of the four configuration/service files. Copy present files into one timestamped directory under `/private/tmp` without printing credentials. + +**Step 2: Build a local package artifact** + +Run the repository packaging script and create an npm-compatible tarball from the validated commit. Inspect its file list to confirm the package contains the changed catalog source and existing DeepSeek/vision/image code. + +**Step 3: Install the exact artifact and repair the service** + +Install the local artifact into the same global prefix that owns the current `ocx`, then run: + +```bash +ocx service repair +ocx sync +ocx status +``` + +Expected: the launchd service is loaded, the installed runtime provenance points to the new package, port `10100` serves `/healthz`, and sync rewrites catalog/cache successfully. + +**Step 4: Inspect generated metadata without exposing secrets** + +Read `~/.codex/opencodex-catalog.json` and `~/.codex/models_cache.json`. Assert: + +- `deepseek/deepseek-v4-flash.tool_mode === "code_mode_only"` +- native GPT-5.6 rows retain their upstream mode +- routed model list still includes DeepSeek + +## Task 7: Run fresh Codex end-to-end acceptance + +**Files:** +- Verify only: installed runtime and fresh Codex tasks + +**Step 1: Validate Computer Use through the default routed model** + +Start a fresh Codex CLI task without a model or reasoning override. Ask it to load the official Computer Use skill and call `sky.list_apps()` read-only through `mcp__node_repl__js`. + +Expected: a real app count returns; the model does not fall back to searching for a shell executable. + +**Step 2: Validate Browser through the default routed model** + +Start another fresh task and ask it to load the official Browser skill, connect to the default browser, and report the browser name without navigation or clicks. + +Expected: it identifies the Codex in-app browser through the nested browser client. + +**Step 3: Validate progressive DeepSeek output** + +Use a fresh text-only task and inspect proxy request logs. Expected: multiple progressive SSE events arrive before exactly one valid terminal; no `502`, stall, or premature disconnect. + +**Step 4: Validate vision and image generation** + +Use separate fresh tasks. Attach a known test image and request a factual description; then request a small generated test image. Verify the first reaches the text-only model as a caption without raw pixels and the second returns a renderable image through the configured image backend. + +**Step 5: Validate native OpenAI remains available** + +After the user restarts Codex App, select one native GPT-5.6 model in a fresh task and confirm its Computer Use/Browser behavior is unchanged. + +**Step 6: Final handoff** + +Report the exact installed commit, artifact/provenance, service status, catalog values, focused/full-suite results, and each real acceptance result. If Codex App itself cannot be restarted without terminating this task, explicitly ask the user to restart it once; CLI and service evidence must already be complete. From 30f48a0cf182eb53236dc36ea3db813a34cf3cea Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 10:25:39 +0800 Subject: [PATCH 15/23] test(codex): require code mode for routed models --- tests/codex-catalog.test.ts | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index d654fd896..3f23e54df 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -323,7 +323,7 @@ describe("combo catalog capability intersection", () => { expect(row.owned_by).toBe("combo"); expect(row.base_instructions).toContain("mixed"); expect(row).not.toHaveProperty("model_messages"); - expect(row).not.toHaveProperty("tool_mode"); + expect(row.tool_mode).toBe("code_mode_only"); expect(row.web_search_tool_type).toBe("text_and_image"); expect(row.supports_search_tool).toBe(true); } @@ -2231,13 +2231,13 @@ describe("Codex catalog routed normalization", () => { } }); - test("normalizeRoutedCatalogEntry strips native-only routed selectors", () => { + test("normalizeRoutedCatalogEntry strips native-only selectors and applies routed tool mode", () => { const entry = nativeTemplate(); normalizeRoutedCatalogEntry(entry); expect(entry).not.toHaveProperty("model_messages"); - expect(entry).not.toHaveProperty("tool_mode"); + expect(entry.tool_mode).toBe("code_mode_only"); expect(entry).not.toHaveProperty("multi_agent_version"); expect(entry).not.toHaveProperty("use_responses_lite"); expect(entry).not.toHaveProperty("supports_websockets"); @@ -2257,7 +2257,7 @@ describe("Codex catalog routed normalization", () => { expect(routed).toBeDefined(); expect(routed).not.toHaveProperty("model_messages"); - expect(routed).not.toHaveProperty("tool_mode"); + expect(routed?.tool_mode).toBe("code_mode_only"); // Routed entries do not inherit a native template's surface pin; the global // Codex v2 flag can choose the surface freely unless upstream pins the model. expect(routed).not.toHaveProperty("multi_agent_version"); @@ -2522,6 +2522,31 @@ describe("Codex catalog routed normalization", () => { expect(native?.service_tiers).toEqual([{ id: "priority" }]); }); + test("buildCatalogEntries assigns code-only tools to routed fallback rows", () => { + const rows = buildCatalogEntries(null, [], [ + { provider: "deepseek", id: "deepseek-v4-flash", owned_by: "deepseek" }, + ]); + + const routed = rows.find(row => row.slug === "deepseek/deepseek-v4-flash"); + expect(routed?.tool_mode).toBe("code_mode_only"); + }); + + test("buildCatalogEntries preserves native tool mode on account-qualified rows", () => { + const rows = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [], + undefined, + false, + "default", + new Set(), + ["team"], + ); + + expect(rows.find(row => row.slug === "gpt-5.5")?.tool_mode).toBe("code"); + expect(rows.find(row => row.slug === "team/gpt-5.5")?.tool_mode).toBe("code"); + }); + test("catalog sync keeps native OpenAI rows when adopted providers expose matching ids", () => { const native = { ...nativeTemplate(), From f60dd981dfabb6022467e5a1291510d1ffc0858e Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 10:26:14 +0800 Subject: [PATCH 16/23] fix(codex): enable code mode for routed models --- src/codex/catalog/parsing.ts | 8 ++++++++ src/codex/catalog/sync.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 952bb09a8..74a93d7ca 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -318,6 +318,13 @@ export function ensureStrictCatalogFields( export type MultiAgentMode = "v1" | "default" | "v2"; +export const ROUTED_CODEX_TOOL_MODE = "code_mode_only"; + +export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry { + entry.tool_mode = ROUTED_CODEX_TOOL_MODE; + return entry; +} + /** * @param v2FeatureEnabled When the native multi_agent_v2 feature is on, "default" * mode stamps unpinned entries as "v2" instead of deleting the key. The native @@ -358,6 +365,7 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry { delete entry.model_messages; delete entry.tool_mode; + applyRoutedCodexToolMode(entry); delete entry.multi_agent_version; delete entry.use_responses_lite; delete entry.supports_websockets; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 1e5e9dbb0..f48c215b1 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -31,7 +31,7 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, applyRoutedCodexToolMode, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { @@ -314,6 +314,7 @@ export function deriveEntry( ...(isRouted ? { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}), }; if (isRouted) { + applyRoutedCodexToolMode(entry); applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); } else { From aa9f11f55e091698b99202d14d1ea6161b657d88 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 10:27:07 +0800 Subject: [PATCH 17/23] test(codex): persist routed code mode policy --- tests/codex-catalog-sync-hardening.test.ts | 39 ++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index b0487a26c..580a3858d 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -447,6 +447,45 @@ describe("Codex catalog sync hardening", () => { expect(rows.some(row => row.slug === "desktop/gpt-5.5")).toBe(false); }); + test("catalog sync persists routed code mode without changing native account rows", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [{ ...nativeEntry("gpt-5.5", 0), tool_mode: "code" }], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false + }, + deepseek: { + adapter: "openai-responses", + baseUrl: "https://api.example.test/v1", + liveModels: false, + models: ["deepseek-v4-flash"] + } + }, + codexAccounts: [{ id: "stored-team-account", isMain: false }], + codexAccountNamespaces: { team: "stored-team-account" } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + tool_mode?: string | null; + }>; + expect(rows.find(row => row.slug === "deepseek/deepseek-v4-flash")?.tool_mode) + .toBe("code_mode_only"); + expect(rows.find(row => row.slug === "gpt-5.5")?.tool_mode).toBe("code"); + expect(rows.find(row => row.slug === "team/gpt-5.5")?.tool_mode).toBe("code"); + }); + test("disabled canonical OpenAI keeps bare bootstrap rows but omits unrouteable account rows", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); From 8bb00d312ce54cff7132f41c2642057cd132a5cd Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 10:27:48 +0800 Subject: [PATCH 18/23] docs(codex): explain routed local tool access --- .../src/content/docs/guides/codex-integration.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 2362a13c1..20081f912 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -193,6 +193,20 @@ Routed catalog entries also get their GPT-5 identity rewritten to the real upstr Reasoning controls come from provider/model metadata across Codex's `low | medium | high | xhigh | max | ultra` ladder; unsupported values are mapped or clamped before the upstream request. +### Routed local tools + +Non-OpenAI catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose its official +`exec` entrypoint and nested MCP tools, including Browser and Computer Use, while opencodex routes +only the model's ordinary function call. Tool execution, permissions, and confirmations remain +local to Codex; opencodex does not implement a second browser or desktop-control executor. + +The selected provider must support function/tool calling. A text-only provider without tool-call +support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool +mode unchanged. + +After `ocx sync` changes this metadata, restart Codex App and open a fresh task. Existing app-server +processes and tasks may retain the catalog and tool plan they loaded at startup. + ### Custom model display names A custom model can carry a human-readable **display name** that overrides the label Codex shows in From e6ad8fb011fe29b20c4d1bc945387704129d987c Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Sun, 9 Aug 2026 11:14:23 +0800 Subject: [PATCH 19/23] fix(responses): bridge routed exec custom tools --- .../content/docs/guides/codex-integration.md | 5 + src/adapters/openai-responses.ts | 4 + src/responses/custom-tool-compat.ts | 182 ++++++++++++++ src/server/responses-custom-tool-repair.ts | 179 ++++++++++++++ src/server/responses/core.ts | 13 +- tests/openai-responses-passthrough.test.ts | 38 ++- tests/responses-custom-tool-repair.test.ts | 229 ++++++++++++++++++ 7 files changed, 647 insertions(+), 3 deletions(-) create mode 100644 src/responses/custom-tool-compat.ts create mode 100644 src/server/responses-custom-tool-repair.ts create mode 100644 tests/responses-custom-tool-repair.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 20081f912..b3b6b473a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -200,6 +200,11 @@ Non-OpenAI catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expos only the model's ordinary function call. Tool execution, permissions, and confirmations remain local to Codex; opencodex does not implement a second browser or desktop-control executor. +For key-auth Responses providers that do not accept Codex's `exec` custom-tool grammar, opencodex +encodes that declaration and its history as an upstream function tool, then restores the streamed +function-call lifecycle to `custom_tool_call` before Codex sees it. Native OpenAI forward routing +and the supported `apply_patch` custom tool stay unchanged. + The selected provider must support function/tool calling. A text-only provider without tool-call support cannot use `exec`, Browser, or Computer Use. Native OpenAI rows keep their upstream tool mode unchanged. diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 9e9b46acc..7aa3fa8ec 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -10,6 +10,7 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; +import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode. // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call. @@ -1245,6 +1246,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { outBody = buildRoutedCompactionBody(outBody); } + if (provider.authMode !== "forward") { + outBody = rewriteRoutedCustomToolsForUpstream(outBody).body; + } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const body = JSON.stringify(stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts new file mode 100644 index 000000000..c5feace80 --- /dev/null +++ b/src/responses/custom-tool-compat.ts @@ -0,0 +1,182 @@ +const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function customToolInput(argumentsText: unknown): string { + if (typeof argumentsText !== "string") return ""; + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (isPlainObject(parsed) && typeof parsed.input === "string") return parsed.input; + } catch { /* malformed arguments stay visible to the client */ } + return argumentsText; +} + +export function customToolItemId(id: unknown): unknown { + if (typeof id !== "string") return id; + return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; +} + +export function collectRoutedCustomToolNames(body: unknown): Set { + const names = new Set(); + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + for (const entry of value) visit(entry); + return; + } + if (!isPlainObject(value)) return; + if ( + value.type === "custom" + && typeof value.name === "string" + && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + ) { + names.add(value.name); + } + for (const entry of Object.values(value)) visit(entry); + }; + visit(body); + return names; +} + +function collectConvertedCallIds(value: unknown, names: ReadonlySet, out: Set): void { + if (Array.isArray(value)) { + for (const entry of value) collectConvertedCallIds(entry, names, out); + return; + } + if (!isPlainObject(value)) return; + if ( + (value.type === "custom_tool_call" || value.type === "function_call") + && typeof value.name === "string" + && names.has(value.name) + && typeof value.call_id === "string" + ) { + out.add(value.call_id); + } + for (const entry of Object.values(value)) collectConvertedCallIds(entry, names, out); +} + +function rewriteForUpstream( + value: unknown, + names: ReadonlySet, + callIds: ReadonlySet, +): unknown { + if (Array.isArray(value)) return value.map(entry => rewriteForUpstream(entry, names, callIds)); + if (!isPlainObject(value)) return value; + + if (value.type === "custom" && typeof value.name === "string" && names.has(value.name)) { + const { format: _format, ...rest } = value; + const isDefinition = typeof value.description === "string" + || isPlainObject(value.format) + || isPlainObject(value.parameters); + if (!isDefinition) return { ...rest, type: "function" }; + return { + ...rest, + type: "function", + parameters: { + type: "object", + properties: { + input: { + type: "string", + description: "Raw input for this client-executed custom tool.", + }, + }, + required: ["input"], + additionalProperties: false, + }, + }; + } + + if ( + value.type === "custom_tool_call" + && typeof value.name === "string" + && names.has(value.name) + ) { + const { input, id: _id, ...rest } = value; + return { + ...rest, + type: "function_call", + arguments: JSON.stringify({ input: typeof input === "string" ? input : "" }), + }; + } + + if ( + value.type === "custom_tool_call_output" + && typeof value.call_id === "string" + && callIds.has(value.call_id) + ) { + return { ...value, type: "function_call_output" }; + } + + let changed = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const rewritten = rewriteForUpstream(entry, names, callIds); + next[key] = rewritten; + changed ||= rewritten !== entry; + } + return changed ? next : value; +} + +export function rewriteRoutedCustomToolsForUpstream(body: unknown): { + body: unknown; + names: Set; +} { + const names = collectRoutedCustomToolNames(body); + if (names.size === 0) return { body, names }; + const callIds = new Set(); + collectConvertedCallIds(body, names, callIds); + return { body: rewriteForUpstream(body, names, callIds), names }; +} + +export function restoreRoutedCustomCalls( + value: unknown, + names: ReadonlySet, +): { value: unknown; changed: boolean } { + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(entry => { + const result = restoreRoutedCustomCalls(entry, names); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isPlainObject(value)) return { value, changed: false }; + + let changed = false; + const restored: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = restoreRoutedCustomCalls(entry, names); + restored[key] = result.value; + changed ||= result.changed; + } + + if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) { + restored.type = "custom_tool_call"; + restored.id = customToolItemId(value.id); + restored.input = customToolInput(value.arguments); + delete restored.arguments; + changed = true; + } + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +export function restoreRoutedCustomCallsInJson( + text: string, + names: ReadonlySet, +): string { + if (names.size === 0) return text; + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const restored = restoreRoutedCustomCalls(payload, names); + return restored.changed ? JSON.stringify(restored.value) : text; +} + +export function unwrapRoutedCustomToolArguments(argumentsText: unknown): string { + return customToolInput(argumentsText); +} diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts new file mode 100644 index 000000000..830711752 --- /dev/null +++ b/src/server/responses-custom-tool-repair.ts @@ -0,0 +1,179 @@ +import type { TranslatorBudget } from "../lib/translator-budget"; +import { + customToolItemId, + restoreRoutedCustomCalls, + unwrapRoutedCustomToolArguments, +} from "../responses/custom-tool-compat"; +import { + replaceSseDataPayload, + sseDataPayload, + type SseBlockRewrite, +} from "./sse-payload-rewrite"; + +const FREEFORM_WRAP_PREFIX = '{"input":"'; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function partialCustomToolInput(argumentsText: string): string { + if (!argumentsText.startsWith(FREEFORM_WRAP_PREFIX)) return argumentsText; + const body = argumentsText.slice(FREEFORM_WRAP_PREFIX.length); + let output = ""; + for (let index = 0; index < body.length; index++) { + const char = body[index]; + if (char === '"') break; + if (char !== "\\") { + output += char; + continue; + } + const escaped = body[index + 1]; + if (escaped === undefined) break; + index += 1; + if (escaped === "n") output += "\n"; + else if (escaped === "t") output += "\t"; + else if (escaped === "r") output += "\r"; + else if (escaped === "u") { + const hex = body.slice(index + 1, index + 5); + if (hex.length !== 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break; + output += String.fromCharCode(Number.parseInt(hex, 16)); + index += 4; + } else output += escaped; + } + return output; +} + +function replaceSseEventName(block: string, type: string): string { + const newline = block.includes("\r\n") ? "\r\n" : "\n"; + const lines = block.split(/\r?\n/); + let replaced = false; + const next = lines.map(line => { + if (!replaced && line.startsWith("event:")) { + replaced = true; + return `event: ${type}`; + } + return line; + }); + return next.join(newline); +} + +type OpenCustomCall = { + argumentsText: string; + emittedInput: string; + retainedBytes: number; +}; + +export function createRoutedCustomToolRestoreBlockRewrite( + names: ReadonlySet, + budget?: TranslatorBudget, +): SseBlockRewrite { + const itemNames = new Map(); + const openCalls = new Map(); + let disposed = false; + + const releaseCall = (itemId: string): void => { + const open = openCalls.get(itemId); + if (!open) return; + if (open.retainedBytes > 0) { + budget?.releaseRetained(open.retainedBytes, { kind: "retained_collectors" }); + } + openCalls.delete(itemId); + }; + + const releaseAll = (): void => { + if (disposed) return; + disposed = true; + for (const itemId of openCalls.keys()) releaseCall(itemId); + itemNames.clear(); + }; + + const rewrite: SseBlockRewrite = (block: string): readonly string[] => { + const payload = sseDataPayload(block); + if (payload === null || payload === "[DONE]") return [block]; + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return [block]; + } + if (!isPlainObject(parsed)) return [block]; + + const type = typeof parsed.type === "string" ? parsed.type : ""; + if ( + (type === "response.output_item.added" || type === "response.output_item.done") + && isPlainObject(parsed.item) + && parsed.item.type === "function_call" + && typeof parsed.item.name === "string" + && names.has(parsed.item.name) + ) { + const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; + if (upstreamItemId) { + itemNames.set(upstreamItemId, parsed.item.name); + if (type === "response.output_item.added") { + openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); + } + } + const restored = restoreRoutedCustomCalls(parsed, names); + if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId); + return restored.changed + ? [replaceSseDataPayload(block, JSON.stringify(restored.value))] + : [block]; + } + + const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined; + if ( + type === "response.function_call_arguments.delta" + && upstreamItemId + && itemNames.has(upstreamItemId) + ) { + const open = openCalls.get(upstreamItemId) ?? { argumentsText: "", emittedInput: "", retainedBytes: 0 }; + const delta = typeof parsed.delta === "string" ? parsed.delta : ""; + const deltaBytes = Buffer.byteLength(delta, "utf8"); + if (deltaBytes > 0) budget?.chargeRetained(deltaBytes, { kind: "retained_collectors" }); + open.argumentsText += delta; + open.retainedBytes += deltaBytes; + openCalls.set(upstreamItemId, open); + if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return []; + const fullInput = partialCustomToolInput(open.argumentsText); + if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return []; + const inputDelta = fullInput.slice(open.emittedInput.length); + open.emittedInput = fullInput; + const nextType = "response.custom_tool_call_input.delta"; + const next = { + ...parsed, + type: nextType, + item_id: customToolItemId(upstreamItemId), + delta: inputDelta, + }; + return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; + } + + if ( + type === "response.function_call_arguments.done" + && upstreamItemId + && itemNames.has(upstreamItemId) + ) { + const nextType = "response.custom_tool_call_input.done"; + const source = typeof parsed.arguments === "string" + ? parsed.arguments + : openCalls.get(upstreamItemId)?.argumentsText ?? ""; + const { arguments: _arguments, ...rest } = parsed; + const next = { + ...rest, + type: nextType, + item_id: customToolItemId(upstreamItemId), + input: unwrapRoutedCustomToolArguments(source), + }; + return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; + } + + const restored = restoreRoutedCustomCalls(parsed, names); + const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete"; + if (terminal) releaseAll(); + return restored.changed + ? [replaceSseDataPayload(block, JSON.stringify(restored.value))] + : [block]; + }; + rewrite.dispose = releaseAll; + return rewrite; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c6e55ebe7..3b8904cd0 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -213,6 +213,8 @@ import { payloadRewriteAsBlockRewrite, relaySseWithBlockRewrite, } from "../sse-payload-rewrite"; +import { collectRoutedCustomToolNames, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; +import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { responsesJsonToSseStream } from "../responses-json-events"; import { guardTerminalEventStream } from "./terminal-guard"; @@ -1899,6 +1901,9 @@ async function handleResponsesInner( const imageGenCallAliases = route.provider.authMode === "forward" ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); + const routedCustomToolNames = route.provider.authMode === "forward" + ? new Set() + : collectRoutedCustomToolNames(parsed._rawBody); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY @@ -2303,6 +2308,9 @@ async function handleResponsesInner( payloadRewrites.length > 0 ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites)) : undefined, + routedCustomToolNames.size > 0 + ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget) + : undefined, githubCopilotRepairEnabled ? createGithubCopilotResponsesBlockRewrite(translatorBudget) : undefined, @@ -2487,7 +2495,10 @@ async function handleResponsesInner( } catch { /* non-JSON despite content-type; recording is best-effort */ } } const clientJson = (() => { - const restored = restoreImageGenCallsInJson(text, imageGenCallAliases); + const restored = restoreRoutedCustomCallsInJson( + restoreImageGenCallsInJson(text, imageGenCallAliases), + routedCustomToolNames, + ); const repaired = (() => { if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored; let outbound: unknown; diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 59f58237b..f5db97fa0 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -93,6 +93,40 @@ describe("DeepSeek Responses endpoint contract", () => { .toBe("https://api.cerebras.ai/v1/responses"); }); + test("key-auth routed Responses converts exec custom tools while native forward preserves them", () => { + const rawBody = { + model: "deepseek-v4-flash", + input: "ping", + tools: [ + { type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }, + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + ], + }; + const parsed = { + modelId: "deepseek-v4-flash", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: rawBody, + }; + const keyed = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.deepseek.com", + responsesPath: "/responses", + authMode: "key" as const, + apiKey: "sk-test", + }); + const keyedBody = JSON.parse(keyed.buildRequest(parsed, { headers: new Headers() }).body) as typeof rawBody; + expect(keyedBody.tools[0]).toMatchObject({ type: "function", name: "exec" }); + expect(keyedBody.tools[1]).toMatchObject({ type: "custom", name: "apply_patch" }); + + const nativeBody = JSON.parse(createResponsesPassthroughAdapter(provider).buildRequest( + { ...parsed, modelId: "gpt-5.6-sol" }, + { headers: new Headers({ authorization: "Bearer token" }) }, + ).body) as typeof rawBody; + expect(nativeBody.tools).toEqual(rawBody.tools); + }); + test("a config saved before the fix is backfilled, and a hand-set path is preserved", () => { const saved = { adapter: "openai-chat", baseUrl: "https://api.deepseek.com", apiKey: "sk-test" } as Parameters[1]; enrichProviderFromRegistry("deepseek", saved); @@ -1451,14 +1485,14 @@ describe("OpenAI Responses hosted-tool name conflicts", () => { expect(body.tools).toEqual([ { type: "image_generation" }, - { type: "custom", name: "exec_command" }, + { type: "function", name: "exec_command", parameters: { type: "object" } }, ]); expect(body.tool_choice).toEqual({ type: "allowed_tools", mode: "required", tools: [ { type: "image_generation" }, - { type: "custom", name: "exec_command" }, + { type: "function", name: "exec_command" }, ], }); }); diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts new file mode 100644 index 000000000..3e12a789a --- /dev/null +++ b/tests/responses-custom-tool-repair.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, test } from "bun:test"; +import { + collectRoutedCustomToolNames, + restoreRoutedCustomCallsInJson, + rewriteRoutedCustomToolsForUpstream, +} from "../src/responses/custom-tool-compat"; +import { createRoutedCustomToolRestoreBlockRewrite } from "../src/server/responses-custom-tool-repair"; +import { handleResponses } from "../src/server/responses"; +import type { OcxConfig } from "../src/types"; + +function dataPayload(block: string): Record { + const line = block.split(/\r?\n/).find(entry => entry.startsWith("data:")); + if (!line) throw new Error("missing SSE data line"); + return JSON.parse(line.slice(5).trim()) as Record; +} + +function frame(event: string, payload: Record): string { + return `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; +} + +describe("routed Responses custom-tool compatibility", () => { + test("rewrites exec definitions and paired history without touching apply_patch", () => { + const raw = { + model: "deepseek-v4-flash", + tools: [ + { type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }, + { type: "custom", name: "apply_patch", description: "Apply a patch", format: { type: "grammar", syntax: "lark" } }, + { type: "function", name: "ordinary", parameters: { type: "object" } }, + ], + input: [ + { type: "custom_tool_call", id: "ctc_exec", call_id: "call_exec", name: "exec", input: "await sky.list_apps()" }, + { type: "custom_tool_call_output", call_id: "call_exec", output: "27 apps" }, + { type: "custom_tool_call", id: "ctc_patch", call_id: "call_patch", name: "apply_patch", input: "*** Begin Patch" }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "done" }, + ], + }; + + expect(collectRoutedCustomToolNames(raw)).toEqual(new Set(["exec"])); + const rewritten = rewriteRoutedCustomToolsForUpstream(raw); + expect(rewritten.names).toEqual(new Set(["exec"])); + expect(rewritten.body).not.toBe(raw); + expect(raw.tools[0]?.type).toBe("custom"); + + const body = rewritten.body as typeof raw; + expect(body.tools[0]).toMatchObject({ + type: "function", + name: "exec", + parameters: { + type: "object", + properties: { input: { type: "string" } }, + required: ["input"], + }, + }); + expect(body.tools[0]).not.toHaveProperty("format"); + expect(body.tools[1]).toEqual(raw.tools[1]); + expect(body.tools[2]).toEqual(raw.tools[2]); + expect(body.input[0]).toMatchObject({ + type: "function_call", + call_id: "call_exec", + name: "exec", + arguments: JSON.stringify({ input: "await sky.list_apps()" }), + }); + expect(body.input[0]).not.toHaveProperty("input"); + expect(body.input[1]).toMatchObject({ type: "function_call_output", call_id: "call_exec" }); + expect(body.input[2]).toEqual(raw.input[2]); + expect(body.input[3]).toEqual(raw.input[3]); + }); + + test("restores non-streaming exec calls while leaving ordinary functions alone", () => { + const upstream = JSON.stringify({ + id: "resp_1", + output: [ + { type: "function_call", id: "fc_exec", call_id: "call_exec", name: "exec", arguments: "{\"input\":\"const apps = await sky.list_apps();\"}", status: "completed" }, + { type: "function_call", id: "fc_other", call_id: "call_other", name: "ordinary", arguments: "{}", status: "completed" }, + ], + }); + + const restored = JSON.parse(restoreRoutedCustomCallsInJson(upstream, new Set(["exec"]))) as { + output: Array>; + }; + expect(restored.output[0]).toMatchObject({ + type: "custom_tool_call", + name: "exec", + input: "const apps = await sky.list_apps();", + }); + expect(restored.output[0]).not.toHaveProperty("arguments"); + expect(restored.output[1]).toMatchObject({ type: "function_call", name: "ordinary", arguments: "{}" }); + }); + + test("restores the streamed exec lifecycle and unwraps progressive input", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_exec", call_id: "call_exec", name: "exec", arguments: "", status: "in_progress" }, + })); + expect(added).toHaveLength(1); + expect(dataPayload(added[0]!).item).toMatchObject({ + type: "custom_tool_call", + id: "ctc_exec", + name: "exec", + input: "", + }); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, item_id: "fc_exec", delta: "{\"inp", + }))).toEqual([]); + const firstDelta = rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, item_id: "fc_exec", delta: "ut\":\"const apps = await sky.list_apps();\\n", + })); + expect(firstDelta).toHaveLength(1); + expect(dataPayload(firstDelta[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.delta", + item_id: "ctc_exec", + delta: "const apps = await sky.list_apps();\n", + }); + const secondDelta = rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, item_id: "fc_exec", delta: "apps.length\"}", + })); + expect(dataPayload(secondDelta[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.delta", + delta: "apps.length", + }); + + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: "{\"input\":\"const apps = await sky.list_apps();\\napps.length\"}", + })); + expect(dataPayload(done[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + item_id: "ctc_exec", + input: "const apps = await sky.list_apps();\napps.length", + }); + + const itemDone = rewrite(frame("response.output_item.done", { + output_index: 0, + item: { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"const apps = await sky.list_apps();\\napps.length\"}", + status: "completed", + }, + })); + expect(dataPayload(itemDone[0]!).item).toMatchObject({ + type: "custom_tool_call", + input: "const apps = await sky.list_apps();\napps.length", + }); + + const completed = rewrite(frame("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [{ + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"apps.length\"}", + status: "completed", + }], + }, + })); + const response = dataPayload(completed[0]!).response as { output: Array> }; + expect(response.output[0]).toMatchObject({ type: "custom_tool_call", name: "exec", input: "apps.length" }); + rewrite.dispose?.(); + }); + + test("handleResponses sends an upstream-safe exec function and restores client SSE", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"const apps = await sky.list_apps();\"}", + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...upstreamItem, arguments: "", status: "in_progress" } }), + frame("response.function_call_arguments.done", { output_index: 0, item_id: "fc_exec", arguments: upstreamItem.arguments }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [upstreamItem] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "list apps" }] }], + tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }], + }), + }), config, { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "exec" }); + expect(clientSse).toContain('"type":"custom_tool_call"'); + expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); + expect(clientSse).toContain('"input":"const apps = await sky.list_apps();"'); + expect(clientSse).not.toContain("response.function_call_arguments.done"); + expect(clientSse).toContain("data: [DONE]"); + } finally { + globalThis.fetch = savedFetch; + } + }); +}); From f61ed63b8d3f5c7817d5c50363796e742be297e9 Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Mon, 10 Aug 2026 12:48:24 +0800 Subject: [PATCH 20/23] fix(responses): address streaming review edge cases --- .../content/docs/guides/codex-integration.md | 2 +- .../docs/ja/guides/codex-integration.md | 19 +++++++++ .../docs/ko/guides/codex-integration.md | 19 +++++++++ .../docs/ru/guides/codex-integration.md | 21 ++++++++++ .../docs/zh-cn/guides/codex-integration.md | 15 +++++++ src/providers/registry.ts | 5 ++- src/server/responses-custom-tool-repair.ts | 2 + tests/deepseek-inbound-wire.test.ts | 19 ++++++++- tests/responses-custom-tool-repair.test.ts | 29 ++++++++++++++ tests/responses-terminal-repair.test.ts | 40 ++++++++++++++++--- 10 files changed, 162 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index b3b6b473a..2c243a3fd 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -195,7 +195,7 @@ max | ultra` ladder; unsupported values are mapped or clamped before the upstrea ### Routed local tools -Non-OpenAI catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose its official +Non-native routed catalog rows use `tool_mode: "code_mode_only"`. This lets Codex expose its official `exec` entrypoint and nested MCP tools, including Browser and Computer Use, while opencodex routes only the model's ordinary function call. Tool execution, permissions, and confirmations remain local to Codex; opencodex does not implement a second browser or desktop-control executor. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 9a2c8c694..d4d2bfa20 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -134,6 +134,25 @@ Codex には、ディスク上のカタログ (デフォルトでは `$CODEX_HOM プロバイダーとモデルメタデータに応じて Codex の `low | medium | high | xhigh | max | ultra` 段階を使い、 上流がサポートしない値はリクエスト送信前にマッピングまたはサポート範囲に下げます。 +### ルーティングされたローカルツール + +ネイティブではないルーティング済みカタログ項目は `tool_mode: "code_mode_only"` を使用します。これにより、 +Codex は公式の `exec` エントリポイントと、Browser や Computer Use を含むネストされた MCP ツールを公開できます。 +opencodex がルーティングするのはモデルの通常の function call だけです。ツールの実行、権限、確認は Codex 内に +残り、opencodex が別のブラウザーやデスクトップ操作 executor を実装することはありません。 + +Codex の `exec` custom-tool grammar を受け付けない key-auth Responses provider に対しては、opencodex が宣言と +履歴を上流向けの function tool にエンコードし、ストリーミングされた function-call lifecycle を Codex に返す前に +`custom_tool_call` へ復元します。ネイティブ OpenAI の forward routing と、対応済みの `apply_patch` custom tool は +変更されません。 + +選択した provider は function/tool calling をサポートしている必要があります。tool call に対応しない text-only +provider では `exec`、Browser、Computer Use は使用できません。ネイティブ OpenAI の項目は上流の tool mode を +そのまま維持します。 + +`ocx sync` でこの metadata を変更した後は Codex App を再起動し、新しいタスクを開いてください。既存の +app-server process とタスクは、起動時に読み込んだ catalog と tool plan を保持している場合があります。 + ### カスタムモデルの表示名 カスタム モデルは、モデルのルーティング方法を何も変更することなく、Codex がモデル ピッカーに表示するラベルをオーバーライドする人間が判読できる **表示名** を付けることができます。表示名はカタログ エントリの `display_name` フィールドのみにマップされます。ルーティング スラグ (`/`)、エイリアスの衝突順序、プロバイダー、およびネイティブ OpenAI マーケティング名はすべて変更されません。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 46feaebc3..002ba14e0 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -124,6 +124,25 @@ Codex는 디스크의 카탈로그(`$CODEX_HOME/opencodex-catalog.json`이 기 프로바이더와 모델 메타데이터에 따라 Codex의 `low | medium | high | xhigh | max | ultra` 단계를 사용하며, 업스트림이 지원하지 않는 값은 요청을 보내기 전에 매핑하거나 지원 범위로 낮춥니다. +### 라우팅된 로컬 도구 + +네이티브가 아닌 라우팅 catalog 항목은 `tool_mode: "code_mode_only"`를 사용합니다. 이를 통해 Codex는 공식 +`exec` 진입점과 Browser 및 Computer Use를 포함한 중첩 MCP 도구를 노출할 수 있으며, opencodex는 모델의 일반 +function call만 라우팅합니다. 도구 실행, 권한, 확인은 Codex에 그대로 남고 opencodex가 별도의 browser 또는 +desktop-control executor를 구현하지는 않습니다. + +Codex의 `exec` custom-tool grammar를 허용하지 않는 key-auth Responses provider의 경우, opencodex는 해당 선언과 +history를 업스트림 function tool로 인코딩한 다음 스트리밍된 function-call lifecycle을 Codex에 전달하기 전에 +`custom_tool_call`로 복원합니다. 네이티브 OpenAI forward routing과 지원되는 `apply_patch` custom tool은 변경되지 +않습니다. + +선택한 provider는 function/tool calling을 지원해야 합니다. tool call을 지원하지 않는 text-only provider에서는 +`exec`, Browser 또는 Computer Use를 사용할 수 없습니다. 네이티브 OpenAI 항목은 업스트림 tool mode를 그대로 +유지합니다. + +`ocx sync`가 이 metadata를 변경한 뒤에는 Codex App을 다시 시작하고 새 task를 여세요. 기존 app-server process와 +task는 시작할 때 불러온 catalog와 tool plan을 계속 유지할 수 있습니다. + ### 사용자 지정 모델 표시 이름 사용자 지정 모델은 사람이 읽을 수 있는 **표시 이름**을 가질 수 있습니다. 이 이름은 Codex의 model picker에 보이는 label만 바꾸고, 모델이 라우팅되는 방식은 바꾸지 않습니다. 표시 이름은 catalog entry의 `display_name` 필드에만 매핑되며, routing slug(`/`), alias collision order, provider, native OpenAI marketing name은 모두 그대로 둡니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 394c66dd3..3c89ef2d0 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -204,6 +204,27 @@ Codex показывает модели из каталога на диске (` по шкале Codex `low | medium | high | xhigh | max | ultra`; неподдерживаемые значения сопоставляются или ограничиваются перед запросом к вышестоящему провайдеру. +### Локальные инструменты для маршрутизируемых моделей + +Маршрутизируемые записи, которые не являются нативными, используют +`tool_mode: "code_mode_only"`. Благодаря этому Codex предоставляет официальный entrypoint `exec` +и вложенные MCP-инструменты, включая Browser и Computer Use, а opencodex маршрутизирует только +обычный function call модели. Выполнение инструментов, разрешения и подтверждения остаются в +Codex; opencodex не реализует второй executor для браузера или управления рабочим столом. + +Для key-auth Responses provider'ов, которые не принимают custom-tool grammar `exec` от Codex, +opencodex кодирует объявление и историю как function tool для upstream, а затем восстанавливает +потоковый lifecycle function call в `custom_tool_call` до передачи в Codex. Нативная forward- +маршрутизация OpenAI и поддерживаемый custom tool `apply_patch` остаются без изменений. + +Выбранный provider должен поддерживать function/tool calling. Text-only provider без tool calls +не может использовать `exec`, Browser или Computer Use. Нативные записи OpenAI сохраняют свой +upstream tool mode без изменений. + +После того как `ocx sync` изменит эти metadata, перезапустите Codex App и откройте новую задачу. +Существующие процессы app-server и задачи могут сохранять catalog и tool plan, загруженные при +запуске. + ### Пользовательские display-name моделей У custom-модели может быть человекочитаемый **display name**, который переопределяет метку в diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index f77683d1c..76b785dcf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -181,6 +181,21 @@ Codex 显示的模型来自一个磁盘上的 catalog(默认是 `$CODEX_HOME/o 使用 Codex 的 `low | medium | high | xhigh | max | ultra` 档位;上游不支持的值会在发送请求前完成 映射或下调。 +### 路由模型的本地工具 + +非原生的路由 catalog 条目使用 `tool_mode: "code_mode_only"`。这样 Codex 可以公开其官方 `exec` 入口以及 +嵌套的 MCP 工具,包括 Browser 和 Computer Use;opencodex 只负责路由模型发起的普通 function call。 +工具执行、权限和确认仍由 Codex 在本地处理;opencodex 不会实现另一套浏览器或桌面控制 executor。 + +对于不接受 Codex `exec` custom-tool grammar 的 key-auth Responses provider,opencodex 会把该工具声明及其 +历史记录编码成上游 function tool,再在 Codex 收到结果前,把流式 function-call lifecycle 还原成 +`custom_tool_call`。原生 OpenAI forward routing 和已支持的 `apply_patch` custom tool 保持不变。 + +所选 provider 必须支持 function/tool calling。不支持 tool call 的 text-only provider 无法使用 `exec`、 +Browser 或 Computer Use。原生 OpenAI 条目会保持其上游 tool mode 不变。 + +`ocx sync` 修改这些 metadata 后,请重启 Codex App 并打开一个新任务。现有 app-server process 和任务可能仍会 +保留它们在启动时加载的 catalog 和 tool plan。 ### 自定义模型显示名 diff --git a/src/providers/registry.ts b/src/providers/registry.ts index da645dfdd..4950c92b1 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2399,8 +2399,9 @@ export function providerModelResponsesTerminalRepair( const entry = getProviderRegistryEntry(id); if (!entry?.modelResponsesTerminalRepair || !providerMatchesRegistryTransport(id, provider)) return undefined; const policy = entry.modelResponsesTerminalRepair[modelId.trim().toLowerCase()]; - if (!policy || !Number.isFinite(policy.graceMs) || policy.graceMs <= 0) return undefined; - return { graceMs: Math.floor(policy.graceMs) }; + const graceMs = Math.floor(policy?.graceMs ?? 0); + if (!Number.isFinite(graceMs) || graceMs <= 0) return undefined; + return { graceMs }; } /** diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 830711752..1a30d38bf 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -33,6 +33,8 @@ function partialCustomToolInput(argumentsText: string): string { if (escaped === "n") output += "\n"; else if (escaped === "t") output += "\t"; else if (escaped === "r") output += "\r"; + else if (escaped === "b") output += "\b"; + else if (escaped === "f") output += "\f"; else if (escaped === "u") { const hex = body.slice(index + 1, index + 5); if (hex.length !== 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) break; diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index 3c6c4751a..301edbae1 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -46,6 +46,7 @@ class ManualTerminalScheduler implements ResponsesTerminalRepairScheduler { return id; } cancel(handle: unknown): void { this.jobs.delete(handle as number); } + pending(): number { return this.jobs.size; } advance(ms: number): void { this.current += ms; for (const [id, job] of [...this.jobs.entries()]) { @@ -143,6 +144,19 @@ describe("DeepSeek wire selection is scoped to the inbound protocol", () => { expect(providerModelResponsesTerminalRepair("deepseek", provider, "deepseek-chat")).toBeUndefined(); expect(providerModelResponsesTerminalRepair("custom-deepseek", provider, MODEL)).toBeUndefined(); }); + + test("terminal repair rejects a fractional grace that normalizes to zero", () => { + const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === "deepseek"); + const policy = entry?.modelResponsesTerminalRepair?.[MODEL]; + if (!policy) throw new Error("missing DeepSeek terminal-repair fixture"); + const originalGraceMs = policy.graceMs; + try { + policy.graceMs = 0.5; + expect(providerModelResponsesTerminalRepair("deepseek", deepseekProvider(), MODEL)).toBeUndefined(); + } finally { + policy.graceMs = originalGraceMs; + } + }); }); describe("the inbound scope survives the handleResponses replay", () => { @@ -269,7 +283,10 @@ describe("the inbound scope survives the handleResponses replay", () => { sequence_number: 6, }), ].join("")); - await Bun.sleep(0); + for (let attempts = 0; attempts < 20 && scheduler.pending() === 0; attempts += 1) { + await Bun.sleep(0); + } + expect(scheduler.pending()).toBe(1); scheduler.advance(5_000); const remainder = await Promise.race([ drainReader(reader), diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 3e12a789a..53cbb0f54 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -167,6 +167,35 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("keeps progressive exec input consistent for escaped control characters", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_exec", call_id: "call_exec", name: "exec", arguments: "", status: "in_progress" }, + })); + + const fragments = ['{"inp', 'ut":"before\\', 'b\\fafter"}']; + let streamedInput = ""; + for (const delta of fragments) { + const blocks = rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_exec", + delta, + })); + for (const block of blocks) streamedInput += String(dataPayload(block).delta ?? ""); + } + + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: fragments.join(""), + })); + const doneInput = dataPayload(done[0]!).input; + expect(streamedInput).toBe(doneInput); + expect(streamedInput).toBe("before\b\fafter"); + rewrite.dispose?.(); + }); + test("handleResponses sends an upstream-safe exec function and restores client SSE", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; diff --git a/tests/responses-terminal-repair.test.ts b/tests/responses-terminal-repair.test.ts index 22feb27ec..f8e2328ac 100644 --- a/tests/responses-terminal-repair.test.ts +++ b/tests/responses-terminal-repair.test.ts @@ -42,6 +42,16 @@ class ManualScheduler implements ResponsesTerminalRepairScheduler { } } + takeDue(ms: number): () => void { + this.current += ms; + const due = [...this.jobs.entries()] + .filter(([, job]) => job.at <= this.current) + .sort((left, right) => left[1].at - right[1].at)[0]; + if (!due) throw new Error("no due scheduler job"); + this.jobs.delete(due[0]); + return due[1].callback; + } + pending(): number { return this.jobs.size; } } @@ -87,6 +97,19 @@ async function readAll(stream: ReadableStream): Promise { } } +async function readUntil( + reader: ReadableStreamDefaultReader, + pattern: string, +): Promise { + let out = ""; + while (!out.includes(pattern)) { + const { done, value } = await reader.read(); + if (done) throw new Error(`stream closed before ${pattern}`); + out += decoder.decode(value, { stream: true }); + } + return out; +} + async function settle(): Promise { await Promise.resolve(); await Bun.sleep(0); @@ -405,9 +428,12 @@ describe("DeepSeek Responses terminal repair", () => { )); source.push(completedMessageLifecycle()); await settle(); + expect(scheduler.pending()).toBe(1); + const dueCallback = scheduler.takeDue(5_000); source.push(sse({ type: "response.completed", response: { id: "resp_message", status: "completed" }, sequence_number: 3 })); source.close(); - scheduler.advance(5_000); + await settle(); + dueCallback(); const output = await outputPromise; expect(terminalTypes(output)).toEqual(["response.completed"]); expect(scheduler.pending()).toBe(0); @@ -447,9 +473,11 @@ describe("DeepSeek Responses terminal repair", () => { createTestTranslatorBudget(), cancelScheduler, ); + const cancelReader = cancelled.getReader(); cancelSource.push(completedMessageLifecycle()); - await settle(); - await cancelled.cancel("client gone"); + expect(await readUntil(cancelReader, "response.output_item.done")).toContain("response.output_item.done"); + expect(cancelScheduler.pending()).toBe(1); + await cancelReader.cancel("client gone"); cancelScheduler.advance(5_000); expect(cancelSource.cancelled()).toBe(true); expect(cancelScheduler.pending()).toBe(0); @@ -464,13 +492,15 @@ describe("DeepSeek Responses terminal repair", () => { createTestTranslatorBudget(), abortScheduler, ); + const abortReader = aborted.getReader(); abortSource.push(completedMessageLifecycle()); - await settle(); + expect(await readUntil(abortReader, "response.output_item.done")).toContain("response.output_item.done"); + expect(abortScheduler.pending()).toBe(1); abortUpstream.abort("shutdown"); await settle(); expect(abortSource.cancelled()).toBe(true); expect(abortScheduler.pending()).toBe(0); - await aborted.cancel("test cleanup"); + await abortReader.cancel("test cleanup"); }); test("retained-state overflow throws translation_buffer_limit and releases budget", async () => { From d20aae4123ba4d2e67a8025f3bd7643e8506be1a Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Mon, 10 Aug 2026 17:03:35 +0800 Subject: [PATCH 21/23] fix(responses): harden malformed stream repair --- src/server/responses-custom-tool-repair.ts | 84 ++++++++- src/server/responses-terminal-repair.ts | 8 +- tests/responses-custom-tool-repair.test.ts | 190 +++++++++++++++++++++ tests/responses-terminal-repair.test.ts | 13 ++ 4 files changed, 285 insertions(+), 10 deletions(-) diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index 1a30d38bf..fa84f5d27 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -65,12 +65,21 @@ type OpenCustomCall = { retainedBytes: number; }; +type PendingArgumentBlock = { + block: string; + itemId?: string; + outputIndex?: number; + retainedBytes: number; +}; + export function createRoutedCustomToolRestoreBlockRewrite( names: ReadonlySet, budget?: TranslatorBudget, ): SseBlockRewrite { const itemNames = new Map(); + const ordinaryItemIds = new Set(); const openCalls = new Map(); + let pendingArguments: PendingArgumentBlock[] = []; let disposed = false; const releaseCall = (itemId: string): void => { @@ -86,7 +95,42 @@ export function createRoutedCustomToolRestoreBlockRewrite( if (disposed) return; disposed = true; for (const itemId of openCalls.keys()) releaseCall(itemId); + const pendingBytes = pendingArguments.reduce((total, pending) => total + pending.retainedBytes, 0); + if (pendingBytes > 0) { + budget?.releaseRetained(pendingBytes, { kind: "retained_collectors" }); + } + pendingArguments = []; itemNames.clear(); + ordinaryItemIds.clear(); + }; + + const retainPendingArgument = ( + block: string, + itemId: string | undefined, + outputIndex: number | undefined, + ): void => { + const retainedBytes = Buffer.byteLength(block, "utf8"); + if (retainedBytes > 0) budget?.chargeRetained(retainedBytes, { kind: "retained_collectors" }); + pendingArguments.push({ block, itemId, outputIndex, retainedBytes }); + }; + + const takePendingArguments = ( + itemId: string | undefined, + outputIndex: number | undefined, + ): string[] => { + const matched: PendingArgumentBlock[] = []; + const remaining: PendingArgumentBlock[] = []; + for (const pending of pendingArguments) { + const sameItem = itemId !== undefined && pending.itemId === itemId; + const sameIndex = outputIndex !== undefined && pending.outputIndex === outputIndex; + (sameItem || sameIndex ? matched : remaining).push(pending); + } + pendingArguments = remaining; + const retainedBytes = matched.reduce((total, pending) => total + pending.retainedBytes, 0); + if (retainedBytes > 0) { + budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" }); + } + return matched.map(pending => pending.block); }; const rewrite: SseBlockRewrite = (block: string): readonly string[] => { @@ -101,28 +145,56 @@ export function createRoutedCustomToolRestoreBlockRewrite( if (!isPlainObject(parsed)) return [block]; const type = typeof parsed.type === "string" ? parsed.type : ""; + const outputIndex = typeof parsed.output_index === "number" + && Number.isInteger(parsed.output_index) + && parsed.output_index >= 0 + ? parsed.output_index + : undefined; if ( (type === "response.output_item.added" || type === "response.output_item.done") && isPlainObject(parsed.item) && parsed.item.type === "function_call" && typeof parsed.item.name === "string" - && names.has(parsed.item.name) ) { const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; + const routed = names.has(parsed.item.name); if (upstreamItemId) { - itemNames.set(upstreamItemId, parsed.item.name); - if (type === "response.output_item.added") { + if (routed) { + itemNames.set(upstreamItemId, parsed.item.name); + ordinaryItemIds.delete(upstreamItemId); + } else { + ordinaryItemIds.add(upstreamItemId); + } + if (routed && type === "response.output_item.added") { openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); } } + const pending = takePendingArguments(upstreamItemId, outputIndex); + if (!routed) { + if (type === "response.output_item.done" && upstreamItemId) ordinaryItemIds.delete(upstreamItemId); + return [...pending, block]; + } + if (upstreamItemId && pending.length > 0 && !openCalls.has(upstreamItemId)) { + openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 }); + } const restored = restoreRoutedCustomCalls(parsed, names); + const restoredBlock = restored.changed + ? replaceSseDataPayload(block, JSON.stringify(restored.value)) + : block; + const replayed = pending.flatMap(pendingBlock => rewrite(pendingBlock)); if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId); - return restored.changed - ? [replaceSseDataPayload(block, JSON.stringify(restored.value))] - : [block]; + return type === "response.output_item.added" + ? [restoredBlock, ...replayed] + : [...replayed, restoredBlock]; } const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined; + const argumentEvent = type === "response.function_call_arguments.delta" + || type === "response.function_call_arguments.done"; + if (argumentEvent && (!upstreamItemId || (!itemNames.has(upstreamItemId) && !ordinaryItemIds.has(upstreamItemId)))) { + retainPendingArgument(block, upstreamItemId, outputIndex); + return []; + } if ( type === "response.function_call_arguments.delta" && upstreamItemId diff --git a/src/server/responses-terminal-repair.ts b/src/server/responses-terminal-repair.ts index 4d6749a91..7d35141a8 100644 --- a/src/server/responses-terminal-repair.ts +++ b/src/server/responses-terminal-repair.ts @@ -292,10 +292,10 @@ export function relayResponsesSseWithTerminalRepair( if (done) { appendBuffer(decoder.decode()); if (buffer.length > 0) { - const kind = inspectPayload(sseDataPayload(buffer)); - if (kind === "done" && !realTerminalSeen) { - emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller); - } + // A delimiter-less suffix is not a complete SSE event. Preserve the + // upstream bytes for passthrough compatibility, but never let a + // truncated lifecycle frame establish synthetic success. + tainted = true; controller.enqueue(encoder.encode(buffer)); } if (!realTerminalSeen) { diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 53cbb0f54..6575d1fe7 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -7,6 +7,7 @@ import { import { createRoutedCustomToolRestoreBlockRewrite } from "../src/server/responses-custom-tool-repair"; import { handleResponses } from "../src/server/responses"; import type { OcxConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; function dataPayload(block: string): Record { const line = block.split(/\r?\n/).find(entry => entry.startsWith("data:")); @@ -167,6 +168,90 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("buffers argument events until a missing added event is identified by item done", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); + const deltaBlock = frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_exec", + delta: "{\"input\":\"echo", + }); + const argumentsDoneBlock = frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: "{\"input\":\"echo ok\"}", + }); + + expect(rewrite(deltaBlock)).toEqual([]); + expect(rewrite(argumentsDoneBlock)).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + + const replayed = rewrite(frame("response.output_item.done", { + output_index: 0, + item: { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"echo ok\"}", + status: "completed", + }, + })); + expect(replayed.map(block => dataPayload(block).type)).toEqual([ + "response.custom_tool_call_input.delta", + "response.custom_tool_call_input.done", + "response.output_item.done", + ]); + expect(dataPayload(replayed[0]!)).toMatchObject({ item_id: "ctc_exec", delta: "echo" }); + expect(dataPayload(replayed[1]!)).toMatchObject({ item_id: "ctc_exec", input: "echo ok" }); + expect(dataPayload(replayed[2]!).item).toMatchObject({ + type: "custom_tool_call", + id: "ctc_exec", + name: "exec", + input: "echo ok", + }); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + }); + + test("replays buffered events unchanged when item done identifies an ordinary function", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); + const deltaBlock = frame("response.function_call_arguments.delta", { + output_index: 1, + item_id: "fc_other", + delta: "{}", + }); + + expect(rewrite(deltaBlock)).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + + const replayed = rewrite(frame("response.output_item.done", { + output_index: 1, + item: { + type: "function_call", + id: "fc_other", + call_id: "call_other", + name: "ordinary", + arguments: "{}", + status: "completed", + }, + })); + expect(replayed).toEqual([deltaBlock, frame("response.output_item.done", { + output_index: 1, + item: { + type: "function_call", + id: "fc_other", + call_id: "call_other", + name: "ordinary", + arguments: "{}", + status: "completed", + }, + })]); + expect(budget.snapshot().currentBytes).toBe(0); + rewrite.dispose?.(); + }); + test("keeps progressive exec input consistent for escaped control characters", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); rewrite(frame("response.output_item.added", { @@ -250,9 +335,114 @@ describe("routed Responses custom-tool compatibility", () => { expect(clientSse).toContain('"type":"response.custom_tool_call_input.done"'); expect(clientSse).toContain('"input":"const apps = await sky.list_apps();"'); expect(clientSse).not.toContain("response.function_call_arguments.done"); + expect(clientSse).not.toContain('"type":"function_call"'); expect(clientSse).toContain("data: [DONE]"); } finally { globalThis.fetch = savedFetch; } }); + + test("handleResponses restores routed custom calls in non-streaming JSON", async () => { + const savedFetch = globalThis.fetch; + const upstreamItem = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"const apps = await sky.list_apps();\"}", + status: "completed", + }; + globalThis.fetch = (async () => new Response(JSON.stringify({ + id: "resp_json", + status: "completed", + output: [upstreamItem], + }), { headers: { "content-type": "application/json" } })) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "list apps" }] }], + tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }], + }), + }), config, { model: "", provider: "" }); + const body = await response.json() as { output: Array> }; + + expect(body.output[0]).toMatchObject({ + type: "custom_tool_call", + id: "ctc_exec", + name: "exec", + input: "const apps = await sky.list_apps();", + }); + expect(body.output[0]).not.toHaveProperty("arguments"); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses leaves custom tools native for forward-auth passthrough", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"native\"}", + status: "completed", + }; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ id: "resp_forward", status: "completed", output: [upstreamItem] }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://forward.fixture.test", + authMode: "forward", + }, + }, + } as OcxConfig; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer caller-token" }, + body: JSON.stringify({ + model: "fixture/native-model", + stream: false, + input: "run", + tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }], + }), + }), config, { model: "", provider: "" }); + const clientBody = await response.json() as { output: Array> }; + const outboundTools = outboundBody?.tools as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "custom", name: "exec" }); + expect(clientBody.output[0]).toMatchObject({ type: "function_call", name: "exec" }); + expect(clientBody.output[0]).not.toHaveProperty("input"); + } finally { + globalThis.fetch = savedFetch; + } + }); }); diff --git a/tests/responses-terminal-repair.test.ts b/tests/responses-terminal-repair.test.ts index f8e2328ac..21457962b 100644 --- a/tests/responses-terminal-repair.test.ts +++ b/tests/responses-terminal-repair.test.ts @@ -337,6 +337,19 @@ describe("DeepSeek Responses terminal repair", () => { expect(output.endsWith("data: [DONE]\n\n")).toBe(true); }); + test("an unframed output item done suffix taints EOF completion", async () => { + const framed = completedMessageLifecycle(); + const finalDelimiter = framed.lastIndexOf("\n\n"); + expect(finalDelimiter).toBeGreaterThan(0); + const input = framed.slice(0, finalDelimiter); + + const { output } = await repairClosedText(input); + + expect(output).toContain('"type":"response.output_item.done"'); + expect(terminalTypes(output)).toEqual(["response.incomplete"]); + expect(output).not.toContain('"type":"response.completed"'); + }); + test("DONE is replaced by completed then one DONE only for a complete candidate", async () => { const { output } = await repairClosedText(completedMessageLifecycle() + "data: [DONE]\n\n"); expect(terminalTypes(output)).toEqual(["response.completed"]); From b9b723c46c05477df0236563c1898e1a665d460f Mon Sep 17 00:00:00 2001 From: baileyh8 Date: Mon, 10 Aug 2026 17:33:24 +0800 Subject: [PATCH 22/23] fix(responses): guard late custom tool frames --- src/server/responses-custom-tool-repair.ts | 8 ++- tests/responses-custom-tool-repair.test.ts | 65 ++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index fa84f5d27..d3b96f0fc 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -121,9 +121,10 @@ export function createRoutedCustomToolRestoreBlockRewrite( const matched: PendingArgumentBlock[] = []; const remaining: PendingArgumentBlock[] = []; for (const pending of pendingArguments) { - const sameItem = itemId !== undefined && pending.itemId === itemId; - const sameIndex = outputIndex !== undefined && pending.outputIndex === outputIndex; - (sameItem || sameIndex ? matched : remaining).push(pending); + const matches = pending.itemId !== undefined + ? itemId !== undefined && pending.itemId === itemId + : outputIndex !== undefined && pending.outputIndex === outputIndex; + (matches ? matched : remaining).push(pending); } pendingArguments = remaining; const retainedBytes = matched.reduce((total, pending) => total + pending.retainedBytes, 0); @@ -134,6 +135,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( }; const rewrite: SseBlockRewrite = (block: string): readonly string[] => { + if (disposed) return [block]; const payload = sseDataPayload(block); if (payload === null || payload === "[DONE]") return [block]; let parsed: unknown; diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 6575d1fe7..9d4b6df89 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -214,6 +214,68 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("does not match a known pending item id by output index alone", () => { + const budget = createTestTranslatorBudget(); + const chargeRetained = budget.chargeRetained.bind(budget); + const releaseRetained = budget.releaseRetained.bind(budget); + let charges = 0; + let releases = 0; + budget.chargeRetained = (...args) => { + charges += 1; + chargeRetained(...args); + }; + budget.releaseRetained = (...args) => { + releases += 1; + releaseRetained(...args); + }; + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_a", + delta: "{\"input\":\"a", + }))).toEqual([]); + expect(charges).toBe(1); + + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_b", + call_id: "call_b", + name: "exec", + arguments: "", + status: "in_progress", + }, + })); + expect(added.map(block => dataPayload(block).type)).toEqual(["response.output_item.added"]); + expect(JSON.stringify(added)).not.toContain('"item_id":"ctc_b"'); + expect(charges).toBe(1); + expect(releases).toBe(0); + + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("does not retain argument events that arrive after a terminal event", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); + + rewrite(frame("response.completed", { + response: { id: "resp_1", status: "completed", output: [] }, + })); + expect(budget.snapshot().currentBytes).toBe(0); + + rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_late", + delta: "{\"input\":\"late", + })); + rewrite.dispose?.(); + + expect(budget.snapshot().currentBytes).toBe(0); + }); + test("replays buffered events unchanged when item done identifies an ordinary function", () => { const budget = createTestTranslatorBudget(); const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); @@ -398,6 +460,7 @@ describe("routed Responses custom-tool compatibility", () => { test("handleResponses leaves custom tools native for forward-auth passthrough", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; + let outboundAuthorization: string | null = null; const upstreamItem = { type: "function_call", id: "fc_exec", @@ -408,6 +471,7 @@ describe("routed Responses custom-tool compatibility", () => { }; globalThis.fetch = (async (_input, init) => { outboundBody = JSON.parse(String(init?.body)) as Record; + outboundAuthorization = new Headers(init?.headers).get("authorization"); return new Response(JSON.stringify({ id: "resp_forward", status: "completed", output: [upstreamItem] }), { headers: { "content-type": "application/json" }, }); @@ -438,6 +502,7 @@ describe("routed Responses custom-tool compatibility", () => { const clientBody = await response.json() as { output: Array> }; const outboundTools = outboundBody?.tools as Array> | undefined; + expect(outboundAuthorization).toBe("Bearer caller-token"); expect(outboundTools?.[0]).toMatchObject({ type: "custom", name: "exec" }); expect(clientBody.output[0]).toMatchObject({ type: "function_call", name: "exec" }); expect(clientBody.output[0]).not.toHaveProperty("input"); From da36a30aaa7d986d9e929e820c57ad16e799ec0f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:20:04 +0200 Subject: [PATCH 23/23] fix(responses): harden routed custom-tool progressive repair Tolerate whitespace in freeform argument wrappers, stamp resolved item ids onto index-matched pending replays, and lock split unicode escapes plus continuation call_id pairing with focused regressions. --- src/server/responses-custom-tool-repair.ts | 33 ++- tests/responses-custom-tool-repair.test.ts | 245 +++++++++++++++++++++ 2 files changed, 274 insertions(+), 4 deletions(-) diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index d3b96f0fc..c3c40b134 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -10,15 +10,24 @@ import { type SseBlockRewrite, } from "./sse-payload-rewrite"; +/** Exact compact prefix used by our upstream rewriter; progressive matching also + * tolerates insignificant JSON whitespace via FREEFORM_WRAP_PREFIX_RE. */ const FREEFORM_WRAP_PREFIX = '{"input":"'; +const FREEFORM_WRAP_PREFIX_RE = /^\s*\{\s*"input"\s*:\s*"/; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function partialCustomToolInput(argumentsText: string): string { - if (!argumentsText.startsWith(FREEFORM_WRAP_PREFIX)) return argumentsText; - const body = argumentsText.slice(FREEFORM_WRAP_PREFIX.length); +/** + * Progressive decode of the freeform `{ "input": "…" }` wrapper. + * Returns null when the accumulated text does not (yet) match that wrapper so + * callers suppress deltas and rely on `response.custom_tool_call_input.done`. + */ +function partialCustomToolInput(argumentsText: string): string | null { + const match = FREEFORM_WRAP_PREFIX_RE.exec(argumentsText); + if (!match) return null; + const body = argumentsText.slice(match[0]!.length); let output = ""; for (let index = 0; index < body.length; index++) { const char = body[index]; @@ -131,7 +140,20 @@ export function createRoutedCustomToolRestoreBlockRewrite( if (retainedBytes > 0) { budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" }); } - return matched.map(pending => pending.block); + // Index-matched entries carry no item id. Stamp the resolved id so replay + // classifies the event instead of buffering it a second time. + return matched.map(pending => { + if (pending.itemId !== undefined || itemId === undefined) return pending.block; + const payload = sseDataPayload(pending.block); + if (payload === null) return pending.block; + try { + const parsed: unknown = JSON.parse(payload); + if (!isPlainObject(parsed)) return pending.block; + return replaceSseDataPayload(pending.block, JSON.stringify({ ...parsed, item_id: itemId })); + } catch { + return pending.block; + } + }); }; const rewrite: SseBlockRewrite = (block: string): readonly string[] => { @@ -209,8 +231,11 @@ export function createRoutedCustomToolRestoreBlockRewrite( open.argumentsText += delta; open.retainedBytes += deltaBytes; openCalls.set(upstreamItemId, open); + // Still accumulating toward the compact wrapper, or an unrecognized shape: + // suppress progressive emission and let the done event carry input. if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return []; const fullInput = partialCustomToolInput(open.argumentsText); + if (fullInput === null) return []; if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return []; const inputDelta = fullInput.slice(open.emittedInput.length); open.emittedInput = fullInput; diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index 9d4b6df89..843bec4a2 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -314,6 +314,49 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("replays id-less argument deltas once output_item.added resolves the routed item", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + delta: "{\"input\":\"echo", + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + + const replayed = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "", + status: "in_progress", + }, + })); + expect(replayed.map(block => dataPayload(block).type)).toEqual([ + "response.output_item.added", + "response.custom_tool_call_input.delta", + ]); + expect(dataPayload(replayed[0]!).item).toMatchObject({ type: "custom_tool_call", id: "ctc_exec", name: "exec" }); + expect(dataPayload(replayed[1]!)).toMatchObject({ item_id: "ctc_exec", delta: "echo" }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: "{\"input\":\"echo ok\"}", + })); + expect(dataPayload(done[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + item_id: "ctc_exec", + input: "echo ok", + }); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + test("keeps progressive exec input consistent for escaped control characters", () => { const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); rewrite(frame("response.output_item.added", { @@ -343,6 +386,87 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("keeps progressive exec input consistent for spaced freeform wrappers", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_exec", call_id: "call_exec", name: "exec", arguments: "", status: "in_progress" }, + })); + + const fragments = ['{ "input": "', 'spaced"}']; + let streamedInput = ""; + for (const delta of fragments) { + const blocks = rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_exec", + delta, + })); + for (const block of blocks) streamedInput += String(dataPayload(block).delta ?? ""); + } + + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: fragments.join(""), + })); + expect(streamedInput).toBe(dataPayload(done[0]!).input); + expect(streamedInput).toBe("spaced"); + rewrite.dispose?.(); + }); + + test("suppresses progressive deltas for unrecognized argument shapes until done", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_exec", call_id: "call_exec", name: "exec", arguments: "", status: "in_progress" }, + })); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_exec", + delta: '{"other":"x"', + }))).toEqual([]); + + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: '{"input":"authoritative"}', + })); + expect(dataPayload(done[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + input: "authoritative", + }); + rewrite.dispose?.(); + }); + + test("keeps progressive exec input consistent for split unicode escapes", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"])); + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_exec", call_id: "call_exec", name: "exec", arguments: "", status: "in_progress" }, + })); + + const fragments = ['{"input":"caf\\u00', 'e9 \\u0041"}']; + let streamedInput = ""; + for (const delta of fragments) { + const blocks = rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_exec", + delta, + })); + for (const block of blocks) streamedInput += String(dataPayload(block).delta ?? ""); + } + + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_exec", + arguments: fragments.join(""), + })); + expect(streamedInput).toBe(dataPayload(done[0]!).input); + expect(streamedInput).toBe("café A"); + rewrite.dispose?.(); + }); + test("handleResponses sends an upstream-safe exec function and restores client SSE", async () => { const savedFetch = globalThis.fetch; let outboundBody: Record | undefined; @@ -404,6 +528,127 @@ describe("routed Responses custom-tool compatibility", () => { } }); + test("handleResponses continuation rewrites custom_tool_call_output and keeps call_id ordered", async () => { + const savedFetch = globalThis.fetch; + const outboundBodies: Array> = []; + const firstUpstreamItem = { + type: "function_call", + id: "fc_exec", + call_id: "call_exec", + name: "exec", + arguments: "{\"input\":\"const apps = await sky.list_apps();\"}", + status: "completed", + }; + const secondUpstreamMessage = { + type: "message", + id: "msg_2", + role: "assistant", + content: [{ type: "output_text", text: "27 apps" }], + status: "completed", + }; + let turn = 0; + globalThis.fetch = (async (_input, init) => { + outboundBodies.push(JSON.parse(String(init?.body)) as Record); + turn += 1; + if (turn === 1) { + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...firstUpstreamItem, arguments: "", status: "in_progress" } }), + frame("response.function_call_arguments.done", { output_index: 0, item_id: "fc_exec", arguments: firstUpstreamItem.arguments }), + frame("response.output_item.done", { output_index: 0, item: firstUpstreamItem }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [firstUpstreamItem] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + } + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...secondUpstreamMessage, content: [], status: "in_progress" } }), + frame("response.output_item.done", { output_index: 0, item: secondUpstreamMessage }), + frame("response.completed", { response: { id: "resp_2", status: "completed", output: [secondUpstreamMessage] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const tools = [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }]; + + try { + const first = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "list apps" }] }], + tools, + }), + }), config, { model: "", provider: "" }); + const firstSse = await first.text(); + expect(firstSse).toContain('"type":"custom_tool_call"'); + expect(firstSse).toContain('"call_id":"call_exec"'); + expect(firstSse).not.toContain('"type":"function_call"'); + + const second = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [ + { role: "user", content: [{ type: "input_text", text: "list apps" }] }, + { + type: "custom_tool_call", + id: "ctc_exec", + call_id: "call_exec", + name: "exec", + input: "const apps = await sky.list_apps();", + }, + { type: "custom_tool_call_output", call_id: "call_exec", output: "27 apps" }, + { type: "custom_tool_call_output", call_id: "call_other", output: "wrong pairing must stay distinct" }, + ], + tools, + }), + }), config, { model: "", provider: "" }); + const secondSse = await second.text(); + const continuationInput = outboundBodies[1]?.input as Array>; + expect(outboundBodies).toHaveLength(2); + expect(continuationInput).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "function_call", + call_id: "call_exec", + name: "exec", + arguments: JSON.stringify({ input: "const apps = await sky.list_apps();" }), + }), + expect.objectContaining({ + type: "function_call_output", + call_id: "call_exec", + output: "27 apps", + }), + ])); + const execOutput = continuationInput.find(item => item.type === "function_call_output" && item.call_id === "call_exec"); + const otherOutput = continuationInput.find(item => item.call_id === "call_other"); + expect(execOutput).toMatchObject({ type: "function_call_output", output: "27 apps" }); + expect(otherOutput).toMatchObject({ type: "custom_tool_call_output", call_id: "call_other" }); + expect(continuationInput.filter(item => item.type === "function_call_output")).toHaveLength(1); + expect(secondSse).toContain('"text":"27 apps"'); + expect(secondSse).toContain('"id":"resp_2"'); + expect(secondSse).not.toContain('"type":"function_call"'); + expect(secondSse.indexOf("resp_2")).toBeLessThan(secondSse.indexOf("data: [DONE]")); + } finally { + globalThis.fetch = savedFetch; + } + }); + test("handleResponses restores routed custom calls in non-streaming JSON", async () => { const savedFetch = globalThis.fetch; const upstreamItem = {