clauderipple 0.9.4 → 0.10.0
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 +80 -0
- package/README.md +31 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/hooks/agent-title.js +7 -4
- package/dist/cli/src/index.js +43 -0
- package/dist/cli/src/remote.js +263 -0
- package/dist/cli/src/schtasks.js +48 -40
- package/dist/cli/src/settings.js +2 -1
- package/dist/mod/hooks/i18n.ts +252 -6
- package/dist/mod/hooks/judge.ts +35 -17
- package/dist/mod/hooks/modes.ts +56 -1
- package/dist/mod/hooks/register.tsx +238 -32
- package/dist/mod/hooks/session.ts +98 -1
- package/dist/mod/hooks/settings.js +7 -3
- package/dist/mod/hooks/settings.ts +9 -4
- package/dist/mod/hooks/stats.ts +25 -1
- package/dist/mod/hooks/ui/agents.tsx +858 -79
- package/dist/mod/hooks/ui/alerts.tsx +20 -17
- package/dist/mod/hooks/ui/art.ts +33 -7
- package/dist/mod/hooks/ui/band.tsx +262 -81
- package/dist/mod/hooks/ui/flat.ts +265 -0
- package/dist/mod/hooks/ui/kit.tsx +70 -55
- package/dist/mod/hooks/ui/look.ts +129 -0
- package/dist/mod/hooks/ui/settings.tsx +71 -46
- package/dist/mod/hooks/ui/setup.tsx +15 -13
- package/dist/mod/hooks/ui/theme.ts +3 -0
- package/dist/mod/hooks/ui/tools.ts +124 -0
- package/dist/mod/hooks/ui/view.ts +116 -3
- package/dist/mod/hooks/workers.ts +33 -6
- package/dist/mod/tests/register.test.tsx +155 -11
- package/dist/mod/tests/ui.test.tsx +375 -27
- package/dist/mod/types/index.d.ts +68 -2
- package/dist/remote/package.json +28 -0
- package/dist/remote/src/away.js +85 -0
- package/dist/remote/src/bridge-client.js +295 -0
- package/dist/remote/src/composer-menu.js +11 -0
- package/dist/remote/src/config.js +102 -0
- package/dist/remote/src/desktop.js +1018 -0
- package/dist/remote/src/e2ee.js +126 -0
- package/dist/remote/src/e2ee.ts +126 -0
- package/dist/remote/src/eventlog.js +298 -0
- package/dist/remote/src/expo-push.js +127 -0
- package/dist/remote/src/folders.js +79 -0
- package/dist/remote/src/fsutil.js +39 -0
- package/dist/remote/src/handoff.js +38 -0
- package/dist/remote/src/host-service.js +168 -0
- package/dist/remote/src/images.js +215 -0
- package/dist/remote/src/local-control.js +138 -0
- package/dist/remote/src/main.js +264 -0
- package/dist/remote/src/permissions.js +124 -0
- package/dist/remote/src/power.js +76 -0
- package/dist/remote/src/processes.js +24 -0
- package/dist/remote/src/protocol.js +32 -0
- package/dist/remote/src/protocol.ts +284 -0
- package/dist/remote/src/push-content.js +32 -0
- package/dist/remote/src/push.js +134 -0
- package/dist/remote/src/receipts.js +111 -0
- package/dist/remote/src/relay.js +438 -0
- package/dist/remote/src/router.js +208 -0
- package/dist/remote/src/sdk-launcher.js +18 -0
- package/dist/remote/src/server.js +706 -0
- package/dist/remote/src/service.js +157 -0
- package/dist/remote/src/sessions.js +1800 -0
- package/dist/remote/src/sidebar-storage.js +211 -0
- package/dist/remote/src/sidebar.js +173 -0
- package/dist/remote/src/tool-details.js +205 -0
- package/dist/remote/src/tool-details.ts +153 -0
- package/dist/remote/src/worktree.js +99 -0
- package/dist/remote/web/app.ts +746 -0
- package/dist/remote/web/conn.ts +193 -0
- package/dist/remote/web/demo.ts +134 -0
- package/dist/remote/web/dom.ts +124 -0
- package/dist/remote/web/esc.ts +4 -0
- package/dist/remote/web/i18n.ts +374 -0
- package/dist/remote/web/icon.svg +1 -0
- package/dist/remote/web/index.html +24 -0
- package/dist/remote/web/manifest.webmanifest +10 -0
- package/dist/remote/web/mascot.ts +60 -0
- package/dist/remote/web/md.ts +78 -0
- package/dist/remote/web/notify.ts +55 -0
- package/dist/remote/web/pushstate.ts +27 -0
- package/dist/remote/web/secure.ts +109 -0
- package/dist/remote/web/sidebar.ts +106 -0
- package/dist/remote/web/style.css +311 -0
- package/dist/remote/web/sw.ts +48 -0
- package/dist/remote/web/timeline.ts +224 -0
- package/dist/remote/web/views.ts +323 -0
- package/dist/router/src/admin.js +227 -4
- package/dist/router/src/config.js +30 -0
- package/dist/router/src/index.js +39 -0
- package/dist/router/src/ingress/translate.js +6 -2
- package/dist/router/src/phone.js +82 -0
- package/dist/router/src/presets.js +3 -0
- package/dist/router/src/providers/anthropic-usage.js +33 -1
- package/dist/router/src/providers/chatgpt/index.js +41 -11
- package/dist/router/src/providers/chatgpt/translate.js +147 -7
- package/dist/router/src/providers/google/index.js +26 -0
- package/dist/router/src/providers/google/prompt-refusal.js +29 -0
- package/dist/router/src/providers/google/usage.js +257 -0
- package/dist/router/src/providers/grok/index.js +18 -0
- package/dist/router/src/providers/grok/usage.js +77 -0
- package/dist/router/src/providers/openai/index.js +29 -5
- package/dist/router/src/providers/openai/translate.js +260 -27
- package/dist/router/src/providers/opencode-usage.js +116 -0
- package/dist/router/src/proxy.js +339 -258
- package/dist/router/src/reasoning.js +157 -0
- package/dist/router/src/remote-host.js +320 -0
- package/dist/router/src/remote-tunnel.js +320 -0
- package/dist/router/src/requestlog.js +48 -39
- package/dist/router/src/thinking.js +20 -9
- package/dist/router/src/usage-history.js +227 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +436 -59
- package/dist/ui/i18n.js +184 -0
- package/dist/ui/index.html +24 -5
- package/dist/ui/style.css +39 -0
- package/docs/ARCHITECTURE.md +164 -12
- package/package.json +7 -1
package/dist/router/src/proxy.js
CHANGED
|
@@ -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 { OpenCodeUsage, isOpenCodeGo } from "./providers/opencode-usage.js";
|
|
33
34
|
import { GrokAdapter } from "./providers/grok/index.js";
|
|
34
35
|
import { conversationKey } from "./providers/chatgpt/translate.js";
|
|
35
36
|
import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
|
|
@@ -193,6 +194,8 @@ export class Proxy {
|
|
|
193
194
|
claudeUsage;
|
|
194
195
|
/** The Claude models the signed-in account can use, from Anthropic's own list. */
|
|
195
196
|
claudeModels;
|
|
197
|
+
/** Each OpenCode Go provider's usage windows, for the status bar's limits row (never a key). */
|
|
198
|
+
opencodeUsage;
|
|
196
199
|
deps;
|
|
197
200
|
/**
|
|
198
201
|
* Cooldowns, quarantines and conversation stickiness for provider credentials. In memory: a
|
|
@@ -343,6 +346,27 @@ export class Proxy {
|
|
|
343
346
|
return Promise.resolve(null);
|
|
344
347
|
return this.google(name, cfg).fetchModels();
|
|
345
348
|
}
|
|
349
|
+
/** Each google provider account's quota groups by owner id, for the status bar's limits row. */
|
|
350
|
+
googleUsage(name) {
|
|
351
|
+
const cfg = this.deps.config().providers[name];
|
|
352
|
+
if (!cfg || cfg.type !== "google" || cfg.auth !== "antigravity")
|
|
353
|
+
return Promise.resolve({});
|
|
354
|
+
return this.google(name, cfg).usageSnapshot();
|
|
355
|
+
}
|
|
356
|
+
/** A Grok provider's usage pool, for the status bar's limits row. Empty otherwise. */
|
|
357
|
+
grokLimits(name) {
|
|
358
|
+
const cfg = this.deps.config().providers[name];
|
|
359
|
+
if (!cfg || cfg.type !== "grok")
|
|
360
|
+
return Promise.resolve([]);
|
|
361
|
+
return this.grok(name, cfg).usage();
|
|
362
|
+
}
|
|
363
|
+
/** An OpenCode Go provider's usage windows, for the status bar's limits row. Empty otherwise. */
|
|
364
|
+
opencodeLimits(name) {
|
|
365
|
+
const cfg = this.deps.config().providers[name];
|
|
366
|
+
if (!cfg || cfg.type !== "openai-compatible" || !isOpenCodeGo(cfg))
|
|
367
|
+
return Promise.resolve([]);
|
|
368
|
+
return this.opencodeUsage.windows(name, cfg.url, cfg.headers ?? {});
|
|
369
|
+
}
|
|
346
370
|
/** Dashboard action: put one resting Google account back into rotation now. */
|
|
347
371
|
googleClearCooldown(ownerId) {
|
|
348
372
|
for (const [name, p] of Object.entries(this.deps.config().providers)) {
|
|
@@ -376,6 +400,7 @@ export class Proxy {
|
|
|
376
400
|
upstream: () => this.deps.config().upstream,
|
|
377
401
|
warn: (message) => deps.log.warn(message),
|
|
378
402
|
});
|
|
403
|
+
this.opencodeUsage = new OpenCodeUsage({ warn: (message) => deps.log.warn(message) });
|
|
379
404
|
this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
|
|
380
405
|
void this.handle(req, res);
|
|
381
406
|
});
|
|
@@ -605,12 +630,16 @@ export class Proxy {
|
|
|
605
630
|
record.session = session;
|
|
606
631
|
let observedUsage;
|
|
607
632
|
let observedStopReason;
|
|
633
|
+
let promptFallbackNote;
|
|
634
|
+
let promptRefusalSeen = false;
|
|
608
635
|
// `failed` counts requests that did not get a proper response (vanished, upstream error,
|
|
609
636
|
// provider error). A note alone is not a failure: adapters attach usage notes on success.
|
|
610
637
|
const finish = (status, bytes, note, failed = note !== undefined, extra) => {
|
|
611
638
|
if (finished)
|
|
612
639
|
return;
|
|
613
640
|
finished = true;
|
|
641
|
+
if (promptFallbackNote)
|
|
642
|
+
note = [promptFallbackNote, note].filter(Boolean).join("; ");
|
|
614
643
|
this.stats.inFlight--;
|
|
615
644
|
if (isMessages)
|
|
616
645
|
this.stats.messagesInFlight--;
|
|
@@ -646,7 +675,7 @@ export class Proxy {
|
|
|
646
675
|
completed.requestedTier = extra.requestedTier;
|
|
647
676
|
if (extra?.servedTier !== undefined)
|
|
648
677
|
completed.servedTier = extra.servedTier;
|
|
649
|
-
if (extra?.upstreamCalled === false)
|
|
678
|
+
if (extra?.upstreamCalled === false && !promptRefusalSeen)
|
|
650
679
|
completed.upstreamCalled = false;
|
|
651
680
|
if (extra?.refusal !== undefined)
|
|
652
681
|
completed.refusal = extra.refusal;
|
|
@@ -728,10 +757,11 @@ export class Proxy {
|
|
|
728
757
|
}
|
|
729
758
|
}
|
|
730
759
|
// A slot with fallbacks picks its provider before anything is sent, so a primary that is rate
|
|
731
|
-
// limited for the next hour is skipped rather than rediscovered once per request.
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
760
|
+
// limited for the next hour is skipped rather than rediscovered once per request. A distinct
|
|
761
|
+
// prompt refusal can also select one fallback below, but only before any response is written.
|
|
762
|
+
let route = resolved ? this.chooseTarget(resolved, cfg, log) : null;
|
|
763
|
+
/** The request before any target rewrote it, kept only when a Google target may hand the turn back. */
|
|
764
|
+
let pristine;
|
|
735
765
|
const source = typeof model === "string" ? model : "-";
|
|
736
766
|
record = {
|
|
737
767
|
...record,
|
|
@@ -784,258 +814,292 @@ export class Proxy {
|
|
|
784
814
|
let chosen = null;
|
|
785
815
|
let penalised = null;
|
|
786
816
|
let target;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
rewriteBody(json, route, cfg.effortClamp);
|
|
800
|
-
if (provider.type === "chatgpt") {
|
|
801
|
-
const td = threadDecision(json);
|
|
802
|
-
if (td === "refuse") {
|
|
803
|
-
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
804
|
-
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
805
|
-
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
817
|
+
// Re-enter only for the one unwritten Google prompt refusal. Each target sees the original
|
|
818
|
+
// request, not the primary's rewritten effort, stripped thread, or translated body.
|
|
819
|
+
for (;;) {
|
|
820
|
+
if (pristine)
|
|
821
|
+
json = structuredClone(pristine);
|
|
822
|
+
if (route && json) {
|
|
823
|
+
record = { ...record, target: route.model, provider: route.provider };
|
|
824
|
+
delete record.effort;
|
|
825
|
+
const configured = cfg.providers[route.provider];
|
|
826
|
+
if (!configured) {
|
|
827
|
+
finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
|
|
828
|
+
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
|
|
806
829
|
return;
|
|
807
830
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
if (
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
if (
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
831
|
+
// A model may speak another wire than the rest of its provider: one subscription serving
|
|
832
|
+
// several protocols is one provider, and this is where the model's own shape is folded in so
|
|
833
|
+
// the branches below pick the adapter it actually needs.
|
|
834
|
+
const provider = providerFor(configured, route.model);
|
|
835
|
+
// Only the Google adapter can return a turn unwritten, so only it pays for the copy.
|
|
836
|
+
if (!pristine && provider.type === "google")
|
|
837
|
+
pristine = structuredClone(json);
|
|
838
|
+
rewriteBody(json, route, cfg.effortClamp);
|
|
839
|
+
if (provider.type === "chatgpt") {
|
|
840
|
+
const td = threadDecision(json);
|
|
841
|
+
if (td === "refuse") {
|
|
842
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
843
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
844
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (td === "strip")
|
|
848
|
+
stripThreadFields(json);
|
|
849
|
+
const routeEffort = effortOf(json);
|
|
850
|
+
if (routeEffort)
|
|
851
|
+
record.effort = routeEffort;
|
|
852
|
+
tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
853
|
+
try {
|
|
854
|
+
// The adapter reports each account's outcome to the shared pool itself, so a slot pointing
|
|
855
|
+
// here fails over once every account is out (chooseTarget asks the adapter).
|
|
856
|
+
const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
|
|
857
|
+
if (!path.startsWith("/v1/messages/count_tokens"))
|
|
858
|
+
this.providerHealth.observe(route.provider, o.status, o.exhaustion);
|
|
859
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
|
|
860
|
+
}
|
|
861
|
+
catch (e) {
|
|
862
|
+
finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
|
|
863
|
+
if (!res.headersSent)
|
|
864
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
865
|
+
else
|
|
866
|
+
res.destroy();
|
|
867
|
+
}
|
|
837
868
|
return;
|
|
838
869
|
}
|
|
839
|
-
if (
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
869
|
-
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
870
|
+
if (provider.type === "openai-compatible") {
|
|
871
|
+
const td = threadDecision(json);
|
|
872
|
+
if (td === "refuse") {
|
|
873
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
874
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
875
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (td === "strip")
|
|
879
|
+
stripThreadFields(json);
|
|
880
|
+
const routeEffort = effortOf(json);
|
|
881
|
+
if (routeEffort)
|
|
882
|
+
record.effort = routeEffort;
|
|
883
|
+
tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
|
|
884
|
+
try {
|
|
885
|
+
const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
|
|
886
|
+
this.recordOutcome(route.provider, o.status);
|
|
887
|
+
if (!path.startsWith("/v1/messages/count_tokens"))
|
|
888
|
+
this.providerHealth.observe(route.provider, o.status, o.exhaustion);
|
|
889
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
|
|
890
|
+
}
|
|
891
|
+
catch (e) {
|
|
892
|
+
this.recordOutcome(route.provider, 0);
|
|
893
|
+
finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
|
|
894
|
+
if (!res.headersSent)
|
|
895
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
896
|
+
else
|
|
897
|
+
res.destroy();
|
|
898
|
+
}
|
|
870
899
|
return;
|
|
871
900
|
}
|
|
872
|
-
if (
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
901
|
+
if (provider.type === "google") {
|
|
902
|
+
// Google holds no server-side thread either, so a `continue` is refused as it is for the
|
|
903
|
+
// other translated providers; the CLI resends the turn with the full history.
|
|
904
|
+
const td = threadDecision(json);
|
|
905
|
+
if (td === "refuse") {
|
|
906
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
907
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
908
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (td === "strip")
|
|
912
|
+
stripThreadFields(json);
|
|
913
|
+
const routeEffort = effortOf(json);
|
|
914
|
+
if (routeEffort)
|
|
915
|
+
record.effort = routeEffort;
|
|
916
|
+
tag = `GOOGLE ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
917
|
+
try {
|
|
918
|
+
const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
|
|
919
|
+
if (o.kind === "prompt-incompatible") {
|
|
920
|
+
const alreadyRetried = promptRefusalSeen;
|
|
921
|
+
promptRefusalSeen = true;
|
|
922
|
+
if (res.destroyed || res.writableEnded || res.headersSent) {
|
|
923
|
+
finish("0", 0, "client closed before prompt-refusal fallback", true);
|
|
924
|
+
if (!res.destroyed && !res.writableEnded)
|
|
925
|
+
res.destroy();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const fallback = !alreadyRetried && resolved
|
|
929
|
+
? this.promptFallback(resolved, route, cfg) : null;
|
|
930
|
+
if (fallback) {
|
|
931
|
+
promptFallbackNote = `${o.note}; fallback ${route.provider}/${route.model} → ${fallback.provider}/${fallback.model}`;
|
|
932
|
+
log.info(`FAILOVER ${promptFallbackNote}`);
|
|
933
|
+
route = fallback;
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
// Refusal is request-specific: neither cool nor mark success for this provider.
|
|
937
|
+
res.writeHead(400, { "content-type": "application/json" }).end(o.errorBody);
|
|
938
|
+
finish("400", Buffer.byteLength(o.errorBody), o.note, true, { refusal: "prompt-incompatible" });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
this.recordOutcome(route.provider, o.status);
|
|
942
|
+
if (!path.startsWith("/v1/messages/count_tokens"))
|
|
943
|
+
this.providerHealth.observe(route.provider, o.status, o.exhaustion);
|
|
944
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
|
|
945
|
+
}
|
|
946
|
+
catch (e) {
|
|
947
|
+
this.recordOutcome(route.provider, 0);
|
|
948
|
+
finish("-", 0, `google error ${e.code ?? ""} ${e.message}`);
|
|
949
|
+
if (!res.headersSent)
|
|
950
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
951
|
+
else
|
|
952
|
+
res.destroy();
|
|
953
|
+
}
|
|
954
|
+
return;
|
|
892
955
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
956
|
+
if (provider.type === "grok") {
|
|
957
|
+
// The chat proxy keeps no server-side thread, so a `continue` is refused like on the other
|
|
958
|
+
// translated providers and the CLI resends the turn with the full history.
|
|
959
|
+
const td = threadDecision(json);
|
|
960
|
+
if (td === "refuse") {
|
|
961
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
962
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
963
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true });
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (td === "strip")
|
|
967
|
+
stripThreadFields(json);
|
|
968
|
+
record = { ...record, target: route.model, provider: route.provider };
|
|
969
|
+
const routeEffort = effortOf(json);
|
|
970
|
+
if (routeEffort)
|
|
971
|
+
record.effort = routeEffort;
|
|
972
|
+
tag = `GROK ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
973
|
+
try {
|
|
974
|
+
const o = await this.grok(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
|
|
975
|
+
this.recordOutcome(route.provider, o.status);
|
|
976
|
+
finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
|
|
977
|
+
}
|
|
978
|
+
catch (e) {
|
|
979
|
+
this.recordOutcome(route.provider, 0);
|
|
980
|
+
finish("-", 0, `grok error ${e.code ?? ""} ${e.message}`);
|
|
981
|
+
if (!res.headersSent)
|
|
982
|
+
res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
|
|
983
|
+
else
|
|
984
|
+
res.destroy();
|
|
985
|
+
}
|
|
903
986
|
return;
|
|
904
987
|
}
|
|
905
|
-
if (
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
988
|
+
if (provider.type === "anthropic") {
|
|
989
|
+
if (!provider.accountPool || provider.auth !== "claude-code") {
|
|
990
|
+
finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
|
|
991
|
+
res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "invalid_request_error", message: "native Anthropic provider is available through OpenAI ingress only" } }));
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
// Native Messages need no translation. Only authentication changes, so server-side threads,
|
|
995
|
+
// prompt caching, beta features and the client's exact tool contract remain first-party.
|
|
996
|
+
const credentials = await this.claudeAccounts.credentials();
|
|
997
|
+
if (credentials.length === 0) {
|
|
998
|
+
const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
|
|
999
|
+
res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
1000
|
+
finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
dropForeignThinking(json);
|
|
1004
|
+
body = Buffer.from(JSON.stringify(json));
|
|
1005
|
+
chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
|
|
1006
|
+
const using = chosen ?? credentials[0];
|
|
1007
|
+
chosen = using;
|
|
1008
|
+
penalised = { provider: route.provider, id: using.id };
|
|
1009
|
+
target = {
|
|
1010
|
+
protocol: "https:",
|
|
1011
|
+
host: cfg.upstream,
|
|
1012
|
+
port: this.deps.upstreamPort ?? 443,
|
|
1013
|
+
agent: this.deps.upstreamAgent ?? this.agentFor(route.provider, "https:"),
|
|
1014
|
+
extraHeaders: withClientBetas(using.headers, req.headers["anthropic-beta"]),
|
|
1015
|
+
dropClientAuth: true,
|
|
1016
|
+
dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
|
|
1017
|
+
dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
|
|
1018
|
+
};
|
|
1019
|
+
const routeEffort = effortOf(json);
|
|
1020
|
+
if (routeEffort)
|
|
1021
|
+
record.effort = routeEffort;
|
|
1022
|
+
tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
|
|
916
1023
|
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1024
|
+
else {
|
|
1025
|
+
// An Anthropic-shaped endpoint keeps no server-side thread either, so a `continue` is refused
|
|
1026
|
+
// here as it is for the translated providers. sanitizeForCompatible used to strip the field
|
|
1027
|
+
// and forward the rest, which is only the delta: DeepSeek (via Bailian) received two orphan
|
|
1028
|
+
// tool_results with no task and answered that none had been given (measured 2026-09-24, CLI
|
|
1029
|
+
// 2.1.280). Refused, the CLI resends the turn with the full history.
|
|
1030
|
+
if (threadDecision(json) === "refuse") {
|
|
1031
|
+
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
1032
|
+
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
1033
|
+
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
|
|
1037
|
+
const routeModel = route.model;
|
|
1038
|
+
const modelEffortLevels = provider.models?.find((entry) => entry.id === routeModel)?.effortLevels;
|
|
1039
|
+
compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
|
|
1040
|
+
const sanitized = sanitizeForCompatible(json, compatCaps);
|
|
1041
|
+
json = sanitized.json;
|
|
1042
|
+
compatChanges = sanitized.changes;
|
|
1043
|
+
// The upstream would otherwise read Claude Code's own system prompt and answer that it is
|
|
1044
|
+
// Claude. After sanitizing, so the effort named is the one the provider actually receives.
|
|
1045
|
+
applyIdentityToAnthropicBody(json, {
|
|
1046
|
+
model: route.model,
|
|
1047
|
+
effort: effortOf(json),
|
|
1048
|
+
identity: provider.identity,
|
|
1049
|
+
instructionsAppend: provider.instructionsAppend,
|
|
1050
|
+
});
|
|
1051
|
+
body = Buffer.from(JSON.stringify(json));
|
|
1052
|
+
const u = new URL(provider.url);
|
|
1053
|
+
const protocol = u.protocol === "https:" ? "https:" : "http:";
|
|
1054
|
+
// Which credential answers this turn. The conversation keeps the one it is on while that one
|
|
1055
|
+
// is healthy, because moving it moves the prompt cache with it. Every credential cooling or
|
|
1056
|
+
// quarantined leaves `chosen` null, and the request goes out on the configured headers so the
|
|
1057
|
+
// provider — not us — gets to say no.
|
|
1058
|
+
const credentials = this.credentialsOf(route.provider, provider);
|
|
1059
|
+
// The same key the prompt cache is keyed on. `metadata.user_id` alone is one value for every
|
|
1060
|
+
// conversation a user has, so using it raw would drag all of them onto one credential at once
|
|
1061
|
+
// — the opposite of what stickiness is for (see conversationKey and ARCHITECTURE §4).
|
|
1062
|
+
chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
|
|
1063
|
+
// Nothing usable means every credential is cooling, and the turn still has to go somewhere:
|
|
1064
|
+
// the first one, so the request carries real credentials rather than none, and so the answer
|
|
1065
|
+
// is charged to the credential that actually produced it.
|
|
1066
|
+
const using = chosen ?? credentials[0];
|
|
1067
|
+
chosen = using;
|
|
1068
|
+
penalised = { provider: route.provider, id: using.id };
|
|
1069
|
+
target = {
|
|
1070
|
+
protocol,
|
|
1071
|
+
host: u.hostname,
|
|
1072
|
+
port: Number(u.port) || (protocol === "https:" ? 443 : 80),
|
|
1073
|
+
agent: this.agentFor(route.provider, protocol),
|
|
1074
|
+
// The credential wins over the session header: they are different names in practice, but a
|
|
1075
|
+
// vendor that reused one would mean the request going out unauthenticated.
|
|
1076
|
+
extraHeaders: provider.sessionHeader
|
|
1077
|
+
? { [provider.sessionHeader]: conversationKey(json), ...using.headers }
|
|
1078
|
+
: using.headers,
|
|
1079
|
+
dropClientAuth: true,
|
|
1080
|
+
// Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
|
|
1081
|
+
// Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
|
|
1082
|
+
// vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
|
|
1083
|
+
basePath: u.pathname.replace(/\/+$/, ""),
|
|
1084
|
+
};
|
|
1085
|
+
const routeEffort = effortOf(json);
|
|
1086
|
+
if (routeEffort)
|
|
1087
|
+
record.effort = routeEffort;
|
|
1088
|
+
tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
924
1089
|
}
|
|
925
|
-
return;
|
|
926
1090
|
}
|
|
927
|
-
if (
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
// Native Messages need no translation. Only authentication changes, so server-side threads,
|
|
934
|
-
// prompt caching, beta features and the client's exact tool contract remain first-party.
|
|
935
|
-
const credentials = await this.claudeAccounts.credentials();
|
|
936
|
-
if (credentials.length === 0) {
|
|
937
|
-
const out = JSON.stringify({ type: "error", error: { type: "authentication_error", message: "ClaudeRipple: no usable Claude account; sign in or reauthenticate an account" } });
|
|
938
|
-
res.writeHead(401, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
939
|
-
finish("401", out.length, "no usable Claude account", true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "no-account" });
|
|
940
|
-
return;
|
|
941
|
-
}
|
|
942
|
-
dropForeignThinking(json);
|
|
943
|
-
body = Buffer.from(JSON.stringify(json));
|
|
944
|
-
chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
|
|
945
|
-
const using = chosen ?? credentials[0];
|
|
946
|
-
chosen = using;
|
|
947
|
-
penalised = { provider: route.provider, id: using.id };
|
|
948
|
-
target = {
|
|
949
|
-
protocol: "https:",
|
|
950
|
-
host: cfg.upstream,
|
|
951
|
-
port: this.deps.upstreamPort ?? 443,
|
|
952
|
-
agent: this.deps.upstreamAgent ?? this.agentFor(route.provider, "https:"),
|
|
953
|
-
extraHeaders: withClientBetas(using.headers, req.headers["anthropic-beta"]),
|
|
954
|
-
dropClientAuth: true,
|
|
955
|
-
dropHeaders: new Set(credentials.flatMap((credential) => Object.keys(credential.headers).map((name) => name.toLowerCase()))),
|
|
956
|
-
dropHeaderPrefixes: ["anthropic-client-", "x-stainless-"],
|
|
957
|
-
};
|
|
958
|
-
const routeEffort = effortOf(json);
|
|
959
|
-
if (routeEffort)
|
|
960
|
-
record.effort = routeEffort;
|
|
961
|
-
tag = `CLAUDE ${route.tag} account=${using.ownerId?.slice(0, 8) ?? "current"}`;
|
|
1091
|
+
else if (isApiHost) {
|
|
1092
|
+
target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
|
|
1093
|
+
tag = `PASS ${typeof model === "string" ? model : "-"}`;
|
|
1094
|
+
// Passthrough stays byte-exact unless another model's thinking would get the turn refused.
|
|
1095
|
+
if (json && dropForeignThinking(json) > 0)
|
|
1096
|
+
body = Buffer.from(JSON.stringify(json));
|
|
962
1097
|
}
|
|
963
1098
|
else {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
// and forward the rest, which is only the delta: DeepSeek (via Bailian) received two orphan
|
|
967
|
-
// tool_results with no task and answered that none had been given (measured 2026-09-24, CLI
|
|
968
|
-
// 2.1.280). Refused, the CLI resends the turn with the full history.
|
|
969
|
-
if (threadDecision(json) === "refuse") {
|
|
970
|
-
const out = JSON.stringify(THREAD_UNSUPPORTED);
|
|
971
|
-
res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
|
|
972
|
-
finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
const preset = provider.preset ? PRESETS.find((entry) => entry.id === provider.preset) : undefined;
|
|
976
|
-
const modelEffortLevels = provider.models?.find((entry) => entry.id === route.model)?.effortLevels;
|
|
977
|
-
compatCaps = resolveCompatibleCaps(preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking, ...(preset.serverTools ? { serverTools: true } : {}) } : undefined, modelEffortLevels === undefined ? provider.caps : { ...provider.caps, effortLevels: modelEffortLevels });
|
|
978
|
-
const sanitized = sanitizeForCompatible(json, compatCaps);
|
|
979
|
-
json = sanitized.json;
|
|
980
|
-
compatChanges = sanitized.changes;
|
|
981
|
-
// The upstream would otherwise read Claude Code's own system prompt and answer that it is
|
|
982
|
-
// Claude. After sanitizing, so the effort named is the one the provider actually receives.
|
|
983
|
-
applyIdentityToAnthropicBody(json, {
|
|
984
|
-
model: route.model,
|
|
985
|
-
effort: effortOf(json),
|
|
986
|
-
identity: provider.identity,
|
|
987
|
-
instructionsAppend: provider.instructionsAppend,
|
|
988
|
-
});
|
|
989
|
-
body = Buffer.from(JSON.stringify(json));
|
|
990
|
-
const u = new URL(provider.url);
|
|
991
|
-
const protocol = u.protocol === "https:" ? "https:" : "http:";
|
|
992
|
-
// Which credential answers this turn. The conversation keeps the one it is on while that one
|
|
993
|
-
// is healthy, because moving it moves the prompt cache with it. Every credential cooling or
|
|
994
|
-
// quarantined leaves `chosen` null, and the request goes out on the configured headers so the
|
|
995
|
-
// provider — not us — gets to say no.
|
|
996
|
-
const credentials = this.credentialsOf(route.provider, provider);
|
|
997
|
-
// The same key the prompt cache is keyed on. `metadata.user_id` alone is one value for every
|
|
998
|
-
// conversation a user has, so using it raw would drag all of them onto one credential at once
|
|
999
|
-
// — the opposite of what stickiness is for (see conversationKey and ARCHITECTURE §4).
|
|
1000
|
-
chosen = this.pool.pick(route.provider, credentials, conversationKey(json));
|
|
1001
|
-
// Nothing usable means every credential is cooling, and the turn still has to go somewhere:
|
|
1002
|
-
// the first one, so the request carries real credentials rather than none, and so the answer
|
|
1003
|
-
// is charged to the credential that actually produced it.
|
|
1004
|
-
const using = chosen ?? credentials[0];
|
|
1005
|
-
chosen = using;
|
|
1006
|
-
penalised = { provider: route.provider, id: using.id };
|
|
1007
|
-
target = {
|
|
1008
|
-
protocol,
|
|
1009
|
-
host: u.hostname,
|
|
1010
|
-
port: Number(u.port) || (protocol === "https:" ? 443 : 80),
|
|
1011
|
-
agent: this.agentFor(route.provider, protocol),
|
|
1012
|
-
// The credential wins over the session header: they are different names in practice, but a
|
|
1013
|
-
// vendor that reused one would mean the request going out unauthenticated.
|
|
1014
|
-
extraHeaders: provider.sessionHeader
|
|
1015
|
-
? { [provider.sessionHeader]: conversationKey(json), ...using.headers }
|
|
1016
|
-
: using.headers,
|
|
1017
|
-
dropClientAuth: true,
|
|
1018
|
-
// Providers mount their Anthropic-compatible API under a path (DeepSeek /anthropic, OpenRouter /api,
|
|
1019
|
-
// Qwen /apps/anthropic): the CLI's /v1/messages is appended to it. Dropping it sent requests to the
|
|
1020
|
-
// vendor's website, which answered 200 with HTML (measured 2026-09-13 with OpenRouter).
|
|
1021
|
-
basePath: u.pathname.replace(/\/+$/, ""),
|
|
1022
|
-
};
|
|
1023
|
-
const routeEffort = effortOf(json);
|
|
1024
|
-
if (routeEffort)
|
|
1025
|
-
record.effort = routeEffort;
|
|
1026
|
-
tag = `${route.provider.toUpperCase()} ${route.tag} effort=${routeEffort ?? "-"}`;
|
|
1099
|
+
target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
|
|
1100
|
+
tag = `WEB ${reqHost}`;
|
|
1027
1101
|
}
|
|
1028
|
-
|
|
1029
|
-
else if (isApiHost) {
|
|
1030
|
-
target = { protocol: "https:", host: cfg.upstream, port: this.deps.upstreamPort ?? 443, agent: this.deps.upstreamAgent ?? this.upstreamAgent, extraHeaders: {} };
|
|
1031
|
-
tag = `PASS ${typeof model === "string" ? model : "-"}`;
|
|
1032
|
-
// Passthrough stays byte-exact unless another model's thinking would get the turn refused.
|
|
1033
|
-
if (json && dropForeignThinking(json) > 0)
|
|
1034
|
-
body = Buffer.from(JSON.stringify(json));
|
|
1035
|
-
}
|
|
1036
|
-
else {
|
|
1037
|
-
target = { protocol: "https:", host: reqHost, port: 443, agent: this.agentFor(`host:${reqHost}`, "https:"), extraHeaders: {} };
|
|
1038
|
-
tag = `WEB ${reqHost}`;
|
|
1102
|
+
break;
|
|
1039
1103
|
}
|
|
1040
1104
|
if (json && typeof json.service_tier === "string")
|
|
1041
1105
|
record.requestedTier = json.service_tier;
|
|
@@ -1536,26 +1600,12 @@ export class Proxy {
|
|
|
1536
1600
|
chooseTarget(resolved, cfg, log) {
|
|
1537
1601
|
if (!resolved.fallbacks?.length)
|
|
1538
1602
|
return resolved;
|
|
1539
|
-
|
|
1540
|
-
const provider = cfg.providers[name];
|
|
1541
|
-
if (!provider)
|
|
1542
|
-
return false;
|
|
1543
|
-
if (provider.type === "anthropic") {
|
|
1544
|
-
if (!provider.accountPool || provider.auth !== "claude-code")
|
|
1545
|
-
return false;
|
|
1546
|
-
const accounts = this.claudeAccounts.peekCredentials();
|
|
1547
|
-
return accounts.length > 0 && this.pool.hasUsable(name, accounts);
|
|
1548
|
-
}
|
|
1549
|
-
if (provider.type === "chatgpt")
|
|
1550
|
-
return this.chatgpt(name, provider).hasUsable();
|
|
1551
|
-
return this.pool.hasUsable(name, this.credentialsOf(name, provider));
|
|
1552
|
-
};
|
|
1553
|
-
if (usable(resolved.provider))
|
|
1603
|
+
if (this.targetUsable(resolved, cfg))
|
|
1554
1604
|
return resolved;
|
|
1555
1605
|
for (const f of resolved.fallbacks) {
|
|
1556
1606
|
if (f.provider === resolved.provider && f.model === resolved.model)
|
|
1557
1607
|
continue;
|
|
1558
|
-
if (!
|
|
1608
|
+
if (!this.targetUsable(f, cfg))
|
|
1559
1609
|
continue;
|
|
1560
1610
|
log.info(`FAILOVER ${resolved.provider}/${resolved.model} exhausted → ${f.provider}/${f.model}`);
|
|
1561
1611
|
// The tag names the model that answers, not the one that could not: a log line saying `->m1`
|
|
@@ -1570,6 +1620,37 @@ export class Proxy {
|
|
|
1570
1620
|
}
|
|
1571
1621
|
return resolved;
|
|
1572
1622
|
}
|
|
1623
|
+
/** Only configured route fallbacks, not another account or an invented provider. */
|
|
1624
|
+
promptFallback(resolved, attempted, cfg) {
|
|
1625
|
+
for (const f of resolved.fallbacks ?? []) {
|
|
1626
|
+
if (f.provider === attempted.provider && f.model === attempted.model)
|
|
1627
|
+
continue;
|
|
1628
|
+
if (!this.targetUsable(f, cfg))
|
|
1629
|
+
continue;
|
|
1630
|
+
return {
|
|
1631
|
+
provider: f.provider, model: f.model, effort: f.effort ?? resolved.effort,
|
|
1632
|
+
tag: `${resolved.tag.split("->")[0] ?? resolved.model}->${f.model} (prompt fallback from ${attempted.provider})`,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
return null;
|
|
1636
|
+
}
|
|
1637
|
+
targetUsable(target, cfg) {
|
|
1638
|
+
const configured = cfg.providers[target.provider];
|
|
1639
|
+
if (!configured)
|
|
1640
|
+
return false;
|
|
1641
|
+
const provider = providerFor(configured, target.model);
|
|
1642
|
+
if (provider.type === "anthropic") {
|
|
1643
|
+
if (!provider.accountPool || provider.auth !== "claude-code")
|
|
1644
|
+
return false;
|
|
1645
|
+
const accounts = this.claudeAccounts.peekCredentials();
|
|
1646
|
+
return accounts.length > 0 && this.pool.hasUsable(target.provider, accounts);
|
|
1647
|
+
}
|
|
1648
|
+
if (provider.type === "chatgpt")
|
|
1649
|
+
return this.chatgpt(target.provider, provider).hasUsable();
|
|
1650
|
+
if (provider.type === "google" && provider.auth === "antigravity")
|
|
1651
|
+
return this.google(target.provider, provider).hasUsable();
|
|
1652
|
+
return this.pool.hasUsable(target.provider, this.credentialsOf(target.provider, provider));
|
|
1653
|
+
}
|
|
1573
1654
|
/**
|
|
1574
1655
|
* Tell the pool how a translated provider's turn went. These adapters hold their own credential —
|
|
1575
1656
|
* an OAuth grant, or configured headers — so there is one of it, but the pool still has to hear
|