feat: streaming support for coding agents (Claude Code & Codex)#586
Merged
Conversation
…g tests The rebase onto main brought in the dashboard metrics added in #547, which are recorded after the response is written. The SSE streaming path returns early, so streamed requests (Claude Code, Codex) were invisible to the dashboard; record them there too, with the request-masking time as the proxy-overhead latency (response restoration is interleaved with the upstream stream). Also adds the test coverage the streaming feature landed without: the SSE engine end-to-end over a wire (chunked framing, header rewrite, PII restore), both provider codecs (placeholder split across deltas, tool-call argument restore, per-channel carry buffers, passthrough events), stream sniffing, Codex host routing, and chatgpt.com interception gating.
shepherd.js is a frontend dependency already declared in src/frontend/package.json; the root entry was added accidentally. Regenerating the lockfile also syncs the recorded src/frontend version with main (1.4.0 -> 1.4.1).
Contributor
|
Warning This PR touches 3+ distinct areas of the codebase. Consider splitting into smaller, focused PRs — each covering a single semantic type. Categories found: docs:
code:
chore:
test:
|
Collaborator
Member
Author
Codex CLI startup and completions both broke when routed through the MITM proxy. Five fixes, verified end-to-end against a live Codex run: - Gate chatgpt.com interception by path: only /backend-api/codex/responses is masked; the streaming MCP transport, model list, and telemetry pass through verbatim via a new in-tunnel passthrough that streams incrementally and preserves framing across keep-alive requests. - Pin relayed responses to HTTP/1.1: upstream legs negotiate HTTP/2, and relaying resp.Proto verbatim produced "HTTP/2.0" status lines that strict clients (Codex's hyper) reject as malformed. - Create the MITM loop's bufio.Reader once per connection so buffered bytes of a pipelined next request are not dropped between iterations. - Sniff SSE when Content-Type is absent: the Codex backend streams SSE with no Content-Type header, which sent responses down the buffered path and skipped PII restoration. - Restore PII in nested .done/.completed Responses-API payloads (content_part.done, output_item.done, response.completed) — Codex renders its final message from these, not from the flat deltas. - Reject WebSocket upgrades on masked endpoints (fail closed): PII in switched protocols cannot be masked. Codex is steered to SSE via a provider config with supports_websockets=false, documented in docs/09-coding-agents.md.
The response-restoration logic replaced each masked (dummy) value with its
original via a sequence of strings.ReplaceAll calls. When the fake-value
generator produced a dummy that coincides with a real original from another
mapping (e.g. "Priya"->"Nicole" alongside "Claude"->"Priya"), the passes
chained: restoring "Nicole"->"Priya" and then re-replacing that "Priya"->
"Claude", so the client received the wrong PII ("Hi Claude" where the model
wrote "Hi Nicole" for Priya). The bug was also map-order dependent.
Replace both restore paths (streaming codecs and the buffered
ResponseProcessor) with a shared single-pass strings.Replacer built by
processor.BuildRestorer, keys ordered longest-first. strings.Replacer scans
the input once and never re-substitutes its own output, so restored text is
never re-replaced. The streaming restoreCore builds the replacer once per
stream instead of iterating the map per delta.
Adds regression tests for the exact collision and longest-match precedence.
Member
Author
Codex sample:codex exec \
--skip-git-repo-check \
-c 'model_provider="kiji"' \
-c 'model_providers.kiji={ name = "Kiji", base_url = "https://chatgpt.com/backend-api/codex", wire_api = "responses", requires_openai_auth = true, supports_websockets = false }' \
"Draft a short reply to David Nettleton at [email protected] confirming the meeting."
OpenAI Codex v0.142.5
--------
workdir: /Users/davidnet/sample
model: gpt-5.4
provider: kiji
approval: never
sandbox: read-only
reasoning effort: medium
reasoning summaries: none
session id: 019f295d-39d1-7971-a168-0b6dd53b08d6
--------
user
Draft a short reply to David Nettleton at [email protected] confirming the meeting.
codex
Subject: Re: Meeting Confirmation
Hi David,
Confirming the meeting works for me. Looking forward to it.
Best,
Jordan
tokens used
8,434
Claude codeset -x HTTP_PROXY http://127.0.0.1:8081
set -x HTTPS_PROXY http://127.0.0.1:8081
set -x NODE_EXTRA_CA_CERTS "$HOME/Library/Application Support/Kiji Privacy Proxy/certs/ca.crt"
$ claude -p --model haiku "Draft a two-sentence email to Priya Raghavan ([email protected]) confirming Tuesday's sync. Sign it from me."
Here's a draft email:
---
**To:** [email protected]
**Subject:** Confirming Tuesday's Sync
Hi Priya,
I wanted to confirm our sync scheduled for Tuesday. Please let me know if you need to reschedule or if there's anything you'd like to go over beforehand.
Best regards,
Yuki Smith
|
Collaborator
|
@Davidnet Great work, but a huge PR. Wanna review it together on Monday? |
Member
Author
Yes, It was also a quite a bit of new things, and for sure |
Davidnet
commented
Jul 6, 2026
| { | ||
| "env": { | ||
| "HTTP_PROXY": "http://127.0.0.1:8081", | ||
| "HTTPS_PROXY": "http://127.0.0.1:8081", |
Davidnet
commented
Jul 6, 2026
| | Variable | Value | Why | | ||
| |----------|-------|-----| | ||
| | `HTTP_PROXY` | `http://127.0.0.1:8081` | Routes Codex's HTTP traffic through the proxy | | ||
| | `HTTPS_PROXY` | `http://127.0.0.1:8081` | Routes Codex's HTTPS traffic (the completions call) through the proxy | |
Member
Author
There was a problem hiding this comment.
HTTPS discussion (ibid.)
Davidnet
commented
Jul 6, 2026
| // until the first bytes arrive, which for a stream is the first event. | ||
| peek, _ := br.Peek(len("event:")) | ||
| s := string(peek) | ||
| for _, prefix := range []string{"event:", "data:", "id:", "retry:", ":"} { |
Member
Author
There was a problem hiding this comment.
Maybe asking chatgpt every few days, and we can see if eventually changes
hanneshapke
approved these changes
Jul 6, 2026
Pin six defects surfaced in review of the streaming SSE path. Each test asserts the correct behavior and currently fails: - carry buffer re-restores already-restored text (chained substitution) in both the OpenAI and Anthropic codecs - OpenAI codec fails open for /v1/chat/completions (chat.completion.chunk) - splitSafe cuts multi-byte UTF-8 runes -> json.Marshal inserts U+FFFD - restored PII spliced unescaped into partial_json tool-arg fragments - codec carry tail dropped when a stream ends without .done/content_block_stop
Fixes the six failing regression tests from 0e9b02d: - Carry buffers now hold RAW (pre-restore) text and restoration runs as a single left-to-right pass (streamRestore) that emits originals exactly once, so a restored original that coincides with another mapping's dummy can no longer be re-substituted across delta boundaries. - Text is only split on rune boundaries; an incomplete trailing UTF-8 rune is held back instead of being emitted truncated (no more U+FFFD). - Only a suffix that is a proper prefix of a dummy is held back, instead of a fixed longest-dummy-1 tail, which also reduces streaming latency. - Tool-call argument fragments (input_json_delta, function_call_arguments, chat tool_calls) restore with JSON-escaped originals so the arguments the client reassembles stay valid JSON; .done arguments fields likewise. - The OpenAI codec now handles /v1/chat/completions chunks (chat.completion.chunk: choices[].delta.content and tool_calls), which previously passed through unrestored. - Codecs expose flushTail(); streamSSEResponse flushes held carry tails at EOF so streams ending without .done/content_block_stop are not truncated. - Tail flushes after *.completed reuse the channel's recorded delta type instead of deriving a bogus response.completed.delta.
golangci-lint (goconst) flagged the literal "delta" and "content" map keys repeated across the SSE codecs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
proxy/streaming.go) with provider-specific codecs for Anthropic (codec_anthropic.go) and OpenAI (codec_openai.go)docs/09-coding-agents.mdguide and README updatesMain work was provided in #532