clauderipple 0.8.10 → 0.9.1

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 (69) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.es.md +21 -15
  3. package/README.ja.md +13 -9
  4. package/README.ko.md +23 -9
  5. package/README.md +26 -11
  6. package/README.pt-BR.md +21 -15
  7. package/README.zh-CN.md +13 -9
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +242 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +373 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +120 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +23 -5
  55. package/dist/router/src/providers/chatgpt/translate.js +4 -1
  56. package/dist/router/src/providers/google/index.js +8 -3
  57. package/dist/router/src/providers/openai/index.js +6 -1
  58. package/dist/router/src/providers/openai/translate.js +6 -1
  59. package/dist/router/src/proxy.js +62 -24
  60. package/dist/router/src/requestlog.js +78 -20
  61. package/dist/router/src/version.js +1 -1
  62. package/dist/ui/app.js +908 -209
  63. package/dist/ui/favicon-32.png +0 -0
  64. package/dist/ui/favicon.svg +12 -0
  65. package/dist/ui/i18n.js +919 -233
  66. package/dist/ui/index.html +159 -87
  67. package/dist/ui/style.css +789 -410
  68. package/docs/ARCHITECTURE.md +141 -0
  69. package/package.json +7 -3
@@ -18,9 +18,9 @@ import https from "node:https";
18
18
  import net from "node:net";
19
19
  import tls from "node:tls";
20
20
  import zlib from "node:zlib";
21
- import { UpstreamHealth } from "./health.js";
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";
@@ -32,7 +32,7 @@ import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
32
32
  import { GoogleAdapter } from "./providers/google/index.js";
33
33
  import { conversationKey } from "./providers/chatgpt/translate.js";
34
34
  import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
35
- import { injectPickerModels, isBootstrapPath } from "./picker.js";
35
+ import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
36
36
  import { effortLevelsForModel } from "./admin.js";
37
37
  import { ResponseUsageTap } from "./requestlog.js";
38
38
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
@@ -198,6 +198,7 @@ export class Proxy {
198
198
  * its quarantine again on the first request.
199
199
  */
200
200
  pool = new CredentialPool();
201
+ providerHealth = new ProviderHealth();
201
202
  /** Latest rate-limit snapshot reported by any chatgpt provider (for the admin GUI). */
202
203
  get chatgptRateLimits() {
203
204
  const out = {};
@@ -628,6 +629,16 @@ export class Proxy {
628
629
  completed.note = note;
629
630
  if (extra?.resent && !completed.ok)
630
631
  completed.resent = true;
632
+ if (extra?.messageId !== undefined)
633
+ completed.messageId = extra.messageId;
634
+ if (extra?.requestedTier !== undefined)
635
+ completed.requestedTier = extra.requestedTier;
636
+ if (extra?.servedTier !== undefined)
637
+ completed.servedTier = extra.servedTier;
638
+ if (extra?.upstreamCalled === false)
639
+ completed.upstreamCalled = false;
640
+ if (extra?.refusal !== undefined)
641
+ completed.refusal = extra.refusal;
631
642
  this.deps.requests.add(completed);
632
643
  }
633
644
  };
@@ -638,7 +649,7 @@ export class Proxy {
638
649
  const msg = JSON.stringify({ type: "error", error: { type: "overloaded_error", message: "ClaudeRipple is restarting; retry" } });
639
650
  res.writeHead(503, { "content-type": "application/json", "retry-after": "3", connection: "close", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
640
651
  req.resume();
641
- finish("503", msg.length, "refused during drain (client retries)", false, { resent: true });
652
+ finish("503", msg.length, "refused during drain (client retries)", false, { resent: true, upstreamCalled: false, refusal: "drain" });
642
653
  return;
643
654
  }
644
655
  let body;
@@ -672,9 +683,17 @@ export class Proxy {
672
683
  // backend is configured: `cfg.webSearch` decides who answers it, but knowing that this is a
673
684
  // search is what lets the guard below refuse a provider that cannot run one. Tying the two
674
685
  // together meant an unconfigured router could not tell a search from any other turn.
686
+ if (json) {
687
+ record.source = record.target = typeof model === "string" ? model : "-";
688
+ record.stream = json.stream === true;
689
+ if (isAutoModeClassifier(json))
690
+ record.purpose = "auto-mode-classifier";
691
+ }
675
692
  const search = json && isApiHost && pathname === "/v1/messages" ? webSearchQuery(json) : null;
693
+ let searchAttempted = false;
694
+ const markSearchAttempted = () => { searchAttempted = true; };
676
695
  if (search && cfg.webSearch) {
677
- const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish);
696
+ const served = await this.serveWebSearch(res, json, search, cfg.webSearch, record, finish, markSearchAttempted);
678
697
  if (served)
679
698
  return;
680
699
  // Fall through on failure: the ordinary path still answers, and a search that quietly
@@ -693,7 +712,7 @@ export class Proxy {
693
712
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }).end(payload);
694
713
  tag = `REFUSE ${model}`;
695
714
  record = { ...record, provider: "refused" };
696
- finish("400", payload.length, `ClaudeRipple: ${reason}`, true);
715
+ finish("400", payload.length, `ClaudeRipple: ${reason}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unroutable" });
697
716
  return;
698
717
  }
699
718
  }
@@ -734,7 +753,7 @@ export class Proxy {
734
753
  // The same holds for a plan that searches on another of its endpoints (`anthropicSearch`).
735
754
  const own = (cfg.providers[route.provider]?.type === "chatgpt" || searchProviderFor(cfg.providers[route.provider], route.model) !== undefined) &&
736
755
  cfg.webSearch?.provider !== route.provider;
737
- if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish)))
756
+ if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish, markSearchAttempted)))
738
757
  return;
739
758
  const blocks = webSearchErrorBlocks(search, "unavailable");
740
759
  const model = typeof json.model === "string" ? json.model : "unknown";
@@ -744,7 +763,8 @@ export class Proxy {
744
763
  ? { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
745
764
  : { "content-type": "application/json" }).end(payload);
746
765
  log.warn(`web search: ${route.provider} cannot run server tools and no webSearch backend is configured; refused rather than answered with invented results`);
747
- finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false);
766
+ record = { ...record, target: route.model, provider: route.provider, purpose: "web-search" };
767
+ finish("200", Buffer.byteLength(payload), `web search refused: ${route.provider} runs no server tools`, false, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "web-search-unsupported" });
748
768
  return;
749
769
  }
750
770
  let compatCaps;
@@ -754,9 +774,10 @@ export class Proxy {
754
774
  let penalised = null;
755
775
  let target;
756
776
  if (route && json) {
777
+ record = { ...record, target: route.model, provider: route.provider };
757
778
  const configured = cfg.providers[route.provider];
758
779
  if (!configured) {
759
- finish("500", 0, `unknown provider ${route.provider}`);
780
+ finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
760
781
  res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
761
782
  return;
762
783
  }
@@ -770,12 +791,11 @@ export class Proxy {
770
791
  if (td === "refuse") {
771
792
  const out = JSON.stringify(THREAD_UNSUPPORTED);
772
793
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
773
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
794
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
774
795
  return;
775
796
  }
776
797
  if (td === "strip")
777
798
  stripThreadFields(json);
778
- record = { ...record, target: route.model, provider: route.provider };
779
799
  const routeEffort = effortOf(json);
780
800
  if (routeEffort)
781
801
  record.effort = routeEffort;
@@ -784,7 +804,9 @@ export class Proxy {
784
804
  // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
785
805
  // here fails over once every account is out (chooseTarget asks the adapter).
786
806
  const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
787
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
807
+ if (!path.startsWith("/v1/messages/count_tokens"))
808
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
809
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
788
810
  }
789
811
  catch (e) {
790
812
  finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
@@ -800,12 +822,11 @@ export class Proxy {
800
822
  if (td === "refuse") {
801
823
  const out = JSON.stringify(THREAD_UNSUPPORTED);
802
824
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
803
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
825
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
804
826
  return;
805
827
  }
806
828
  if (td === "strip")
807
829
  stripThreadFields(json);
808
- record = { ...record, target: route.model, provider: route.provider };
809
830
  const routeEffort = effortOf(json);
810
831
  if (routeEffort)
811
832
  record.effort = routeEffort;
@@ -813,7 +834,9 @@ export class Proxy {
813
834
  try {
814
835
  const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
815
836
  this.recordOutcome(route.provider, o.status);
816
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
837
+ if (!path.startsWith("/v1/messages/count_tokens"))
838
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
839
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
817
840
  }
818
841
  catch (e) {
819
842
  this.recordOutcome(route.provider, 0);
@@ -832,12 +855,11 @@ export class Proxy {
832
855
  if (td === "refuse") {
833
856
  const out = JSON.stringify(THREAD_UNSUPPORTED);
834
857
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
835
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
858
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
836
859
  return;
837
860
  }
838
861
  if (td === "strip")
839
862
  stripThreadFields(json);
840
- record = { ...record, target: route.model, provider: route.provider };
841
863
  const routeEffort = effortOf(json);
842
864
  if (routeEffort)
843
865
  record.effort = routeEffort;
@@ -845,7 +867,9 @@ export class Proxy {
845
867
  try {
846
868
  const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
847
869
  this.recordOutcome(route.provider, o.status);
848
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
870
+ if (!path.startsWith("/v1/messages/count_tokens"))
871
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
872
+ finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
849
873
  }
850
874
  catch (e) {
851
875
  this.recordOutcome(route.provider, 0);
@@ -869,7 +893,7 @@ export class Proxy {
869
893
  if (credentials.length === 0) {
870
894
  const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
871
895
  res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
872
- finish("401", out.length, "no usable Claude account");
896
+ finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
873
897
  return;
874
898
  }
875
899
  dropForeignThinking(json);
@@ -888,7 +912,6 @@ export class Proxy {
888
912
  dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
889
913
  dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
890
914
  };
891
- record = { ...record, target: route.model, provider: route.provider };
892
915
  const routeEffort = effortOf(json);
893
916
  if (routeEffort)
894
917
  record.effort = routeEffort;
@@ -903,7 +926,7 @@ export class Proxy {
903
926
  if (threadDecision(json) === "refuse") {
904
927
  const out = JSON.stringify(THREAD_UNSUPPORTED);
905
928
  res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
906
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
929
+ finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
907
930
  return;
908
931
  }
909
932
  const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
@@ -954,7 +977,6 @@ export class Proxy {
954
977
  // vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
955
978
  basePath: u.pathname.replace(/\/+$/, ""),
956
979
  };
957
- record = { ...record, target: route.model, provider: route.provider };
958
980
  const routeEffort = effortOf(json);
959
981
  if (routeEffort)
960
982
  record.effort = routeEffort;
@@ -972,6 +994,8 @@ export class Proxy {
972
994
  target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
973
995
  tag = `WEB ${reqHost}`;
974
996
  }
997
+ if (json && typeof json.service_tier === "string")
998
+ record.requestedTier = json.service_tier;
975
999
  // Two kinds of response editing: the CLI bootstrap (api host) and the claude.ai bootstrap (picker mode).
976
1000
  const isCliBootstrap = isApiHost && !route && path.startsWith(BOOTSTRAP_PATH);
977
1001
  const isPickerBootstrap = !isApiHost && !!cfg.picker?.enabled && method === "GET" && isBootstrapPath(path);
@@ -1224,6 +1248,9 @@ export class Proxy {
1224
1248
  send(withCredential(headers, retry.headers, usedCredentialHeaders));
1225
1249
  return;
1226
1250
  }
1251
+ const native = isApiHost && (!route || cfg.providers[route.provider]?.type === "anthropic");
1252
+ if (native && typeof upRes.headers["request-id"] === "string")
1253
+ record.upstreamRequestId = upRes.headers["request-id"];
1227
1254
  const outHeaders = [];
1228
1255
  const r = upRes.rawHeaders;
1229
1256
  for (let i = 0; i < r.length; i += 2) {
@@ -1274,7 +1301,7 @@ export class Proxy {
1274
1301
  else if (status === 200 && isPickerBootstrap) {
1275
1302
  try {
1276
1303
  const j = JSON.parse(out.toString("utf8"));
1277
- const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1304
+ const r = injectPickerModels(j, chatPickerModels(cfg), cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1278
1305
  if (r.surfaces.length > 0) {
1279
1306
  this.lastPickerInjection = { at: new Date().toISOString(), ...r };
1280
1307
  out = Buffer.from(JSON.stringify(j));
@@ -1336,7 +1363,15 @@ export class Proxy {
1336
1363
  const observed = tap.finish();
1337
1364
  observedUsage = observed.usage;
1338
1365
  observedStopReason = observed.stopReason;
1366
+ if (!native && observed.messageId !== undefined)
1367
+ record.messageId = observed.messageId;
1368
+ if (observed.servedTier !== undefined)
1369
+ record.servedTier = observed.servedTier;
1339
1370
  res.end();
1371
+ const errorBody = status >= 400 ? decodeBodyToText(Buffer.concat(errorHead), upRes.headers["content-encoding"]) : "";
1372
+ if (route && path.startsWith("/v1/messages") && !path.startsWith("/v1/messages/count_tokens")) {
1373
+ this.providerHealth.observe(route.provider, status, providerExhaustion(status, errorBody, retryAfterMs(upRes.headers)));
1374
+ }
1340
1375
  const note = status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], [...errorSecrets], upRes.headers["content-type"])}` : undefined;
1341
1376
  // Anthropic forgets a conversation's server-side thread after a few idle minutes, and the CLI
1342
1377
  // answers the 404 by replaying the whole conversation, which hits the prompt cache. From CLI
@@ -1360,7 +1395,7 @@ export class Proxy {
1360
1395
  * model. Returns false when it could not be served, and the caller falls back to the normal path:
1361
1396
  * an empty search result is the one outcome worth avoiding, because nothing anywhere reports it.
1362
1397
  */
1363
- async serveWebSearch(res, json, query, settings, record, finish) {
1398
+ async serveWebSearch(res, json, query, settings, record, finish, markAttempted) {
1364
1399
  const configured = this.deps.config().providers[settings.provider];
1365
1400
  if (!configured) {
1366
1401
  this.deps.log.warn(`web search: unknown provider ${settings.provider}; leaving the request alone`);
@@ -1411,6 +1446,7 @@ export class Proxy {
1411
1446
  const model = typeof json.model === "string" ? json.model : "unknown";
1412
1447
  let blocks;
1413
1448
  try {
1449
+ markAttempted();
1414
1450
  const outcome = await backend.search(query);
1415
1451
  blocks = webSearchBlocks(query, outcome).blocks;
1416
1452
  }
@@ -1419,6 +1455,8 @@ export class Proxy {
1419
1455
  return false;
1420
1456
  }
1421
1457
  record.target = `${settings.provider}/${settings.model}`;
1458
+ record.provider = settings.provider;
1459
+ record.purpose = "web-search";
1422
1460
  const wantStream = json.stream === true;
1423
1461
  const payload = wantStream ? webSearchSse(model, blocks, 1) : JSON.stringify(webSearchMessage(model, blocks, 1));
1424
1462
  const headers = wantStream
@@ -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.8.10";
8
+ export const VERSION = "0.9.1";