clauderipple 0.8.10 → 0.9.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 (68) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.es.md +0 -4
  3. package/README.ja.md +0 -4
  4. package/README.ko.md +5 -1
  5. package/README.md +5 -1
  6. package/README.pt-BR.md +0 -4
  7. package/README.zh-CN.md +0 -4
  8. package/dist/app/package.json +1 -1
  9. package/dist/cli/src/codex.js +3 -1
  10. package/dist/cli/src/settings.js +5 -2
  11. package/dist/mod/.claude-plugin/plugin.json +9 -1
  12. package/dist/mod/hooks/cache.ts +60 -0
  13. package/dist/mod/hooks/compact.ts +40 -0
  14. package/dist/mod/hooks/handoff.ts +43 -0
  15. package/dist/mod/hooks/i18n.ts +2716 -0
  16. package/dist/mod/hooks/judge.ts +226 -0
  17. package/dist/mod/hooks/ladder.js +179 -0
  18. package/dist/mod/hooks/ladder.ts +174 -0
  19. package/dist/mod/hooks/modes.ts +93 -0
  20. package/dist/mod/hooks/register.tsx +1458 -256
  21. package/dist/mod/hooks/session.ts +62 -0
  22. package/dist/mod/hooks/settings.js +342 -0
  23. package/dist/mod/hooks/settings.ts +321 -0
  24. package/dist/mod/hooks/stats.ts +59 -0
  25. package/dist/mod/hooks/ui/agents.tsx +138 -0
  26. package/dist/mod/hooks/ui/alerts.tsx +370 -0
  27. package/dist/mod/hooks/ui/art.ts +628 -0
  28. package/dist/mod/hooks/ui/band.tsx +836 -0
  29. package/dist/mod/hooks/ui/kit.tsx +258 -0
  30. package/dist/mod/hooks/ui/log.tsx +109 -0
  31. package/dist/mod/hooks/ui/parts.tsx +217 -0
  32. package/dist/mod/hooks/ui/ripple.ts +614 -0
  33. package/dist/mod/hooks/ui/settings.tsx +376 -0
  34. package/dist/mod/hooks/ui/setup.tsx +148 -0
  35. package/dist/mod/hooks/ui/theme.ts +217 -0
  36. package/dist/mod/hooks/ui/view.ts +371 -0
  37. package/dist/mod/hooks/worker.ts +1 -1
  38. package/dist/mod/hooks/workers.ts +217 -0
  39. package/dist/mod/tests/register.test.tsx +1730 -0
  40. package/dist/mod/tests/ui.test.tsx +853 -0
  41. package/dist/mod/tsconfig.json +2 -1
  42. package/dist/mod/types/index.d.ts +320 -1
  43. package/dist/router/src/admin.js +170 -52
  44. package/dist/router/src/agents.js +24 -5
  45. package/dist/router/src/bootstrap.js +4 -2
  46. package/dist/router/src/config.js +3 -0
  47. package/dist/router/src/health.js +33 -0
  48. package/dist/router/src/index.js +1 -0
  49. package/dist/router/src/ingress/models.js +4 -1
  50. package/dist/router/src/mod-settings.js +119 -0
  51. package/dist/router/src/model-policy.js +6 -0
  52. package/dist/router/src/model-scores.js +159 -0
  53. package/dist/router/src/picker.js +7 -0
  54. package/dist/router/src/providers/chatgpt/index.js +6 -1
  55. package/dist/router/src/providers/chatgpt/translate.js +1 -1
  56. package/dist/router/src/providers/google/index.js +6 -2
  57. package/dist/router/src/providers/openai/index.js +4 -1
  58. package/dist/router/src/providers/openai/translate.js +1 -1
  59. package/dist/router/src/proxy.js +14 -3
  60. package/dist/router/src/version.js +1 -1
  61. package/dist/ui/app.js +908 -209
  62. package/dist/ui/favicon-32.png +0 -0
  63. package/dist/ui/favicon.svg +12 -0
  64. package/dist/ui/i18n.js +919 -233
  65. package/dist/ui/index.html +159 -87
  66. package/dist/ui/style.css +789 -410
  67. package/docs/ARCHITECTURE.md +128 -0
  68. package/package.json +7 -3
@@ -0,0 +1,159 @@
1
+ // AA benchmark scores are metadata only: this module never changes routing or calls providers.
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { homeDir } from "./config.js";
5
+ import { declaredBy, resolve } from "./routing.js";
6
+ import { isJudgeOnlyModel } from "./model-policy.js";
7
+ export const AA_DATA_URL = "https://raw.githubusercontent.com/PBJ-2/clauderipple/data/aa-models.json";
8
+ const ATTRIBUTION = "Data: Artificial Analysis (artificialanalysis.ai)";
9
+ const TTL_MS = 24 * 60 * 60_000;
10
+ const RETRY_MS = 60 * 60_000;
11
+ /** Namespace, @effort and Claude snapshot date are transport spelling, not AA base identity. */
12
+ export function normalizeAaBase(model) {
13
+ return model.toLowerCase().split("/").at(-1).replace(/@[a-z]+$/, "").replace(/-\d{8}$/, "").replace(/[._]/g, "-");
14
+ }
15
+ /** AA minimal/non-reasoning have no equivalent in the picker's five-level ladder. Do not mislabel. */
16
+ export function mapAaEffort(effort) {
17
+ return effort === "minimal" || effort === "non-reasoning" ? null : effort;
18
+ }
19
+ /** Only the fields consumed here are validated; full dataset schema belongs to the producer. */
20
+ export function parseScoreDataset(raw) {
21
+ if (!raw || typeof raw !== "object")
22
+ throw new Error("invalid AA dataset");
23
+ const d = raw;
24
+ if (d.source !== "https://artificialanalysis.ai" || d.attribution !== ATTRIBUTION || typeof d.generatedAt !== "string" || !Number.isFinite(Date.parse(d.generatedAt)) || !Array.isArray(d.models))
25
+ throw new Error("invalid AA dataset envelope");
26
+ const models = [];
27
+ const slugs = new Set();
28
+ for (const value of d.models) {
29
+ if (!value || typeof value !== "object")
30
+ throw new Error("invalid AA model");
31
+ const m = value;
32
+ const validNumber = (n) => n === null || (typeof n === "number" && Number.isFinite(n));
33
+ if (typeof m.slug !== "string" || !m.slug || slugs.has(m.slug) || typeof m.base !== "string" || !m.base || ![null, "low", "medium", "high", "xhigh", "max", "minimal", "non-reasoning"].includes(m.effort) || !validNumber(m.intelligence) || !validNumber(m.costPerTask))
34
+ throw new Error("invalid AA model score");
35
+ slugs.add(m.slug);
36
+ models.push({ slug: m.slug, base: m.base, effort: m.effort, intelligence: m.intelligence, costPerTask: m.costPerTask });
37
+ }
38
+ return { attribution: ATTRIBUTION, generatedAt: d.generatedAt, models };
39
+ }
40
+ /** Fetch at most daily, single-flight; offline keeps the last good disk/memory copy and retries hourly. */
41
+ export class ModelScoreCatalog {
42
+ dataset = null;
43
+ until = 0;
44
+ loaded = false;
45
+ pending = null;
46
+ options;
47
+ constructor(options = {}) { this.options = options; }
48
+ async get() {
49
+ if (this.pending)
50
+ return this.pending;
51
+ if (this.loaded && this.until > this.now())
52
+ return this.dataset;
53
+ this.pending = this.refresh();
54
+ try {
55
+ return await this.pending;
56
+ }
57
+ finally {
58
+ this.pending = null;
59
+ }
60
+ }
61
+ now() { return (this.options.now ?? Date.now)(); }
62
+ warn(message) { (this.options.warn ?? console.warn)(`AA model scores: ${message}`); }
63
+ async refresh() {
64
+ const file = path.join(this.options.home ?? homeDir(), "aa-models.json");
65
+ if (!this.loaded) {
66
+ this.loaded = true;
67
+ try {
68
+ this.dataset = parseScoreDataset(JSON.parse(await fs.readFile(file, "utf8")));
69
+ // Disk mtime records retrieval, not AA's measurement/publication time. Honour TTL on restart.
70
+ const fetched = (await fs.stat(file)).mtimeMs;
71
+ if (fetched <= this.now())
72
+ this.until = fetched + TTL_MS;
73
+ }
74
+ catch (e) {
75
+ if (!(e instanceof Error && "code" in e && e.code === "ENOENT"))
76
+ this.warn(`cache unreadable: ${String(e)}`);
77
+ }
78
+ }
79
+ if (this.until > this.now())
80
+ return this.dataset;
81
+ let raw;
82
+ try {
83
+ const response = await (this.options.fetch ?? fetch)(AA_DATA_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5_000) });
84
+ if (!response.ok)
85
+ throw new Error(`HTTP ${response.status}`);
86
+ raw = await response.json();
87
+ this.dataset = parseScoreDataset(raw);
88
+ this.until = this.now() + TTL_MS;
89
+ }
90
+ catch (e) {
91
+ this.until = this.now() + RETRY_MS;
92
+ this.warn(`refresh failed; using ${this.dataset ? "cached scores" : "unmatched models"}: ${String(e)}`);
93
+ return this.dataset;
94
+ }
95
+ const tmp = `${file}.tmp-${process.pid}`;
96
+ try {
97
+ await fs.mkdir(path.dirname(file), { recursive: true });
98
+ await fs.writeFile(tmp, JSON.stringify(raw, null, 2) + "\n");
99
+ await fs.rename(tmp, file);
100
+ }
101
+ catch (e) {
102
+ this.warn(`could not persist cache: ${String(e)}`);
103
+ await fs.rm(tmp, { force: true }).catch(error => this.warn(`could not remove temporary cache: ${String(error)}`));
104
+ }
105
+ return this.dataset;
106
+ }
107
+ }
108
+ /**
109
+ * Enumerate finite configured ids, not hypothetical ids a prefix could accept. Use resolve() so
110
+ * explicit routes/direct precedence and ambiguous owners behave exactly like inference. Native
111
+ * Anthropic declarations remain visible (they are routable via OpenAI ingress, not Messages).
112
+ * Marker aliases contribute their target ids; a slot contributes its resolved target, not the
113
+ * Claude source label. Deduplicate by (provider, model); ambiguous declarations without an
114
+ * explicit rule are not routable and are excluded.
115
+ */
116
+ export function buildModelScores(cfg, dataset) {
117
+ const targets = new Map();
118
+ const add = (model, provider) => {
119
+ if (cfg.providers[provider] && !isJudgeOnlyModel(model))
120
+ targets.set(`${provider}\0${model}`, { model, provider });
121
+ };
122
+ const declared = new Set(Object.values(cfg.providers).flatMap(p => (p.models ?? []).map(m => m.id)));
123
+ const candidates = new Set([...declared, ...Object.values(cfg.aliases), ...cfg.cli.extraModels.map(m => m.model), ...Object.values(cfg.cli.models ?? {}), ...Object.keys(cfg.routes)]);
124
+ // Fallbacks are explicit provider/model targets even when they are absent from declared lists.
125
+ for (const route of Object.values(cfg.routes)) {
126
+ for (const fallback of route.fallbacks ?? []) {
127
+ const provider = cfg.providers[fallback.provider];
128
+ if (provider && (provider.type !== "anthropic" || provider.accountPool))
129
+ add(fallback.model, fallback.provider);
130
+ }
131
+ }
132
+ for (const candidate of candidates) {
133
+ const result = resolve(candidate, {}, cfg);
134
+ if (result) {
135
+ add(result.model, result.provider);
136
+ continue;
137
+ }
138
+ // Native ingress has a separate resolver; do not pretend ambiguous translated models route.
139
+ const owners = declaredBy(candidate, cfg);
140
+ if (owners.length === 1 && cfg.providers[owners[0]].type === "anthropic")
141
+ add(candidate.replace(/@[a-z]+$/, ""), owners[0]);
142
+ }
143
+ const groups = new Map();
144
+ for (const variant of dataset?.models ?? []) {
145
+ const group = groups.get(variant.base) ?? [];
146
+ group.push(variant);
147
+ groups.set(variant.base, group);
148
+ }
149
+ const models = [...targets.values()].sort((a, b) => a.model.localeCompare(b.model) || a.provider.localeCompare(b.provider)).map(target => {
150
+ const aaBase = normalizeAaBase(target.model);
151
+ const found = groups.get(aaBase) ?? [];
152
+ return {
153
+ ...target, aaBase,
154
+ variants: [...found].sort((a, b) => a.slug.localeCompare(b.slug)).map(v => ({ effort: mapAaEffort(v.effort), intelligence: v.intelligence, costPerTask: v.costPerTask })),
155
+ matched: found.length > 0,
156
+ };
157
+ });
158
+ return { attribution: dataset?.attribution ?? ATTRIBUTION, generatedAt: dataset?.generatedAt ?? null, models };
159
+ }
@@ -9,7 +9,9 @@
9
9
  // entry as a template and override the identifying fields. Surfaces are matched by id
10
10
  // heuristically (anything that is not the claude.ai chat surface); the ids we saw are
11
11
  // logged once so the heuristic can be tightened.
12
+ import { resolve } from "./routing.js";
12
13
  import { clampEffort } from "./compat.js";
14
+ import { isJudgeOnlyModel } from "./model-policy.js";
13
15
  export const BOOTSTRAP_PATHS = ["/edge-api/bootstrap", "/api/bootstrap"];
14
16
  export function isBootstrapPath(path) {
15
17
  return BOOTSTRAP_PATHS.some((p) => path === p || path.startsWith(p + "/") || path.startsWith(p + "?"));
@@ -48,12 +50,17 @@ function narrowThinking(entry, levels) {
48
50
  const preferred = clampEffort("high", kept.map((option) => option.id));
49
51
  entry.thinking = { ...thinking, effort_options: kept.map(({ recommended: _recommended, ...option }) => (option.id === preferred ? { ...option, recommended: true } : option)) };
50
52
  }
53
+ /** Resolve aliases/routes before advertising an entry; a chat-looking alias can target a judge. */
54
+ export function chatPickerModels(cfg) {
55
+ return cfg.cli.extraModels.filter(entry => !isJudgeOnlyModel(entry.model) && !isJudgeOnlyModel(resolve(entry.model, {}, cfg)?.model ?? cfg.aliases[entry.model.split("@")[0]] ?? ""));
56
+ }
51
57
  /**
52
58
  * Mutates `json` in place; returns what was done for logging. `effortFor` names the levels a model
53
59
  * takes (by its id without an `@effort` suffix); undefined leaves the template's menu as it is.
54
60
  */
55
61
  export function injectPickerModels(json, extra, contextWindow, effortFor) {
56
62
  const result = { injected: 0, surfaces: [] };
63
+ extra = extra.filter(e => !isJudgeOnlyModel(e.model));
57
64
  const msc = json.model_selector_config;
58
65
  if (!Array.isArray(msc) || extra.length === 0)
59
66
  return result;
@@ -8,6 +8,7 @@ import { SseParser } from "./sse.js";
8
8
  import { fetchWithRetry } from "../retry.js";
9
9
  import { looksLikeAuth } from "../openai/index.js";
10
10
  import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
11
+ import { providerExhaustion } from "../../health.js";
11
12
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
12
13
  import { codexClientVersion, latestCodexVersion, parseCodexCatalog } from "./catalog.js";
13
14
  import fs from "node:fs";
@@ -1037,7 +1038,9 @@ export class ChatGptAdapter {
1037
1038
  this.dump(sent.status, json, upstreamReq, sent.text);
1038
1039
  }
1039
1040
  res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
1040
- return { status: err.status, bytes: err.body.length, note };
1041
+ return { status: err.status, bytes: err.body.length, note,
1042
+ ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
1043
+ };
1041
1044
  }
1042
1045
  const { upstream, credential } = sent;
1043
1046
  const nextTurnState = upstream.headers.get("x-codex-turn-state");
@@ -1141,6 +1144,7 @@ export class ChatGptAdapter {
1141
1144
  }
1142
1145
  const failure = mapper.failure;
1143
1146
  const failedStatus = failure?.type === "invalid_request_error" ? 400 : failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
1147
+ const exhaustion = failure ? providerExhaustion(429, `${failure.code ?? ""} ${failure.message}`, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined;
1144
1148
  if (!wantStream || (failure && !res.headersSent)) {
1145
1149
  // A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
1146
1150
  const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
@@ -1154,6 +1158,7 @@ export class ChatGptAdapter {
1154
1158
  return {
1155
1159
  // A stream has already sent 200; the record still says the turn failed.
1156
1160
  status: failure ? failedStatus : 200,
1161
+ exhaustion,
1157
1162
  bytes,
1158
1163
  note: failure
1159
1164
  ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
@@ -539,7 +539,7 @@ export class StreamMapper {
539
539
  const type = contextOverflow ? "invalid_request_error" : code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
540
540
  if (contextOverflow)
541
541
  message = `prompt is too long: ${message}`;
542
- this.failure = { type, message };
542
+ this.failure = { type, message, ...(code ? { code } : {}) };
543
543
  return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
544
544
  }
545
545
  /** Non-streaming body once finished. */
@@ -14,6 +14,7 @@
14
14
  // vendor sent. The antigravity path picks the account the conversation is sticky to and moves to the
15
15
  // next one only on a refusal that has not reached the client (§4 Credential pools).
16
16
  import http from "node:http";
17
+ import { providerExhaustion } from "../../health.js";
17
18
  import { CredentialPool, retryAfterMs } from "../../pool.js";
18
19
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
19
20
  import { SseParser } from "../chatgpt/sse.js";
@@ -320,7 +321,7 @@ export class GoogleAdapter {
320
321
  this.log.warn(`google ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
321
322
  res.off("close", onClose);
322
323
  res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
323
- return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}` };
324
+ return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers)) ?? googleRetryDelayMs(text)) };
324
325
  }
325
326
  return this.streamBack(res, upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
326
327
  }
@@ -384,7 +385,9 @@ export class GoogleAdapter {
384
385
  note = `upstream ${sent.status}`;
385
386
  }
386
387
  res.writeHead(err.status, { "content-type": "application/json", ...(retryAfterSeconds ? { "retry-after": String(retryAfterSeconds) } : {}) }).end(err.body);
387
- return { status: err.status, bytes: Buffer.byteLength(err.body), note };
388
+ return { status: err.status, bytes: Buffer.byteLength(err.body), note,
389
+ ...(sent.kind === "refused" ? { exhaustion: providerExhaustion(sent.status, sent.text, sent.retryAfterSeconds === undefined ? undefined : sent.retryAfterSeconds * 1000) } : {}),
390
+ };
388
391
  }
389
392
  return this.streamBack(res, sent.upstream, transport, json, model, wantStream, key, startInput, controller, onClose);
390
393
  }
@@ -484,6 +487,7 @@ export class GoogleAdapter {
484
487
  const usage = mapper.usage;
485
488
  return {
486
489
  status: failure ? failedStatus : 200,
490
+ exhaustion: failure ? providerExhaustion(429, failure.message, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined,
487
491
  bytes,
488
492
  note: failure
489
493
  ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
@@ -2,6 +2,8 @@
2
2
  // Responses SSE out, translated back to Anthropic Messages. It never synthesizes
3
3
  // cache figures; the request log reflects cached tokens only when the vendor sends them.
4
4
  import http from "node:http";
5
+ import { providerExhaustion } from "../../health.js";
6
+ import { retryAfterMs } from "../../pool.js";
5
7
  import { credentialHeaderValues, redactErrorText } from "../../redact.js";
6
8
  import { SseParser } from "../chatgpt/sse.js";
7
9
  import { fetchWithRetry } from "../retry.js";
@@ -146,7 +148,7 @@ export class OpenAiCompatibleAdapter {
146
148
  this.log.warn(`openai ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
147
149
  res.off("close", onClose);
148
150
  res.writeHead(out.status, { "content-type": "application/json", "content-length": String(Buffer.byteLength(out.body)) }).end(out.body);
149
- return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}` };
151
+ return { status: out.status, bytes: Buffer.byteLength(out.body), note: `upstream ${upstream.status}`, exhaustion: providerExhaustion(upstream.status, text, retryAfterMs(Object.fromEntries(upstream.headers))) };
150
152
  }
151
153
  const wantStream = json.stream === true;
152
154
  const mapper = new OpenAiStreamMapper(model, startInput, toolNameRestoreMap(json));
@@ -226,6 +228,7 @@ export class OpenAiCompatibleAdapter {
226
228
  return {
227
229
  // A stream has already sent 200; the record still says the turn failed.
228
230
  status: failure ? failedStatus : 200,
231
+ exhaustion: failure ? providerExhaustion(429, `${failure.code ?? ""} ${failure.message}`, retryAfterMs(Object.fromEntries(upstream.headers))) : undefined,
229
232
  bytes,
230
233
  note: failure
231
234
  ? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${usage.input_tokens} cached=${usage.cache_read_input_tokens} out=${usage.output_tokens})`
@@ -550,7 +550,7 @@ export class OpenAiStreamMapper {
550
550
  // `overloaded_error` is the one mid-stream error Claude Code retries on its own (CLI 2.1.278
551
551
  // matches `"type":"overloaded_error"` in the message); `api_error` ends the turn for good.
552
552
  const type = code === "rate_limit_exceeded" ? "rate_limit_error" : code === "server_is_overloaded" ? "overloaded_error" : "api_error";
553
- this.failure = { type, message };
553
+ this.failure = { type, message, ...(code ? { code } : {}) };
554
554
  return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
555
555
  }
556
556
  message() {
@@ -18,7 +18,7 @@ import https from "node:https";
18
18
  import net from "node:net";
19
19
  import tls from "node:tls";
20
20
  import zlib from "node:zlib";
21
- import { UpstreamHealth } from "./health.js";
21
+ import { ProviderHealth, providerExhaustion, UpstreamHealth } from "./health.js";
22
22
  import { BOOTSTRAP_PATH, injectBootstrap } from "./bootstrap.js";
23
23
  import { THREAD_UNSUPPORTED, effortOf, resolve, rewriteBody, stripThreadFields, threadDecision, unroutableReason } from "./routing.js";
24
24
  import { defaultAgentDir, withAgentAliases } from "./agents.js";
@@ -32,7 +32,7 @@ import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
32
32
  import { GoogleAdapter } from "./providers/google/index.js";
33
33
  import { conversationKey } from "./providers/chatgpt/translate.js";
34
34
  import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
35
- import { injectPickerModels, isBootstrapPath } from "./picker.js";
35
+ import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
36
36
  import { effortLevelsForModel } from "./admin.js";
37
37
  import { ResponseUsageTap } from "./requestlog.js";
38
38
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
@@ -198,6 +198,7 @@ export class Proxy {
198
198
  * its quarantine again on the first request.
199
199
  */
200
200
  pool = new CredentialPool();
201
+ providerHealth = new ProviderHealth();
201
202
  /** Latest rate-limit snapshot reported by any chatgpt provider (for the admin GUI). */
202
203
  get chatgptRateLimits() {
203
204
  const out = {};
@@ -784,6 +785,8 @@ export class Proxy {
784
785
  // The adapter reports each account's outcome to the shared pool itself, so a slot pointing
785
786
  // here fails over once every account is out (chooseTarget asks the adapter).
786
787
  const o = await this.chatgpt(route.provider, provider).handle(req, res, path, json, route.model, effortOf(json));
788
+ if (!path.startsWith("/v1/messages/count_tokens"))
789
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
787
790
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
788
791
  }
789
792
  catch (e) {
@@ -813,6 +816,8 @@ export class Proxy {
813
816
  try {
814
817
  const o = await this.openai(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
815
818
  this.recordOutcome(route.provider, o.status);
819
+ if (!path.startsWith("/v1/messages/count_tokens"))
820
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
816
821
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
817
822
  }
818
823
  catch (e) {
@@ -845,6 +850,8 @@ export class Proxy {
845
850
  try {
846
851
  const o = await this.google(route.provider, provider).handle(req, res, path, json, route.model, routeEffort);
847
852
  this.recordOutcome(route.provider, o.status);
853
+ if (!path.startsWith("/v1/messages/count_tokens"))
854
+ this.providerHealth.observe(route.provider, o.status, o.exhaustion);
848
855
  finish(String(o.status), o.bytes, o.note, o.status >= 400, { ...(o.usage ? { usage: o.usage } : {}), ...(o.stopReason ? { stopReason: o.stopReason } : {}) });
849
856
  }
850
857
  catch (e) {
@@ -1274,7 +1281,7 @@ export class Proxy {
1274
1281
  else if (status === 200 && isPickerBootstrap) {
1275
1282
  try {
1276
1283
  const j = JSON.parse(out.toString("utf8"));
1277
- const r = injectPickerModels(j, cfg.cli.extraModels, cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1284
+ const r = injectPickerModels(j, chatPickerModels(cfg), cfg.cli.autoCompactWindow, (model) => effortLevelsForModel(cfg, model));
1278
1285
  if (r.surfaces.length > 0) {
1279
1286
  this.lastPickerInjection = { at: new Date().toISOString(), ...r };
1280
1287
  out = Buffer.from(JSON.stringify(j));
@@ -1337,6 +1344,10 @@ export class Proxy {
1337
1344
  observedUsage = observed.usage;
1338
1345
  observedStopReason = observed.stopReason;
1339
1346
  res.end();
1347
+ const errorBody = status >= 400 ? decodeBodyToText(Buffer.concat(errorHead), upRes.headers["content-encoding"]) : "";
1348
+ if (route && path.startsWith("/v1/messages") && !path.startsWith("/v1/messages/count_tokens")) {
1349
+ this.providerHealth.observe(route.provider, status, providerExhaustion(status, errorBody, retryAfterMs(upRes.headers)));
1350
+ }
1340
1351
  const note = status >= 400 ? `upstream ${status}: ${errorSnippet(Buffer.concat(errorHead), upRes.headers["content-encoding"], [...errorSecrets], upRes.headers["content-type"])}` : undefined;
1341
1352
  // Anthropic forgets a conversation's server-side thread after a few idle minutes, and the CLI
1342
1353
  // answers the 404 by replaying the whole conversation, which hits the prompt cache. From CLI
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.8.10";
8
+ export const VERSION = "0.9.0";