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.
- package/CHANGELOG.md +60 -0
- package/README.es.md +21 -15
- package/README.ja.md +13 -9
- package/README.ko.md +23 -9
- package/README.md +26 -11
- package/README.pt-BR.md +21 -15
- package/README.zh-CN.md +13 -9
- package/dist/app/package.json +1 -1
- package/dist/cli/src/codex.js +3 -1
- package/dist/cli/src/settings.js +5 -2
- package/dist/mod/.claude-plugin/plugin.json +9 -1
- package/dist/mod/hooks/cache.ts +60 -0
- package/dist/mod/hooks/compact.ts +40 -0
- package/dist/mod/hooks/handoff.ts +43 -0
- package/dist/mod/hooks/i18n.ts +2716 -0
- package/dist/mod/hooks/judge.ts +242 -0
- package/dist/mod/hooks/ladder.js +179 -0
- package/dist/mod/hooks/ladder.ts +174 -0
- package/dist/mod/hooks/modes.ts +93 -0
- package/dist/mod/hooks/register.tsx +1458 -256
- package/dist/mod/hooks/session.ts +62 -0
- package/dist/mod/hooks/settings.js +342 -0
- package/dist/mod/hooks/settings.ts +321 -0
- package/dist/mod/hooks/stats.ts +59 -0
- package/dist/mod/hooks/ui/agents.tsx +138 -0
- package/dist/mod/hooks/ui/alerts.tsx +373 -0
- package/dist/mod/hooks/ui/art.ts +628 -0
- package/dist/mod/hooks/ui/band.tsx +836 -0
- package/dist/mod/hooks/ui/kit.tsx +258 -0
- package/dist/mod/hooks/ui/log.tsx +109 -0
- package/dist/mod/hooks/ui/parts.tsx +217 -0
- package/dist/mod/hooks/ui/ripple.ts +614 -0
- package/dist/mod/hooks/ui/settings.tsx +376 -0
- package/dist/mod/hooks/ui/setup.tsx +148 -0
- package/dist/mod/hooks/ui/theme.ts +217 -0
- package/dist/mod/hooks/ui/view.ts +371 -0
- package/dist/mod/hooks/worker.ts +1 -1
- package/dist/mod/hooks/workers.ts +217 -0
- package/dist/mod/tests/register.test.tsx +1730 -0
- package/dist/mod/tests/ui.test.tsx +853 -0
- package/dist/mod/tsconfig.json +2 -1
- package/dist/mod/types/index.d.ts +320 -1
- package/dist/router/src/admin.js +170 -52
- package/dist/router/src/agents.js +24 -5
- package/dist/router/src/bootstrap.js +4 -2
- package/dist/router/src/config.js +3 -0
- package/dist/router/src/health.js +33 -0
- package/dist/router/src/index.js +1 -0
- package/dist/router/src/ingress/models.js +4 -1
- package/dist/router/src/mod-settings.js +120 -0
- package/dist/router/src/model-policy.js +6 -0
- package/dist/router/src/model-scores.js +159 -0
- package/dist/router/src/picker.js +7 -0
- package/dist/router/src/providers/chatgpt/index.js +23 -5
- package/dist/router/src/providers/chatgpt/translate.js +4 -1
- package/dist/router/src/providers/google/index.js +8 -3
- package/dist/router/src/providers/openai/index.js +6 -1
- package/dist/router/src/providers/openai/translate.js +6 -1
- package/dist/router/src/proxy.js +62 -24
- package/dist/router/src/requestlog.js +78 -20
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +908 -209
- package/dist/ui/favicon-32.png +0 -0
- package/dist/ui/favicon.svg +12 -0
- package/dist/ui/i18n.js +919 -233
- package/dist/ui/index.html +159 -87
- package/dist/ui/style.css +789 -410
- package/docs/ARCHITECTURE.md +141 -0
- package/package.json +7 -3
package/dist/router/src/proxy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
222
|
-
|
|
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 =
|
|
239
|
+
const newline = buffer.indexOf("\n", start);
|
|
236
240
|
if (newline < 0)
|
|
237
|
-
|
|
238
|
-
const raw =
|
|
239
|
-
|
|
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
|
|
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
|
|
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
|
+
export const VERSION = "0.9.1";
|