Skip to content

Releases: xhqing/CC-Bridge

Add Agnes upstream, hybrid multi-provider upstream, and optional upstream proxy; fix continuation thinking-block stripping (CC-side "Content block not found")

Choose a tag to compare

@xhqing xhqing released this 02 Sep 11:19

Added: Agnes upstream (Agnes AI bridge adapter + framework-level upstream proxy support)

Why: Mapping the hybrid upstream's haiku/sonnet roles to agnes-2.5-flash (the free tier) required Agnes to be a registry upstream, but it had previously been used only as a classifier side-channel. Research (official docs at wiki.agnes-ai.com plus local testing, 2026-09-01) confirmed Agnes offers an official Anthropic-compatible endpoint POST /v1/messages (auth via x-api-key + anthropic-version, supporting streaming, tools, and the Anthropic-format thinking field), so it could be added following the lightweight mimo-bridge pattern with no protocol translation needed.

What:

  • New agnes-bridge/ (adapter.js + agnes.env.example): model tables follow the official docs (agnes-2.5-flash: 512K context / 65536 max output, currently free at $0/1M promo pricing; agnes-2.5-pro and pro-beta: 1M / 65536, paid; agnes-2.0-flash and pro-alpha are officially deprecated and excluded). adaptRequestBody only clamps max_tokens (pass-through principle same as MiMo). defaultTarget: agnes-2.5-flash. Registering the row in core/adapter.js makes it automatically usable as a hybrid member (AGNES_BASES / AGNES_API_KEY_n section + MODEL_MAP entries like agnes:agnes-2.5-flash).
  • Framework-level upstream proxy support (UPSTREAM_PROXY, optional config): for overseas upstreams like Agnes that are unreachable or unstable when connected directly, configuring an HTTP proxy routes all outbound requests to upstream endpoints through it. Parsed in core/config.js and shown by config show; core/server.js builds an HttpsProxyAgent (reusing the existing https-proxy-agent dependency) and injects it into both upstream request paths (main forwarding and mid-body continuation), and the startup banner prints an upstream via line; an invalid proxy URL fails at startup instead of silently falling back to a direct connection. Applies to https endpoints only (production endpoints are all https; http endpoints are local-mock scenarios); zero impact when unset, and no automatic fallback to the shell's HTTPS_PROXY (the daemon's environment is not a controlled source, and auto-proxying domestic endpoints through it would be harmful); the classifier channel is unaffected (it already has its own HTTPS_PROXY awareness).
  • Peripheral sync: CLI HELP title line adds Agnes (the upstream list was already generated dynamically); package.json (files adds agnes-bridge, description / keywords); main README in both English and Chinese (implemented-upstreams list, available-upstreams table, thinking-level pass-through section, file table); hybrid README and hybrid.env.example member lists add agnes plus a UPSTREAM_PROXY note (calling out its global nature: once configured, domestic endpoints also go through the proxy); .claude/CLAUDE.md (the AGENTS.md symlink follows) implemented-upstreams list updated.
  • hybrid.env.example default members and mappings switched to GLM + Agnes dual-active (user decision, 2026-09-01): default mappings for claude-haiku-4-5 and claude-sonnet-5 changed from ds flash / glm-4.6 to agnes-2.5-flash (free lightweight roles), the AGNES section uncommented as a default-active member (so copying the template as-is doesn't hit a "provider not configured" error from MODEL_MAP referencing agnes: while the member section is commented out), and DS / MiMo demoted to commented example blocks; the third pair keeps the "omitted prefix auto-qualification" teaching example (claude-sonnet-5->agnes-2.5-flash). The template's active state was validated through the framework's parseEnv + preprocessEnv startup checks (3 endpoints flattened, 3 mappings, keys bound correctly).
  • hybrid.env.example mappings expanded to five pairs with the DS section activated (user decision, 2026-09-02): added claude-opus-4-7->glm:glm-5.3-flash and claude-opus-4-6->ds:deepseek-v4-flash, with the DS section uncommented as a third default-active member (otherwise copying the template fails startup with provider 'ds' not configured) — the default member set is now GLM + Agnes + DeepSeek, with MiMo left as a commented example. Verified: parseEnv + preprocessEnv pass (4 endpoints flattened: glm-zai / glm-cn / agnes-default / ds-default; all 5 mappings qualified; 4 keys bound correctly).

Known limitation (observed in testing, non-blocking): the Agnes gateway's streaming responses emit no message_delta event (only start / block* / stop — confirmed over multiple test rounds on 2026-09-01, similar to z.ai's earlier nonstandard behavior). The framework's modelUsage injection hooks on message_delta, so it misses for this gateway (Claude Code falls back to its built-in table for the window), and streaming output stats record 0 (non-streaming usage is complete and correct). The gateway outputs thinking blocks by default (even when thinking was not requested; Claude Code handles them fine). The planned patch (detect a missing delta in-stream and synthesize one) is tracked as TODO T18.

Verification: tmp/test-agnes.js end-to-end (bridge from dev source, real endpoint via UPSTREAM_PROXY=http://127.0.0.1:1087) passed all 5 checks: non-streaming 200 + model rewrite (claude-opus-4-8 → agnes-2.5-flash, evidenced by the response model field), forced tool calling (tool_use block + stop_reason=tool_use + correct arguments), streaming SSE event sequence (thinking + text blocks), and the message_delta-absent shape (known-limitation path recorded as expected); tmp/test-agnes-hybrid.js configuration-layer integration passed all 9 checks (AGNES section discovery and flattening, <provider>- endpoint prefixing, cross-section key renumbering and binding, qualified-target qualification, merged window table with qualified keys, routeKeys narrowed to [1,2], provider error when the AGNES section is absent); no regressions in existing tests: tmp/test-hybrid.js 18 checks, tmp/test-modelusage.js (T14 injection) PASS, node --check clean; CLI-level checks confirm agnes is recognized as a valid upstream and hybrid error paths report properly.

Fixed: incomplete thinking-block stripping in mid-body stream continuation (root cause of CC-side "API Error: Content block not found")

Why: On 2026-09-01 the user reported intermittent API Error: Content block not found on the CC side of the production glm bridge. Investigation: the error text appears in neither bridge logs nor upstream responses; decompiling the CC 2.1.226 binary confirmed it is a client-side CC error — the stream reducer throws RangeError("Content block not found") upon receiving a content_block_delta / content_block_stop that references an unknown block (an index that never had a content_block_start). The bridge-side root cause sits in attachContinuationStream (the stripping logic of the mid-body continuation stream): by design it discards thinking blocks re-emitted by the continuation stream, but it only stripped the start event and missed delta and stop — ① the delta stripping condition checked the wrong field names: d.thinking_delta || d.signature_delta looks at fields that don't exist on the delta object (the protocol's discriminant is delta.type, with values "thinking_delta" / "signature_delta"), so the condition was always false and thinking deltas were never stripped; ② the stop branch had no type-based stripping path, so a thinking stop fell into the "remap and forward all subsequent blocks" fallback. Both were forwarded to CC with remapped index nextBlockIndex - 1 — when the break point sits mid tool_use (start withheld in toolBuffer, index already consumed), that index points to a block CC never saw a start for → orphan delta/stop → CC reports "Content block not found". Trigger = mid-body drop (bigmodel.cn gateway ~15s silent RST; the bridge's 12s watchdog proactively continues — 12+ occurrences in production logs on 2026-09-01 alone) + continuation stream leading with a thinking block + break point exactly mid tool_use; the three combined make it intermittent. The 2.15.1 tmp/test-continuation-sse.js regression missed it because its break point was mid text block — the orphan events' remapped index happened to close that half-open block, incidentally avoiding the error — and its client only validated W3C event grouping, without CC-reducer block-reference semantics.

What (core/server.js attachContinuationStream): thinking-block stripping is now consistent across the full trio (start / delta / stop) — a new skipBlockIdx tracks the thinking block currently being stripped: start stripping records its index; the delta condition is fixed to discriminate on delta.type plus index matching as a double guard; the stop branch strips by index match up front and resets. No thinking-block events reach the forwarding path anymore, so the continuation's block-index space stays unpolluted.

Verification: end-to-end via tmp/repro/ (mock upstream + dev-source bridge under isolated config + a strict event checker implementing CC reducer semantics), three scenarios: ① repro (mid-tool_use drop + continuation with thinking): pre-fix, CC sees 3 orphan events (thinking delta ×2 + stop referencing unknown block index=2), reproducing the error; post-fix, the event sequence is legal and the continuation body resumes at the correct index (3); ② control scenarios "break mid text + continuation with thinking" and "mid-tool_use drop + continuation without thinking" both pass post-fix, no regression on the normal continuation path; ③ node --check clean.

Added: hybrid multi-provider upstream (one port serving multiple model providers, routed by model)

Why: previously each upstream needed its own dedicated port / config (glm 8788, ds 8792, mimo 8791…), and switching Claude Code between providers' models meant changing ANTHROPIC_BASE_URL. The user wanted (2026-09-01) a "hybrid bridge": one port whose config holds multiple providers' URLs and k...

Read more

Fix tool_use buffered-forwarding SSE event corruption (CC-side JSON Parse error); adapter-based per-target context window injection; add AGENTS.md symlink

Choose a tag to compare

@xhqing xhqing released this 31 Aug 15:06

Fixed (T15: broken SSE events in tool_use buffered forwarding — root fix for the CC-side "JSON Parse error: Unexpected EOF")

  • Why: On the evening of 2026-08-31 the user hit API Error: JSON Parse error: Unexpected EOF in Claude Code. Investigation confirmed a protocol-level bug introduced by v2.15.0's "buffer tool_use blocks and forward them whole". Reproduced locally end-to-end (fake upstream → bridge → a strict W3C-compliant SSE parser grouping events one by one: 7 events valid, 1 parse failure). Root cause: in the forwarding loop, event: lines and blank-line separators were passed through in real time, while a tool_use block's data: lines were withheld until content_block_stop and then flushed in one batch — CC received "N dangling events with no data + multiple data lines joined into a single event". Per the SSE spec, multiple data lines are joined with newlines into one event's data field, so JSON.parse('{start}\n{delta1}\n…\n{stop}') inevitably fails. Trigger condition = the model issues a tool call during a streaming response (the norm in CC agent sessions); the production glm bridge hit it right after loading 2.15.0. The bridge log showed all-200 with no anomalies throughout (every byte was forwarded; the corruption happened at the event-grouping layer, invisible to the bridge) — pre-release testing of 2.15.0 missed it because it only verified byte-level integrity (content / indices / block order), not parsing grouped by SSE events.
  • What changed (all in core/server.js):
    • Main forwarding loop now stages whole events: a new pendingEventLine mechanism — event: lines are no longer written out immediately; each is held until the same event's data: line or blank line arrives and then written in order. Once a tool_use block is recognized (the data: line of content_block_start), the event line is withheld into toolBuffer together with the data lines and inter-event blank lines; after the content_block_stop event is fully received, everything is flushed at once in the upstream's original order. On the non-tool_use path every byte is still written in the original order, byte-for-byte identical to real-time passthrough (only the write timing is deferred to the event boundary). Prefill material extraction now takes only the buffered data: lines (the buffer also contains event lines and blank lines).
    • The same fix applied to the continuation stream attachContinuationStream: whole-block tool_use forwarding (start / deltas / stop) and the dedup-forwarded first text block now emit a trailing blank line after each event — these two spots were also missing the inter-event blank separator in v2.15.0 (they would blow up the same way whenever mid-body continuation triggered).
  • Verification: ① tmp/repro-toolbuf.js (the 2.15.0 repro script): after the fix, all 11 events valid, 0 parse failures, event-for-event identical to the upstream's original stream; ② a new tmp/test-continuation-sse.js end-to-end continuation test (fake upstream destroys the body at char 200 to simulate a gateway RST → bridge prefill continuation → strict parser verification): all 15 events valid; whole-block tool_use forwarding, deduped continuation body arrival, and thinking stripping all pass; ③ logs confirm the full continuation chain (continuation #1/3 → continuation stream attached → completed, prefill material contains both a text and a tool_use block).

Changed (T14: context windows moved down into adapters and injected per target; modelUsage filled in by default)

  • Why: CONTEXT_WINDOW was previously a single global value (unset → modelUsage not injected), so CC could only guess the window from its built-in table — when going through the bridge, ANTHROPIC_BASE_URL is not api.anthropic.com, and CC degrades claude-opus-4-8 (natively 1M) to a 200K fallback. Measured on 2026-08-30: a long session at 209.8K tokens was rejected by CC's local preflight with "Prompt is too long" (the request was never sent; upstream GLM-5.3's real 1M window would have held it easily; a manual /compact passing at the same size proves it was not an upstream limit). A single global value also cannot adapt to different windows under a multi-pair MODEL_MAP (glm-5.3=1M / glm-4.6=200K).
  • What changed:
    • Each adapter gains a modelContextWindow table (official-doc values, verified 2026-08-31): glm-bridge (5.3 / 5.3-Flash / 5.2 = 1M; 5.1 / 5 / 5-Turbo / 5V-Turbo / 4.7 / 4.6 = 200K; the five 4.5-series models = 128K, from the docs.bigmodel.cn model pages), ds-bridge (V4 pro / flash = 1M, from the official pricing page), mimo-bridge (V2.5 / V2.5-Pro = 1M, from Xiaomi's MiMo official docs). Nominal K/M values are converted in decimal (1M=1000000), slightly conservative versus 2^N — better for CC to compact a little early than for preflight to pass and the upstream to reject. Targets not in a table get no window injected (falling back to CC's built-in table, same as older versions).
    • core/server.js buildModelUsage() now picks the window per target: priority = explicit CONTEXT_WINDOW config (global, backward compatible) > adapter table by target; one entry per mapped pair, with spoof names getting their mapped pair's entry (with multiple pairs, the CLI hits the right window whether it looks up the spoof or the target name). The pairs' contextWindow field also falls back to the adapter table. The core/adapter.js interface comment now documents the modelContextWindow field. Both READMEs' modelUsage-injection wording updated (from "injected when CONTEXT_WINDOW is set" to "injected by default from the adapter's official-doc table; explicit config overrides").
  • Verification: tmp/test-modelusage.js with a multi-pair MODEL_MAP (claude-opus-4-8→glm-5.3 / claude-haiku-4-5→glm-4.6) end-to-end — the modelUsage injected in message_delta is {"glm-5.3":{"contextWindow":1000000},"claude-opus-4-8":{…1M},"glm-4.6":{"contextWindow":200000},"claude-haiku-4-5":{…200K}}, each target correct; with an explicit CONTEXT_WINDOW=123456 all entries are overridden to 123456 (priority works, backward compatible); tool_use stream regression under modelUsage injection (11 events all green).
  • Left over: T12 (feedback to Zhipu about the gateway's 15s SSE idle timeout) — the feedback draft is ready (tmp/zhipu-sse-feedback.md, with three measured runs at 15134/15104/15141ms); submitting the ticket / the user community step is on the user.

Added (AGENTS.md symlink pointing to .claude/CLAUDE.md)

  • Why: Newer agent tools such as ZCode use the project-root AGENTS.md as the conventional entry for project-level guidance (ZCode does not auto-load .claude/CLAUDE.md), but CC-Bridge's project guide has only ever lived in .claude/CLAUDE.md — ZCode sessions on this project could not read the project rules. Rather than maintaining two copies (which would inevitably drift), a symlink shares the single source of truth.
  • What changed: a new symlink AGENTS.md.claude/CLAUDE.md at the project root (relative-path link, valid wherever the repo is cloned; git tracks it as a symlink).

Mid-body stream interruption recovery: body-phase drops no longer error out to Claude Code; prefill-based continuation resumes seamlessly from the break point

Choose a tag to compare

@xhqing xhqing released this 31 Aug 12:48

Mid-body stream interruption recovery: body-phase drops no longer surface an error to Claude Code; a prefill-based continuation resumes seamlessly from the break point.

Changed — mid-body interruption recovery (body-phase drops no longer error out to CC; prefill continuation resumes from the break point)

  • Why: On 2026-08-30 the user hit "API Error: Server error mid-response" again. Full-chain investigation established two facts: (1) the upstream gateway (bigmodel.cn) enforces a ~15s application-layer idle timeout on SSE connections — three observed drops measured 15134/15104/15141ms since the last chunk; GLM's long thinking/generation pauses that stay silent beyond 15s get RST. TCP keepalive (15s) cannot prevent this (the gateway watches application-layer bytes, not probes; GLM only pings once at stream start, zero bytes during silent thinking). (2) Decompiling CC 2.1.226 confirmed its mid-stream error-retry policy: once body blocks (text/tool_use) have been produced, any in-stream error event finalizes the partial response (synthesizing end_turn, yielding the error text to the user, no retry this turn); only thinking-only drops with no body output get auto-retried. Hence v2.14.0's SSE-error close-out only rescued thinking-phase drops (18 of 26 drops that day recovered silently); body-phase drops (8) still surfaced an error — already-forwarded partial body cannot be recalled and CC does not retry, so an error was unavoidable.
  • What changed (all in core/server.js):
    • Continuation main path continueInterrupted(): on a body-phase drop, instead of sending an error to CC, the already-forwarded body (complete blocks plus the in-flight partial text) is re-sent upstream as an assistant prefill appended to the original conversation (GLM endpoint verified in practice: string/array prefill, thinking enabled, and mid-sentence break points all resume precisely; if the last message is already an assistant — structured-output prefill scenarios — blocks are merged to avoid adjacent double-assistant 400). The continuation stream's body is forwarded under the original message with incrementing content_block indices; message_delta/message_stop close normally — CC receives a protocol-complete message, fully seamless.
    • Continuation-stream unwrapping attachContinuationStream(): drops the continuation stream's message_start/ping/thinking blocks (the original message frame already lives on the CC side; repeated thinking adds no value); the first text block is buffered whole, deduplicated via stripRepeatedPrefix() (GLM occasionally repeats the prefill tail or even the whole segment at the continuation head — seen in roughly half of tested scenarios; the algorithm does line-aligned longest-prefix matching over a 4KB window, ignoring single-character coincidences), then forwarded as one new-index block; subsequent text blocks resume real-time per-delta forwarding; tool_use blocks are buffered whole as in the original stream and recorded as prefill material (supports drop-after-drop recovery); if the continuation stream itself drops, it recursively continues within the limit.
    • Upstream silence watchdog: body-phase drops all follow the "silent 15s → gateway RST" pattern; the bridge now detects a drop after 12s of silence and continues immediately (beating the gateway RST by 3s with controllable logs); armed only after body forwarding has started — thinking-phase drops keep the SSE-error close-out, since CC's thinking-only auto-retry is a verified good path we do not take away.
    • 5s SSE comment-line keepalive toward CC (: keepalive\n\n): while the continuation reconnects or the first text block buffers, CC receives no body bytes; comment lines feed its byte watchdog (default 3 minutes) and stop automatically once real body flow resumes.
    • tool_use blocks buffered whole before forwarding: half-delivered tool-input JSON is useless to CC (it needs the complete JSON to execute); blocks are now forwarded in one shot after content_block_stop — when a drop hits mid-tool generation the block stays buffered, zero loss to CC.
    • Fixes and defenses: buildHeaders computes content-length from the actual outgoing body (the continuation body is longer than the original request; without recomputing, the upstream truncates at the original length and returns 422 json_invalid — caught in end-to-end testing); the SSE forwarding loop gains an sseState.abandoned flag (stop writing the original stream once continuation takes over or error close-out runs, preventing ERR_STREAM_WRITE_AFTER_END — caught in testing); continuation capped at 3 attempts, degrading to SSE-error close-out (back to CC's auto-retry path) when exhausted.
  • Verified in practice: (1) prefill continuation across three scenarios (string/array/thinking-enabled) resumes precisely; (2) dedup algorithm unit tests 7/7; (3) regression on normal traffic (plain streaming, forced tool_use) — block structure/indices/forwarding unchanged; (4) simulated-drop end-to-end (test hook destroys the upstream stream 200 chars into the body): logs show the full chain TESTHOOK → continuation #1/3 → ←200 continuation stream attached → continuation done; the client receives [thinking, text (pre-drop half), text (continuation)] — three blocks, indices 0/1/2 contiguous, 654 chars of complete body, normal message_stop close-out, zero error events.
  • Follow-up: reporting the gateway's 15s SSE idle timeout to Zhipu (the official Anthropic API pings every second precisely to prevent this) is tracked in TODO T12.

Registered TODO (T14: per-target context-window injection in adapters)

  • Why registered: investigating QuantStrategistAgent's long-session "Prompt is too long" (209.8K-token context rejected by CC's local pre-check; the request never left the client) exposed a framework gap: for non-official ANTHROPIC_BASE_URL (host not on the api.anthropic.com allowlist), CC does not honor claude-opus-4-8's native 1M window (the built-in native_1m:true table only applies to verifiable channels) and falls back to a 200K floor — while the bridge's modelUsage injection (which tells CC the real window) only fires when CONTEXT_WINDOW is explicitly configured; the global default of 0 means no injection, and it is a single global value that cannot adapt to different windows across a multi-target MODEL_MAP (glm-5.3=1M / glm-4.6=200K). The upstream GLM-5.3's real 1M window fits 209.8K easily (a manual /compact of the same size succeeded, proving it).
  • Registered: new TODO T14 (TODO.md, orange upstream/link section) — each <name>-bridge/adapter.js gains a MODEL_CONTEXT_WINDOW table (modeled on MODEL_MAX_TOKENS, per-target official-doc values); buildModelUsage() falls back to the adapter's real window for the request's target when CONTEXT_WINDOW is not explicitly set. TODO registration only; no code changed this release.

Add glm-5.3-flash entry to MODEL_MAX_TOKENS clamp table

Choose a tag to compare

@xhqing xhqing released this 30 Aug 07:41

Add glm-5.3-flash entry to MODEL_MAX_TOKENS clamp table

Changed (glm-bridge: add glm-5.3-flash entry to MODEL_MAX_TOKENS)

  • Why: A local glm.env MODEL_MAP pair claude-opus-4-7->glm-5.3-flash was added so switching models in Claude Code switches the GLM target (Opus 4.8 = glm-5.3, Opus 4.7 = glm-5.3-flash) with no config change or restart. But the MODEL_MAX_TOKENS table lacked a glm-5.3-flash entry — the clamping logic skips when cap == null, so an over-limit max_tokens would be sent upstream as-is and rejected. The official docs (docs.bigmodel.cn GLM-5.3-Flash page) confirm its text parameters match GLM-5.3, with a max output of 128K (131072).
  • What changed: Added one line to the MODEL_MAX_TOKENS table in glm-bridge/adapter.js: 'glm-5.3-flash': 131072. Table entry only; the clamping logic and passthrough behavior are unchanged.
  • Verified: node --check passed; end-to-end test after a local bridge restart — claude-opus-4-7 requests through the bridge return "model":"glm-5.3-flash" (thinking intact), and claude-opus-4-8 regression still returns "model":"glm-5.3".

v2.14.0

Choose a tag to compare

@xhqing xhqing released this 30 Aug 06:58

Highlights

  • Mid-stream interruption no longer hard-tears the client connection. When the upstream drops a long SSE stream (ECONNRESET mid-response), the bridge now emits an in-protocol error event (overloaded_error) and ends the stream cleanly. Claude Code (≥ 2.1.199) auto-retries the whole turn on in-stream error events, so tasks continue instead of stopping with "Connection closed mid-response" awaiting manual intervention.
  • Upstream socket keepalive (15s) to keep long-lived streams alive across NAT / load balancers that recycle silent connections.
  • Stream-interruption diagnostics: interruption log lines now include time-since-last-chunk and bytes-received, to distinguish idle-timeout kills (server-side recycling during long silent thinking) from active-path resets.

Details

  • Write-after-end guards on the SSE relay path (discard late upstream data after an in-stream error has been sent).
  • No behavioral change to request forwarding, model rewriting, or key failover.

Request-body passthrough and per-key privacy options

Choose a tag to compare

@xhqing xhqing released this 22 Aug 14:10

Request-body passthrough and per-key privacy options

Changed (T4: request body switched to full passthrough — removed four strip categories; added per-key privacy option HIDE_USER_ID)

  • Why: The T4 design was finalized without a FIDELITY switch — passthrough is now the only behavior. All strip/rewrite steps (context_management, Anthropic-specific system blocks, cache_control stripping plus tools tail re-tagging) were re-examined against a T6 live direct-connection baseline capture and official docs, and none of them held up: endpoints treat unrecognized fields as ignored (stripping only fabricated a signature), and re-tagging the tools tail diverged from the direct-connection shape while being harmful to Zhipu explicit caching. metadata.user_id became a per-key privacy option (API_KEY_n_HIDE_USER_ID, passthrough by default, set to 1 to blank it), preserving the ability to keep device identifiers off-network for those who want it without forcing a global deviation from the direct-connection shape.
  • What changed:
    • Bridge adapters: glm-bridge/adapter.js and mimo-bridge/adapter.js rewritten to "passthrough + max_tokens clamp" only (with a "passthrough principle" note in the file header); ds-bridge/adapter.js likewise dropped the four strip categories while keeping repairToolSequence (a functional fix) and the clamp. stripCacheControl removed from all three.
    • core/config.js: collectKeys now collects API_KEY_n_HIDE_USER_ID; validateKeyAttrs accepts only 0/1 (invalid values surface in the validate report); KEYS exposes a hideUserId boolean (unset / 0 = false).
    • core/server.js: inside send(keyIdx) the metadata.user_id is blanked per the current key's hideUserId and the body is re-serialized (behavior follows the key across key rotation / failover); request logs add a user_id=hidden marker; PROXY_DUMP comment notes that dumps record the pre-key-processing shape, so the original user_id appearing there is expected.
    • Five env templates: neutral wording for API_KEY_n_HIDE_USER_ID (privacy option, passthrough by default) plus commented example lines in glm/ds templates.
    • Docs: the three bridge READMEs' adaptation tables rewritten as a "passthrough principle" section; main README (EN/ZH) — intro and "What it does" reworded from "Request-body adaptation" to "Request-body passthrough", the multi-key failover section gained a per-key privacy option entry, and Notes gained the boundary sentence "passthrough is a design choice, not a compliance claim"; architecture bullet wording synced.
  • Verified: all three adapters plus config and server modules load cleanly; hideUserId parsing and validation unit-tested (set to 1 → true, unset → false, invalid value flagged in validate); live end-to-end with a real key — passthrough config (HIDE unset) returned 200, with the dump showing cache_control (system tail + message blocks) and metadata.user_id preserved verbatim and no tools re-tagging (direct-connection shape); HIDE_USER_ID=1 config returned 200, logs showed user_id=hidden, and the forwarded body's user_id was blanked (verified via the re-serialization path in send).
  • Watch item (acceptance criteria, effective after release): the Zhipu key's cache(anthropic) hit rate should not drop and should rise (restoring CC's native cache split points); the zai key's hit rate unchanged.
  • Compatibility: breaking change (request-body shape changed: previously stripped fields are now passed through; no config migration needed — HIDE_USER_ID is an optional addition); grouped with T11 in this release's behavior-change set.
  • TODO closure: T4 / T8 archived (T8's README passthrough docs landed with T4 the same day: EN/ZH passthrough wording + per-key privacy option + non-compliance-claim boundary sentence all in place); T7 (request-body rewrite audit tool) dropped by user decision after archiving — after T4 the request body has only five known rewrites, each with a clear reason to exist, so no dedicated audit tool is needed to guard against drift. The fidelity TODO series (T4/T6/T7/T8/T11) is fully closed out.

Changed (T11: thinking-level pinning feature fully retired; thinking fields now passed through verbatim)

  • Why: The premise of the 2026-07-07 project — that the CC VS Code extension's effort enum lacked max and the bridge had to pin thinking levels — no longer holds (the 2.1.226 extension binary was tested and the enum contains all five levels including max; spoof ID claude-opus-4-8 carries the max_effort capability). Each upstream's official docs now publish the mapping for CC /effort levels (GLM-5.3: low/medium/high→high, xhigh/max/ultracode→max, default is max; DeepSeek-V4-Flash-0731 and V4-Pro-0813 official mappings match: low→low, medium/high/xhigh→high, max→max, Agent requests auto-max), so the passthrough chain is officially guaranteed. Pinning lost its reason to exist, and writing a top-level reasoning_effort field that direct-connection traffic never contains was a fidelity deviation (T6 baseline capture had no such field). User decision: retire the feature, note the mapping tables in the config files, and keep unused fields out of the configs to avoid misleading users.
  • What changed:
    • core/config.js: parseModelThinking emptied into a compatibility no-op (stale MODEL_THINKING lines in old configs are silently ignored without error); removed THINK_MAP/THINK_DEFAULT parsing and thinkingError (consumers in validate / show removed too); the two fields remain present as empty / null in the return object purely for downstream read-contract compatibility.
    • core/server.js: removed the two startup lines injecting modelThinking/thinkingDefault into adapters and the banner's thinking: line; header comment reworded from "GLM thinking normalization" to "request-body adaptation".
    • Three bridge adapters (glm / ds / mimo): removed the thinking-field write block in adaptRequestBody (three-field symmetric writes), the mapEffortToGLM/mapEffortToDeepSeek/mapEffortToMiMo legacy functions, and the defaultThinking export; rewrite-item comments now read "thinking fields passed through verbatim".
    • core/adapter.js: interface doc comment dropped the defaultThinking entry and gained a passthrough note.
    • Five config templates: glm.env.example (note: GLM-5.3 official mapping table + "/effort requires a value; choose xhigh or max to always get max" + a 2026-08 version caveat to prevent future mapping changes from misleading), ds.env.example (note: DeepSeek official mapping table + V4-Flash-0731 / V4-Pro-0813 versions agree + the none→400 warning kept + a hint that xhigh only reaches high on the DS side, unlike GLM), mimo.env.example (note: two-state switch, passthrough means on), kimi/qwen reserved templates (comments synced). The MODEL_THINKING / MODEL_THINKING_DEFAULT config blocks were deleted outright from all templates (kept out of the configs to avoid misleading users).
    • Docs: three bridge READMEs + main README (EN/ZH) — removed the "pin thinking level per target model" feature entry, the adaptation-table row, and the MODEL_THINKING field docs; the main README's "Per-model thinking level" section was rewritten as "Thinking level passthrough" (with the three upstreams' official mapping tables + version caveat); the quick-start "configure MODEL_THINKING" step became "no configuration needed — /effort forwarded verbatim"; adapter interface docs dropped defaultThinking; file tables and notes synced.
  • Regression review (against CHANGELOG history): ① the "ignore /effort and pin" capability introduced in 2.6.0 is retired along with the feature — the user ruled the capability lost its reason to exist (premise gone + official mapping guarantee), an intentional regression; ② the 2.8.x "DS none=400" config boundary note: with the feature retired the bridge no longer writes output_config.effort, so the 400 can never be bridge-triggered — the warning survives as an environment hint in ds.env.example to not turn effort off to none on the CC side; ③ 2.7.2 "MiMo MODEL_THINKING validation failure fix" and 2.7.x "OpenAI-path reasoning_effort passthrough": gone with the pinning mechanism, historical entries kept unrewritten; ④ the fidelity TODO (T4) simplified accordingly — faithful mode needs no thinking sub-switch (the former T5 was cancelled with the feature), thinking fields pass through unconditionally.
  • Compatibility: breaking change (MODEL_THINKING config no longer takes effect, silently ignored); this release bumps the minor version and the README marks it. Tested: all three adapters and the server load cleanly; production glm.env / ds.env (with stale MODEL_THINKING lines) load without error; adaptRequestBody passes CC's original request body (thinking adaptive + output_config.effort) through field by field, adding no reasoning_effort.

Changed (glm.env.example now documents the CC /effort → GLM official mapping; thinking-pin removal filed)

  • Why: The user verified that the CC VS Code extension (2.1.226) effort enum now matches the CLI (binary tested: ["low","medium","high","xhigh","max"] five levels; spoof ID claude-opus-4-8 carries max_effort capability) — the 2026-07-07 premise "VS Code plugin max unusable (enum lacks max, silently falls back to high), bridge must pin thinking" is gone; and the GLM endpoint's official mapping table (Zhipu docs: low/medium/high→high, xhigh/max/ultracode→max, default level is max) already guarantees max thinking across the /effort passthrough chain. Pinning lost its reason to exist, and writing a top-level reasoning_effort field absent from direct-connection traffic (T6 baseline) was a fidelity deviation. User instruction: just note the effort mapping table in the glm.env config file (no bridge-side pinning; level passthrough + a comment document...
Read more

cc-bridge dashboard: local browser panel with the most detailed usage stats

Choose a tag to compare

@xhqing xhqing released this 22 Aug 07:56

cc-bridge dashboard: local browser panel with the most detailed usage stats

Added — cc-bridge dashboard (local browser panel for detailed usage statistics)

  • Why: cc-bridge stats now defaults back to terminal text output, with a trailing hint: "For more detailed usage statistics, run cc-bridge dashboard". The dashboard is a general-purpose panel that will host more modules later — usage stats is the first module.
  • What changed:
    • CLI (bin/cc-bridge.js): cc-bridge stats defaults to terminal text mode again (aggregated / single-upstream detection unchanged); --text / -t kept as compatibility aliases. New cc-bridge dashboard command (aliases: stats --gui / -g) opens the local browser panel. HELP and both READMEs updated.
    • Dashboard page (core/gui.html): 6 overview cards (including "cache-created", which had been recorded but never displayed); a pure-SVG multi-series "usage trend" line chart (per-upstream series, requests / input / output metric switch, auto day-merge beyond 48 buckets, resize-adaptive); detail tables expanded to 3 (by upstream / by key / by model), all with a "cache-created" column; fixed slot-based series colors per upstream (light / dark modes); new dark mode (follows the system). Page is modular (<div class="module">) so future features append as sibling modules; server routes (core/gui.js) refactored into an API_ROUTES registry.
    • Aggregation layer (core/stats.js): aggregate() / aggregateWindowFor() now also return totals / upstreamTotals / series; terminal stats output gains the dashboard hint line.
    • Fixed a trend-chart empty-render bug (TypeError when an hourly bucket was missing some upstream; the matrix is now zero-filled so lines drop to 0).

Changed — READMEs: "Why a bridge?" rationale and unique-capability documentation

  • Why: The main READMEs (EN/CN) described What / How but never Why — the founding premise that "Claude Code only accepts whitelisted model IDs" was nowhere in the docs, and several bridge-only capabilities (classifier routing via CLASSIFIER_MODE, modelUsage injection, request-body adaptation) were under-documented. Intro examples still said "GLM / Kimi / Qwen", out of sync with the implemented set (glm / ds / mimo).
  • What changed:
    • Main READMEs (EN/CN): new "Why a bridge?" section (before Available upstreams) — explains the opposing model-field requirements on both sides (CC accepts only whitelisted IDs vs. upstreams accept only real model names, and there is a single model field), making spoof→target rewriting the only glue; then lists six capabilities plain configuration cannot deliver (multi-key failover, pinned thinking levels, request-body adaptation, classifier routing, modelUsage injection, usage statistics). "What it does" gains three entries: request-body adaptation (stripping CC-specific fields / clamping max_tokens / cache-flag value), security-classifier routing (3× request frequency, ~70% of quota, on/off semantics), and modelUsage injection (CONTEXT_WINDOW / MAX_OUTPUT_TOKENS — clients show the real context window instead of the spoofed model's). The architecture diagram's modelUsage row now notes "real context window". Intro examples updated to "GLM / DeepSeek / MiMo".
    • glm-bridge/README.md: new section "CC security classifier routing (CLASSIFIER_MODE)" — what the classifier is, why its high frequency burns quota (~70%), an on vs off behavior/cost comparison table (on: AGNES free model + primary/backup failover + protocol conversion + system proxy; off: default, local fabricated allow, zero cost, no judgment), and the fallback semantics of returning 502 without forwarding when all AGNES backends fail. "What it does" gains a classifier-routing entry; the config field list gains CLASSIFIER_MODE.

Changed — TODO/MEMO entries retrofitted with unique numbers

  • Why: A new global rule (2026-08-21) requires every TODO/MEMO entry to carry a unique number (T+index / M+index), and existing entries were to be retrofitted in one pass — numbers let the user and AI refer to a specific entry without restating its full text.
  • What changed: 3 archived entries in TODO-archive.md numbered T1–T3. Zero body changes (numbers inserted only; no rewriting, reordering, or timestamp changes).

Fixed — TODO/MEMO numbering bolding script truncation incident

  • Why: The batch script that bolded entry numbers had a slicing bug that emptied the body of every matched entry (leaving only - [ ] **Tn** ), affecting 1 file and 3 entries.
  • What changed: Full rebuild from multiple recovery sources — ① git index / HEAD snapshots; ② Claude Code file-history checkpoints; ③ session-transcript replay of historical edits in time order. Entries re-bolded per the rule (Tn / Mn) with bodies verified verbatim against recovery sources; damaged shells archived locally (/tmp/todo-damage-backup/).

Usage stats GUI with time-window query; hourly-bucket stats persistence

Choose a tag to compare

@xhqing xhqing released this 20 Aug 14:47

Usage stats GUI with time-window query, and hourly-bucket persistence that survives daemon restarts

Added — Usage stats GUI (time-window query + per-dimension views)

cc-bridge stats now opens a local usage dashboard in your browser by default (--text keeps the original terminal view):

  • Hourly-bucket persistence (core/server.js): the stats model switches from per-daemon-process cumulative counters to hours[hk].models/.keys hourly buckets (UTC hour keys, e.g. "2026-08-20T04"). Snapshots are loaded on startup, so usage history survives daemon restarts; old v1 snapshots are auto-migrated into a single bucket (totals preserved, granularity coarsened to the hour); 30-day rolling retention (STATS_RETENTION_HOURS). Usage attribution (Anthropic / OpenAI styles, message_start / message_delta dual checkpoints, base fallback estimation) and cache-hit accounting are unchanged.
  • Time-window aggregation (core/stats.js): aggregate(fromISO, toISO) merges buckets within the window into two views — by key-name (merged across upstreams, disambiguated on name collisions) and by model (upstream/model labels). normalizedHours() provides a unified view over v1/v2 snapshots for backward compatibility.
  • Local GUI server (new core/gui.js): cc-bridge stats starts a temporary HTTP server bound to 127.0.0.1 only (port picked from PROXY_PORT+1, up to 20 tries), protected by a one-time random token in the URL (any missing/wrong token gets 403). It auto-opens the system browser and exits on Ctrl-C. The data API GET /api/stats?from=&to= reuses aggregate() and works even when the daemon is stopped (reads the snapshot file).
  • Dashboard page (new core/gui.html, zero-dependency single file): start/end time pickers (datetime-local, local timezone) + Query button + quick windows (Today / Last 7 days / Last 30 days / All, default Last 7 days); five overview cards (requests / input tokens / cache-hit tokens / cache-hit rate / output tokens); by key-name and by model tables with the same columns as the CLI text view; footer documents attribution and hourly-bucket granularity.
  • CLI wiring (bin/cc-bridge.js): cc-bridge stats defaults to the GUI; --text / -t keeps the terminal view. HELP and README (EN/CN) updated.
  • Tested: 11 unit tests for window filtering (v1/v2 compatibility, closed-interval boundaries, one-sided windows, empty window); end-to-end bridged request verifying v1 snapshot migration + new requests landing in new buckets + SIGTERM persisting v2; GUI token checks (valid 200 / missing 403 / wrong 403 / unknown path 403); Chrome headless screenshot confirming page rendering.

Changed — Visitors badge renamed to Visits/day (14d): alt text aligned with the centralized traffic stats label

README badge alt="Visitors"alt="Visits/day (14d)", matching the badge JSON label on the centralized xhqing traffic-stats side. Alt text only — the endpoint URL, data source, and badge semantics are unchanged.

Fix daemon crash on late upstream errors after response started (ERR_HTTP_HEADERS_SENT)

Choose a tag to compare

@xhqing xhqing released this 16 Aug 15:27

Fixed

  • Fix frequent daemon crashes on late upstream errors after the response has started (ERR_HTTP_HEADERS_SENT uncaught exception killing the process): Users had long been hit by "the cc-bridge background service dies for no reason and has to be restarted manually" — logs confirmed 27 crashes with the same signature on the glm channel and 9 on the ds channel (2026-07-28 to 2026-08-16), with another occurrence on 2026-08-16 16:57. Root-cause chain: ① On a streaming request the upstream returns 200 immediately, the bridge has already written response headers to the client (writeHead executed) and SSE forwarding has begun; ② tens of seconds later the connection is killed by an RST, and the read ECONNRESET lands in the request-level activeUpReq.on('error') handler (instead of the harmless client-side path that only disconnects the client); ③ that handler only checks "is this a transient error" and retries, violating the design intent stated in its own code comment ("the retry window closes once the first upstream response arrives; no more switching after that"); ④ the retry also gets a 200 and calls writeHead a second time on the same client response → Node throws ERR_HTTP_HEADERS_SENT in an async socket-data callback with no catcher → the process exits, the daemon dies, and every in-flight request on the bridge is dropped. Three-layer fix (mutually reinforcing): Primary fix — each request closure now carries a responseStarted flag, set at the entry of handleUpstreamResponse; in activeUpReq.on('error'), if the response has already started, do not retry — just disconnect the client (a partially forwarded stream cannot be transparently retried anyway; Claude Code resends the request on its own). Defense in depthhandleUpstreamResponse checks responseStarted || clientRes.headersSent at entry and drops any late upstream response that arrives after headers were sent, never writing headers twice (catches all variants of late errors). Process-level backstop — register process.on('uncaughtException') / unhandledRejection, log the full stack to the daemon log and keep running (requests on a local proxy are isolated from each other, so continuing is safe), so that any unforeseen bug no longer kills the daemon. After the fix, an upstream connection drop now manifests as "one request dropped (the CC client retries it), daemon alive" instead of "the whole process crashes and needs a manual restart". Verified by reproduction: a mock upstream returning 200 first, then resetAndDestroy() mid-stream (RST, with the error landing in the request-level handler, byte-for-byte identical to the production logs), and a retry returning 200 — the old build (2.10.0 installed copy) crashes with exactly the production stack, while the fixed build survives the same scenario and its log shows "upstream late error (response started, not retrying)" working as designed; regression tests for 429 / 500 transient retries and non-streaming requests pass 3/3 (retry / failover behavior unchanged). Rationale: occasional upstream drops of long streams are unavoidable (every trigger in glm.log was ECONNRESET / ETIMEDOUT), but amplifying "a dropped stream" into "a dead daemon" was a retry-timing flaw in the bridge and had to be fixed.

Per-key priority, daemon banner fixes, and GLM dual-endpoint rebrand

Choose a tag to compare

@xhqing xhqing released this 16 Aug 04:49

Per-key priority, daemon banner fixes, and GLM dual-endpoint rebrand

Added

  • Per-key priority (API_KEY_n_PRIORITY — highest-priority key serves traffic first): when a single upstream config carries multiple API keys, each key can set API_KEY_n_PRIORITY=<non-negative integer> (higher = used first; unset counts as 0). Implementation: collectKeys in core/config.js collects the raw KEY_n_PRIORITY values, validateKeyAttrs rejects non-integers at startup (API_KEY_n_PRIORITY="…" is not a non-negative integer), and loadConfig sorts keys by priority descending with a stable order (same priority keeps key-number order) — the server's key rotation scans the array in order, so after sorting pickNextKey / circuit breaking / failback all work unchanged, with zero changes to core/server.js. Effect: the highest-priority key takes all traffic until it gets circuit-broken on 401/403, then traffic falls to lower-priority keys; 60 s after the breaker expires, traffic automatically switches back to the high-priority key — "primary key first, backup keys only for failover" is now expressed by config, no need to add/remove key lines. If no key sets PRIORITY, sorting degrades to key-number order, identical to previous behavior (backward compatible). cc-bridge config show annotates each key with prio=; the glm / ds / mimo env.example templates document API_KEY_n_PRIORITY with primary/backup examples; both READMEs (feature list, config example, multi-key failover section) gain a "Key priority" entry. Motivation: the user wanted control over key usage order (e.g. burn the primary account first, keep backups for failover only); previously this could only be done by renumbering API_KEY_n lines, which is painful to maintain when adding accounts. Tested: local mock with two keys (low-priority key numbered first) — requests hit the high-priority key; with the high-priority key returning persistent 401, the breaker switched to the low-priority key and got 200; an invalid PRIORITY value was rejected by validate; with no PRIORITY set, order stayed at key-number order.

Fixed

  • Daemon banner showed wrong endpoints (still a single api base after cc-bridge restart): 2.9.1 introduced multi-endpoint API_BASES but only upgraded the server-process banner (core/server.js lists all endpoints); printBanner in core/daemon.js (output of restart / start / claude) still printed the compatibility field cfg.API_BASE (= first endpoint URL) — with z.ai + Zhipu dual endpoints configured and only Zhipu keys enabled, the banner still showed api base : https://api.z.ai/..., misleading the user into thinking traffic went to z.ai (forwarding actually follows each key's binding; logs base=cn confirmed Zhipu). printBanner now matches the server banner: multi-endpoint configs print all endpoints as api bases : zai=… | cn=…, single-endpoint keeps the one-line api base; the API keys line now lists key names instead of a count (multi-endpoint shows name@endpoint, e.g. zhipu-cn@cn), consistent with the server banner. Motivation: the banner is the first thing users check to judge routing — an endpoint shown that differs from where traffic actually goes directly misleads troubleshooting.
  • GLM adapter displayName was pinned to (z.ai), inconsistent with multi-endpoint support: displayName in glm-bridge/adapter.js had been GLM-5.3 (z.ai) since 2.9.0; after 2.9.1 added the Zhipu domestic endpoint, the name still claimed a single vendor, and the banner line upstream : GLM-5.3 (z.ai) was equally misleading when traffic went to Zhipu. Changed to GLM (z.ai / bigmodel.cn) (no pinned model version — the version is on the spoof→target line and the actual endpoints on the api-bases line). All references to the old wording were updated in sync: glm-bridge/README.md (title and body reworded for dual endpoints; config field docs moved from legacy API_BASE / comma-separated API_KEY to the API_BASES + API_KEY_n trio; "z.ai doesn't recognize" wording in the adaptation table changed to "GLM endpoint"), header comment in glm-bridge/adapter.js, both main READMEs (implemented list, upstream table, file table), the implemented list in .claude/CLAUDE.md, and 6 SVGs under assets/demo/ that still said GLM-5.2 (z.ai) / glm-5.2 (displayName to dual-endpoint, model 5.2→5.3, version footnote v2.8.1→v2.9.1), with the 5 affected PNGs re-rendered at 2x via rsvg-convert. Motivation: docs and demo images should describe current behavior; pinning an outdated vendor/model version misleads readers about multi-endpoint capability.