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