clauderipple 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.es.md +22 -11
  3. package/README.ja.md +14 -5
  4. package/README.ko.md +19 -8
  5. package/README.md +22 -10
  6. package/README.pt-BR.md +22 -11
  7. package/README.zh-CN.md +14 -5
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/index.js +2 -1
  10. package/dist/mod/hooks/i18n.ts +36 -12
  11. package/dist/mod/hooks/judge.ts +17 -1
  12. package/dist/mod/hooks/register.tsx +39 -9
  13. package/dist/mod/hooks/session.ts +11 -3
  14. package/dist/mod/hooks/ui/alerts.tsx +21 -5
  15. package/dist/mod/hooks/ui/band.tsx +1 -1
  16. package/dist/mod/hooks/ui/view.ts +4 -1
  17. package/dist/mod/tests/register.test.tsx +53 -0
  18. package/dist/mod/tests/ui.test.tsx +10 -6
  19. package/dist/mod/types/index.d.ts +4 -1
  20. package/dist/router/src/admin.js +72 -2
  21. package/dist/router/src/agents.js +4 -0
  22. package/dist/router/src/config.js +24 -0
  23. package/dist/router/src/ingress/server.js +2 -2
  24. package/dist/router/src/mod-settings.js +2 -1
  25. package/dist/router/src/providers/chatgpt/index.js +19 -6
  26. package/dist/router/src/providers/chatgpt/translate.js +3 -0
  27. package/dist/router/src/providers/google/index.js +4 -3
  28. package/dist/router/src/providers/grok/auth.js +289 -0
  29. package/dist/router/src/providers/grok/catalog.js +52 -0
  30. package/dist/router/src/providers/grok/index.js +94 -0
  31. package/dist/router/src/providers/openai/index.js +71 -17
  32. package/dist/router/src/providers/openai/translate.js +11 -1
  33. package/dist/router/src/proxy.js +91 -21
  34. package/dist/router/src/redact.js +7 -2
  35. package/dist/router/src/requestlog.js +78 -20
  36. package/dist/router/src/version.js +1 -1
  37. package/dist/ui/app.js +105 -0
  38. package/dist/ui/i18n.js +6 -6
  39. package/docs/ARCHITECTURE.md +91 -0
  40. package/package.json +1 -1
@@ -20,7 +20,7 @@ import tls from "node:tls";
20
20
  import zlib from "node:zlib";
21
21
  import { ProviderHealth, providerExhaustion, UpstreamHealth } from "./health.js";
22
22
  import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
23
- import { THREAD_UNSUPPORTED, effortOf, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
23
+ import { THREAD_UNSUPPORTED, effortOf, isAutoModeClassifier, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
24
24
  import { defaultAgentDir, withAgentAliases } from "./agents.js";
25
25
  import { forwardCompatibleHeader, resolveCompatibleCaps, sanitizeForCompatible } from "./compat.js";
26
26
  import { applyIdentityToAnthropicBody } from "./identity.js";
@@ -30,6 +30,7 @@ import { PRESETS } from "./presets.js";
30
30
  import { ChatGptAdapter } from "./providers/chatgpt/index.js";
31
31
  import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
32
32
  import { GoogleAdapter } from "./providers/google/index.js";
33
+ import { GrokAdapter } from "./providers/grok/index.js";
33
34
  import { conversationKey } from "./providers/chatgpt/translate.js";
34
35
  import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
35
36
  import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
@@ -186,6 +187,7 @@ export class Proxy {
186
187
  chatgptAdapters = new Map();
187
188
  openaiAdapters = new Map();
188
189
  googleAdapters = new Map();
190
+ grokAdapters = new Map();
189
191
  claudeAccounts;
190
192
  /** Each Claude subscription account's usage, for the admin status (never a token). */
191
193
  claudeUsage;
@@ -304,6 +306,15 @@ export class Proxy {
304
306
  this.openaiAdapters.set(name, { key, adapter });
305
307
  return adapter;
306
308
  }
309
+ grok(name, cfg) {
310
+ const key = JSON.stringify(cfg);
311
+ const cur = this.grokAdapters.get(name);
312
+ if (cur && cur.key === key)
313
+ return cur.adapter;
314
+ const adapter = new GrokAdapter(name, cfg, this.deps.log);
315
+ this.grokAdapters.set(name, { key, adapter });
316
+ return adapter;
317
+ }
307
318
  google(name, cfg) {
308
319
  const key = JSON.stringify(cfg);
309
320
  const cur = this.googleAdapters.get(name);
@@ -629,6 +640,16 @@ export class Proxy {
629
640
  completed.note = note;
630
641
  if (extra?.resent && !completed.ok)
631
642
  completed.resent = true;
643
+ if (extra?.messageId !== undefined)
644
+ completed.messageId = extra.messageId;
645
+ if (extra?.requestedTier !== undefined)
646
+ completed.requestedTier = extra.requestedTier;
647
+ if (extra?.servedTier !== undefined)
648
+ completed.servedTier = extra.servedTier;
649
+ if (extra?.upstreamCalled === false)
650
+ completed.upstreamCalled = false;
651
+ if (extra?.refusal !== undefined)
652
+ completed.refusal = extra.refusal;
632
653
  this.deps.requests.add(completed);
633
654
  }
634
655
  };
@@ -639,7 +660,7 @@ export class Proxy {
639
660
  const msg = JSON.stringify({ type: "error", error: { type: "overloaded_error", message: "ClaudeRipple is restarting; retry" } });
640
661
  res.writeHead(503, { "content-type": "application/json", "retry-after": "3", connection: "close", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
641
662
  req.resume();
642
- finish("503", msg.length, "refused during drain (client retries)", false, { resent: true });
663
+ finish("503", msg.length, "refused during drain (client retries)", false, { resent: true, upstreamCalled: false, refusal: "drain" });
643
664
  return;
644
665
  }
645
666
  let body;
@@ -673,9 +694,17 @@ export class Proxy {
673
694
  // backend is configured: `cfg.webSearch` decides who answers it, but knowing that this is a
674
695
  // search is what lets the guard below refuse a provider that cannot run one. Tying the two
675
696
  // together meant an unconfigured router could not tell a search from any other turn.
697
+ if (json) {
698
+ record.source = record.target = typeof model === "string" ? model : "-";
699
+ record.stream = json.stream === true;
700
+ if (isAutoModeClassifier(json))
701
+ record.purpose = "auto-mode-classifier";
702
+ }
676
703
  const search = json && isApiHost && pathname === "/v1/messages" ? webSearchQuery(json) : null;
704
+ let searchAttempted = false;
705
+ const markSearchAttempted = () => { searchAttempted = true; };
677
706
  if (search && cfg.webSearch) {
678
- const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish);
707
+ const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish, markSearchAttempted);
679
708
  if (served)
680
709
  return;
681
710
  // Fall through on failure: the ordinary path still answers, and a search that quietly
@@ -694,7 +723,7 @@ export class Proxy {
694
723
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
695
724
  tag = `REFUSE ${model}`;
696
725
  record = { ...record, provider: "refused" };
697
- finish("400", payload.length, `ClaudeRipple: ${reason}`, true);
726
+ finish("400", payload.length, `ClaudeRipple: ${reason}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unroutable" });
698
727
  return;
699
728
  }
700
729
  }
@@ -735,7 +764,7 @@ export class Proxy {
735
764
  // The same holds for a plan that searches on another of its endpoints (`anthropicSearch`).
736
765
  const own = (cfg.providers[route.provider]?.type === "chatgpt" || searchProviderFor(cfg.providers[route.provider], route.model) !== undefined) &&
737
766
  cfg.webSearch?.provider !== route.provider;
738
- if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish)))
767
+ if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish, markSearchAttempted)))
739
768
  return;
740
769
  const blocks = webSearchErrorBlocks(search, "unavailable");
741
770
  const model = typeof json.model === "string" ? json.model : "unknown";
@@ -745,7 +774,8 @@ export class Proxy {
745
774
  ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
746
775
  : { "content-type": "application/json" }).end(payload);
747
776
  log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
748
- finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false);
777
+ record = { ...record, target: route.model, provider: route.provider, purpose: "web-search" };
778
+ finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "web-search-unsupported" });
749
779
  return;
750
780
  }
751
781
  let compatCaps;
@@ -755,9 +785,10 @@ export class Proxy {
755
785
  let penalised = null;
756
786
  let target;
757
787
  if (route && json) {
788
+ record = { ...record, target: route.model, provider: route.provider };
758
789
  const configured = cfg.providers[route.provider];
759
790
  if (!configured) {
760
- finish("500", 0, `unknown provider ${route.provider}`);
791
+ finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
761
792
  res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
762
793
  return;
763
794
  }
@@ -771,12 +802,11 @@ export class Proxy {
771
802
  if (td === "refuse") {
772
803
  const out = JSON.stringify(THREAD_UNSUPPORTED);
773
804
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
774
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
805
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
775
806
  return;
776
807
  }
777
808
  if (td === "strip")
778
809
  stripThreadFields(json);
779
- record = { ...record, target: route.model, provider: route.provider };
780
810
  const routeEffort = effortOf(json);
781
811
  if (routeEffort)
782
812
  record.effort = routeEffort;
@@ -787,7 +817,7 @@ export class Proxy {
787
817
  const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
788
818
  if (!path.startsWith("/v1/messages/count_tokens"))
789
819
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
790
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
820
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
791
821
  }
792
822
  catch (e) {
793
823
  finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
@@ -803,12 +833,11 @@ export class Proxy {
803
833
  if (td === "refuse") {
804
834
  const out = JSON.stringify(THREAD_UNSUPPORTED);
805
835
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
806
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
836
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
807
837
  return;
808
838
  }
809
839
  if (td === "strip")
810
840
  stripThreadFields(json);
811
- record = { ...record, target: route.model, provider: route.provider };
812
841
  const routeEffort = effortOf(json);
813
842
  if (routeEffort)
814
843
  record.effort = routeEffort;
@@ -818,7 +847,7 @@ export class Proxy {
818
847
  this.recordOutcome(route.provider, o.status);
819
848
  if (!path.startsWith("/v1/messages/count_tokens"))
820
849
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
821
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
850
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
822
851
  }
823
852
  catch (e) {
824
853
  this.recordOutcome(route.provider, 0);
@@ -837,12 +866,11 @@ export class Proxy {
837
866
  if (td === "refuse") {
838
867
  const out = JSON.stringify(THREAD_UNSUPPORTED);
839
868
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
840
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
869
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
841
870
  return;
842
871
  }
843
872
  if (td === "strip")
844
873
  stripThreadFields(json);
845
- record = { ...record, target: route.model, provider: route.provider };
846
874
  const routeEffort = effortOf(json);
847
875
  if (routeEffort)
848
876
  record.effort = routeEffort;
@@ -852,7 +880,7 @@ export class Proxy {
852
880
  this.recordOutcome(route.provider, o.status);
853
881
  if (!path.startsWith("/v1/messages/count_tokens"))
854
882
  this.providerHealth.observe(route.provider, o.status, o.exhaustion);
855
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
883
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
856
884
  }
857
885
  catch (e) {
858
886
  this.recordOutcome(route.provider, 0);
@@ -864,6 +892,38 @@ export class Proxy {
864
892
  }
865
893
  return;
866
894
  }
895
+ if (provider.type === "grok") {
896
+ // The chat proxy keeps no server-side thread, so a `continue` is refused like on the other
897
+ // translated providers and the CLI resends the turn with the full history.
898
+ const td = threadDecision(json);
899
+ if (td === "refuse") {
900
+ const out = JSON.stringify(THREAD_UNSUPPORTED);
901
+ res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
902
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
903
+ return;
904
+ }
905
+ if (td === "strip")
906
+ stripThreadFields(json);
907
+ record = { ...record, target: route.model, provider: route.provider };
908
+ const routeEffort = effortOf(json);
909
+ if (routeEffort)
910
+ record.effort = routeEffort;
911
+ tag = `GROK ${route.tag} effort=${routeEffort ?? "-"}`;
912
+ try {
913
+ const o = await this.grok(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
914
+ this.recordOutcome(route.provider, o.status);
915
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
916
+ }
917
+ catch (e) {
918
+ this.recordOutcome(route.provider, 0);
919
+ finish("-", 0, `grok error ${e.code ?? ""} ${e.message}`);
920
+ if (!res.headersSent)
921
+ res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
922
+ else
923
+ res.destroy();
924
+ }
925
+ return;
926
+ }
867
927
  if (provider.type === "anthropic") {
868
928
  if (!provider.accountPool || provider.auth !== "claude-code") {
869
929
  finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
@@ -876,7 +936,7 @@ export class Proxy {
876
936
  if (credentials.length === 0) {
877
937
  const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
878
938
  res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
879
- finish("401", out.length, "no usable Claude account");
939
+ finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
880
940
  return;
881
941
  }
882
942
  dropForeignThinking(json);
@@ -895,7 +955,6 @@ export class Proxy {
895
955
  dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
896
956
  dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
897
957
  };
898
- record = { ...record, target: route.model, provider: route.provider };
899
958
  const routeEffort = effortOf(json);
900
959
  if (routeEffort)
901
960
  record.effort = routeEffort;
@@ -910,7 +969,7 @@ export class Proxy {
910
969
  if (threadDecision(json) === "refuse") {
911
970
  const out = JSON.stringify(THREAD_UNSUPPORTED);
912
971
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
913
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
972
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
914
973
  return;
915
974
  }
916
975
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
@@ -961,7 +1020,6 @@ export class Proxy {
961
1020
  // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
962
1021
  basePath: u.pathname.replace(/\/+$/, ""),
963
1022
  };
964
- record = { ...record, target: route.model, provider: route.provider };
965
1023
  const routeEffort = effortOf(json);
966
1024
  if (routeEffort)
967
1025
  record.effort = routeEffort;
@@ -979,6 +1037,8 @@ export class Proxy {
979
1037
  target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
980
1038
  tag = `WEB ${reqHost}`;
981
1039
  }
1040
+ if (json && typeof json.service_tier === "string")
1041
+ record.requestedTier = json.service_tier;
982
1042
  // Two kinds of response editing: the CLI bootstrap (api host) and the claude.ai bootstrap (picker mode).
983
1043
  const isCliBootstrap = isApiHost && !route && path.startsWith(BOOTSTRAP_PATH);
984
1044
  const isPickerBootstrap = !isApiHost && !!cfg.picker?.enabled && method === "GET" && isBootstrapPath(path);
@@ -1231,6 +1291,9 @@ export class Proxy {
1231
1291
  send(withCredential(headers, retry.headers, usedCredentialHeaders));
1232
1292
  return;
1233
1293
  }
1294
+ const native = isApiHost && (!route || cfg.providers[route.provider]?.type === "anthropic");
1295
+ if (native && typeof upRes.headers["request-id"] === "string")
1296
+ record.upstreamRequestId = upRes.headers["request-id"];
1234
1297
  const outHeaders = [];
1235
1298
  const r = upRes.rawHeaders;
1236
1299
  for (let i = 0; i < r.length; i += 2) {
@@ -1343,6 +1406,10 @@ export class Proxy {
1343
1406
  const observed = tap.finish();
1344
1407
  observedUsage = observed.usage;
1345
1408
  observedStopReason = observed.stopReason;
1409
+ if (!native && observed.messageId !== undefined)
1410
+ record.messageId = observed.messageId;
1411
+ if (observed.servedTier !== undefined)
1412
+ record.servedTier = observed.servedTier;
1346
1413
  res.end();
1347
1414
  const errorBody = status >= 400 ? decodeBodyToText(Buffer.concat(errorHead), upRes.headers["content-encoding"]) : "";
1348
1415
  if (route && path.startsWith("/v1/messages") && !path.startsWith("/v1/messages/count_tokens")) {
@@ -1371,7 +1438,7 @@ export class Proxy {
1371
1438
  * model. Returns false when it could not be served, and the caller falls back to the normal path:
1372
1439
  * an empty search result is the one outcome worth avoiding, because nothing anywhere reports it.
1373
1440
  */
1374
- async serveWebSearch(res, json, query, settings, record, finish) {
1441
+ async serveWebSearch(res, json, query, settings, record, finish, markAttempted) {
1375
1442
  const configured = this.deps.config().providers[settings.provider];
1376
1443
  if (!configured) {
1377
1444
  this.deps.log.warn(`web search: unknown provider ${settings.provider}; leaving the request alone`);
@@ -1422,6 +1489,7 @@ export class Proxy {
1422
1489
  const model = typeof json.model === "string" ? json.model : "unknown";
1423
1490
  let blocks;
1424
1491
  try {
1492
+ markAttempted();
1425
1493
  const outcome = await backend.search(query);
1426
1494
  blocks = webSearchBlocks(query, outcome).blocks;
1427
1495
  }
@@ -1430,6 +1498,8 @@ export class Proxy {
1430
1498
  return false;
1431
1499
  }
1432
1500
  record.target = `${settings.provider}/${settings.model}`;
1501
+ record.provider = settings.provider;
1502
+ record.purpose = "web-search";
1433
1503
  const wantStream = json.stream === true;
1434
1504
  const payload = wantStream ? webSearchSse(model, blocks, 1) : JSON.stringify(webSearchMessage(model, blocks, 1));
1435
1505
  const headers = wantStream
@@ -11,8 +11,13 @@ function credentialHeaderName(name) {
11
11
  export function credentialHeaderValues(headers) {
12
12
  const secrets = new Set();
13
13
  for (const [rawName, value] of headers) {
14
- if (credentialHeaderName(rawName) && value)
15
- secrets.add(value);
14
+ if (!credentialHeaderName(rawName) || !value)
15
+ continue;
16
+ secrets.add(value);
17
+ // A vendor that echoes the credential echoes the token, not the scheme in front of it.
18
+ const bare = /^(?:Bearer|Basic|Token)\s+(\S+)$/i.exec(value)?.[1];
19
+ if (bare)
20
+ secrets.add(bare);
16
21
  }
17
22
  return [...secrets].sort((a, b) => b.length - a.length);
18
23
  }
@@ -130,6 +130,7 @@ export class ResponseUsageTap {
130
130
  isSse;
131
131
  decoder = new TextDecoder();
132
132
  carry = "";
133
+ skipSseLine = false;
133
134
  event = "";
134
135
  data = [];
135
136
  dropUntilNewline = false;
@@ -138,6 +139,13 @@ export class ResponseUsageTap {
138
139
  jsonUsageText = "";
139
140
  stopCarry = "";
140
141
  stopReason;
142
+ messageId;
143
+ servedTier;
144
+ jsonDepth = 0;
145
+ jsonString = null;
146
+ jsonEscaped = false;
147
+ jsonKey;
148
+ jsonValue = false;
141
149
  usage;
142
150
  // Compressed responses (Anthropic answers the CLI with gzip/br) are collected side-band up to a cap
143
151
  // and inflated once at the end; the forwarded bytes are never touched.
@@ -215,28 +223,28 @@ export class ResponseUsageTap {
215
223
  }
216
224
  if (this.isSse && this.carry)
217
225
  this.feedSse("\n");
218
- return { ...(this.usage ? { usage: this.usage } : {}), ...(this.stopReason ? { stopReason: this.stopReason } : {}) };
226
+ return {
227
+ ...(this.usage ? { usage: this.usage } : {}),
228
+ ...(this.stopReason ? { stopReason: this.stopReason } : {}),
229
+ ...(this.messageId !== undefined ? { messageId: this.messageId } : {}),
230
+ ...(this.servedTier !== undefined ? { servedTier: this.servedTier } : {}),
231
+ };
219
232
  }
233
+ // Complete lines are consumed before the cap applies, so an inflated body or one large chunk keeps
234
+ // its events; only an unfinished line longer than the cap is dropped, along with its later bytes.
220
235
  feedSse(text) {
221
- this.carry += text;
222
- if (this.carry.length > MAX_CARRY) {
223
- const newline = this.carry.lastIndexOf("\n");
224
- if (newline < 0) {
225
- this.carry = "";
226
- this.event = "";
227
- this.data = [];
228
- return;
229
- }
230
- this.carry = this.carry.slice(newline + 1);
231
- this.event = "";
232
- this.data = [];
233
- }
236
+ const buffer = this.carry + text;
237
+ let start = 0;
234
238
  for (;;) {
235
- const newline = this.carry.indexOf("\n");
239
+ const newline = buffer.indexOf("\n", start);
236
240
  if (newline < 0)
237
- return;
238
- const raw = this.carry.slice(0, newline);
239
- this.carry = this.carry.slice(newline + 1);
241
+ break;
242
+ const raw = buffer.slice(start, newline);
243
+ start = newline + 1;
244
+ if (this.skipSseLine) {
245
+ this.skipSseLine = false;
246
+ continue;
247
+ }
240
248
  const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
241
249
  if (line === "") {
242
250
  this.consumeSseEvent();
@@ -250,6 +258,13 @@ export class ResponseUsageTap {
250
258
  this.data.push(line.slice(5).trimStart());
251
259
  }
252
260
  }
261
+ this.carry = buffer.slice(start);
262
+ if (this.carry.length > MAX_CARRY) {
263
+ this.carry = "";
264
+ this.skipSseLine = true;
265
+ this.event = "";
266
+ this.data = [];
267
+ }
253
268
  }
254
269
  consumeSseEvent() {
255
270
  if (!this.data.length)
@@ -258,6 +273,8 @@ export class ResponseUsageTap {
258
273
  const body = JSON.parse(this.data.join("\n"));
259
274
  if (this.event === "message_start" || body.type === "message_start") {
260
275
  const message = body.message;
276
+ if (typeof message?.id === "string")
277
+ this.messageId = message.id;
261
278
  this.setUsage(message?.usage);
262
279
  }
263
280
  else if (this.event === "message_delta" || body.type === "message_delta") {
@@ -272,6 +289,42 @@ export class ResponseUsageTap {
272
289
  }
273
290
  }
274
291
  feedJson(text) {
292
+ // Read only the top-level id, never an id quoted in content or a tool input.
293
+ for (const char of text) {
294
+ if (this.jsonString !== null) {
295
+ if (this.jsonString.length < 256)
296
+ this.jsonString += char;
297
+ if (this.jsonEscaped)
298
+ this.jsonEscaped = false;
299
+ else if (char === "\\")
300
+ this.jsonEscaped = true;
301
+ else if (char === '"') {
302
+ if (this.jsonDepth === 1) {
303
+ try {
304
+ const value = JSON.parse(this.jsonString);
305
+ if (!this.jsonValue && typeof value === "string")
306
+ this.jsonKey = value;
307
+ else if (this.jsonKey === "id" && typeof value === "string")
308
+ this.messageId = value;
309
+ }
310
+ catch { /* oversized or malformed string */ }
311
+ }
312
+ this.jsonString = null;
313
+ }
314
+ }
315
+ else if (char === '"')
316
+ this.jsonString = '"';
317
+ else if (char === "{" || char === "[")
318
+ this.jsonDepth++;
319
+ else if (char === "}" || char === "]")
320
+ this.jsonDepth--;
321
+ else if (this.jsonDepth === 1 && char === ":")
322
+ this.jsonValue = true;
323
+ else if (this.jsonDepth === 1 && char === ",") {
324
+ this.jsonValue = false;
325
+ this.jsonKey = undefined;
326
+ }
327
+ }
275
328
  this.carry += text;
276
329
  this.stopCarry = (this.stopCarry + text).slice(-MAX_CARRY);
277
330
  const stop = /"stop_reason"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/.exec(this.stopCarry);
@@ -303,6 +356,7 @@ export class ResponseUsageTap {
303
356
  this.jsonUsageText = "";
304
357
  return;
305
358
  }
359
+ this.jsonUsageDepth = 0;
306
360
  let quoted = false;
307
361
  let escaped = false;
308
362
  for (let i = 0; i < this.jsonUsageText.length; i++) {
@@ -335,15 +389,19 @@ export class ResponseUsageTap {
335
389
  if (!value || typeof value !== "object")
336
390
  return;
337
391
  const usage = value;
392
+ if (typeof usage.service_tier === "string")
393
+ this.servedTier = usage.service_tier;
338
394
  const number = (key) => typeof usage[key] === "number" && Number.isFinite(usage[key]) ? Math.max(0, usage[key]) : 0;
339
395
  if (!("input_tokens" in usage || "output_tokens" in usage || "cache_read_input_tokens" in usage || "cache_creation_input_tokens" in usage))
340
396
  return;
341
397
  const current = this.usage ?? { input: 0, cached: 0, output: 0 };
342
398
  const input = "input_tokens" in usage ? number("input_tokens") : current.input;
343
399
  const cached = "cache_read_input_tokens" in usage ? number("cache_read_input_tokens") : current.cached;
344
- const cacheWrite = "cache_creation_input_tokens" in usage ? number("cache_creation_input_tokens") : current.cacheWrite;
400
+ const cacheWrite = "cache_creation_input_tokens" in usage
401
+ ? typeof usage.cache_creation_input_tokens === "number" && Number.isFinite(usage.cache_creation_input_tokens) ? number("cache_creation_input_tokens") : undefined
402
+ : current.cacheWrite;
345
403
  const output = "output_tokens" in usage ? number("output_tokens") : current.output;
346
- this.usage = { input, cached, ...(cacheWrite && cacheWrite > 0 ? { cacheWrite } : {}), output };
404
+ this.usage = { input, cached, ...(cacheWrite !== undefined ? { cacheWrite } : {}), output };
347
405
  }
348
406
  }
349
407
  export function requestId() {
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.9.0";
8
+ export const VERSION = "0.9.2";