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.
Files changed (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -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. Failing over
732
- // mid-turn is not possible — once a byte of the answer has gone out, replacing it would splice
733
- // two answers together — so the choice has to be made here or not at all.
734
- const route = resolved ? this.chooseTarget(resolved, cfg, log) : null;
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
- if (route && json) {
788
- record = { ...record, target: route.model, provider: route.provider };
789
- const configured = cfg.providers[route.provider];
790
- if (!configured) {
791
- finish("500", 0, `unknown provider ${route.provider}`, true, { ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "unknown-provider" });
792
- res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: { type: "clauderipple_config", message: `unknown provider ${route.provider}` } }));
793
- return;
794
- }
795
- // A model may speak another wire than the rest of its provider: one subscription serving
796
- // several protocols is one provider, and this is where the model's own shape is folded in so
797
- // the branches below pick the adapter it actually needs.
798
- const provider = providerFor(configured, route.model);
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
- if (td === "strip")
809
- stripThreadFields(json);
810
- const routeEffort = effortOf(json);
811
- if (routeEffort)
812
- record.effort = routeEffort;
813
- tag = `CHATGPT ${route.tag} effort=${routeEffort ?? "-"}`;
814
- try {
815
- // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
816
- // here fails over once every account is out (chooseTarget asks the adapter).
817
- const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
818
- if (!path.startsWith("/v1/messages/count_tokens"))
819
- this.providerHealth.observe(route.provider, o.status, o.exhaustion);
820
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
821
- }
822
- catch (e) {
823
- finish("-", 0, `chatgpt error ${e.code ?? ""} ${e.message}`);
824
- if (!res.headersSent)
825
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
826
- else
827
- res.destroy();
828
- }
829
- return;
830
- }
831
- if (provider.type === "openai-compatible") {
832
- const td = threadDecision(json);
833
- if (td === "refuse") {
834
- const out = JSON.stringify(THREAD_UNSUPPORTED);
835
- res.writeHead(400, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out)) }).end(out);
836
- finish("400", out.length, "thread continue refused → CLI resends stateless", false, { resent: true, ...(!searchAttempted ? { upstreamCalled: false } : {}), refusal: "thread-continue" });
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 (td === "strip")
840
- stripThreadFields(json);
841
- const routeEffort = effortOf(json);
842
- if (routeEffort)
843
- record.effort = routeEffort;
844
- tag = `OPENAI ${route.tag} wire=${provider.wire ?? "chat"} effort=${routeEffort ?? "-"}`;
845
- try {
846
- const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
847
- this.recordOutcome(route.provider, o.status);
848
- if (!path.startsWith("/v1/messages/count_tokens"))
849
- this.providerHealth.observe(route.provider, o.status, o.exhaustion);
850
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
851
- }
852
- catch (e) {
853
- this.recordOutcome(route.provider, 0);
854
- finish("-", 0, `openai error ${e.code ?? ""} ${e.message}`);
855
- if (!res.headersSent)
856
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
857
- else
858
- res.destroy();
859
- }
860
- return;
861
- }
862
- if (provider.type === "google") {
863
- // Google holds no server-side thread either, so a `continue` is refused as it is for the
864
- // other translated providers; the CLI resends the turn with the full history.
865
- const td = threadDecision(json);
866
- if (td === "refuse") {
867
- const out = JSON.stringify(THREAD_UNSUPPORTED);
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 (td === "strip")
873
- stripThreadFields(json);
874
- const routeEffort = effortOf(json);
875
- if (routeEffort)
876
- record.effort = routeEffort;
877
- tag = `GOOGLE ${route.tag} effort=${routeEffort ?? "-"}`;
878
- try {
879
- const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
880
- this.recordOutcome(route.provider, o.status);
881
- if (!path.startsWith("/v1/messages/count_tokens"))
882
- this.providerHealth.observe(route.provider, o.status, o.exhaustion);
883
- finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...o, ...(searchAttempted ? { upstreamCalled: undefined } : {}) });
884
- }
885
- catch (e) {
886
- this.recordOutcome(route.provider, 0);
887
- finish("-", 0, `google error ${e.code ?? ""} ${e.message}`);
888
- if (!res.headersSent)
889
- res.writeHead(502, { "content-type": "application/json" }).end(JSON.stringify({ type: "error", error: { type: "api_error", message: e.message } }));
890
- else
891
- res.destroy();
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
- return;
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 });
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 (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 } : {}) });
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
- 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();
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 (provider.type === "anthropic") {
928
- if (!provider.accountPool || provider.auth !== "claude-code") {
929
- finish("400", 0, "native Anthropic provider is available through OpenAI ingress only", false);
930
- 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" } }));
931
- return;
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
- // An Anthropic-shaped endpoint keeps no server-side thread either, so a `continue` is refused
965
- // here as it is for the translated providers. sanitizeForCompatible used to strip the field
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
- const usable = (name) => {
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 (!usable(f.provider))
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