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.
- package/CHANGELOG.md +49 -0
- package/README.es.md +22 -11
- package/README.ja.md +14 -5
- package/README.ko.md +19 -8
- package/README.md +22 -10
- package/README.pt-BR.md +22 -11
- package/README.zh-CN.md +14 -5
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +2 -1
- package/dist/mod/hooks/i18n.ts +36 -12
- package/dist/mod/hooks/judge.ts +17 -1
- package/dist/mod/hooks/register.tsx +39 -9
- package/dist/mod/hooks/session.ts +11 -3
- package/dist/mod/hooks/ui/alerts.tsx +21 -5
- package/dist/mod/hooks/ui/band.tsx +1 -1
- package/dist/mod/hooks/ui/view.ts +4 -1
- package/dist/mod/tests/register.test.tsx +53 -0
- package/dist/mod/tests/ui.test.tsx +10 -6
- package/dist/mod/types/index.d.ts +4 -1
- package/dist/router/src/admin.js +72 -2
- package/dist/router/src/agents.js +4 -0
- package/dist/router/src/config.js +24 -0
- package/dist/router/src/ingress/server.js +2 -2
- package/dist/router/src/mod-settings.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +19 -6
- package/dist/router/src/providers/chatgpt/translate.js +3 -0
- package/dist/router/src/providers/google/index.js +4 -3
- package/dist/router/src/providers/grok/auth.js +289 -0
- package/dist/router/src/providers/grok/catalog.js +52 -0
- package/dist/router/src/providers/grok/index.js +94 -0
- package/dist/router/src/providers/openai/index.js +71 -17
- package/dist/router/src/providers/openai/translate.js +11 -1
- package/dist/router/src/proxy.js +91 -21
- package/dist/router/src/redact.js +7 -2
- package/dist/router/src/requestlog.js +78 -20
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +105 -0
- package/dist/ui/i18n.js +6 -6
- package/docs/ARCHITECTURE.md +91 -0
- package/package.json +1 -1
package/dist/router/src/proxy.js
CHANGED
|
@@ -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
|
-
|
|
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, { ...
|
|
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, { ...
|
|
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, { ...
|
|
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)
|
|
15
|
-
|
|
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 {
|
|
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.9.
|
|
8
|
+
export const VERSION = "0.9.2";
|