clauderipple 0.2.0 → 0.3.1
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 +96 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/cli/src/tray.js +17 -2
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +163 -2
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +713 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Credential pools and failover: keeping a session alive when one credential, or one provider,
|
|
2
|
+
// stops answering.
|
|
3
|
+
//
|
|
4
|
+
// Two separate problems that share this module because they share a failure vocabulary:
|
|
5
|
+
//
|
|
6
|
+
// 1. A provider may hold several credentials (API keys, or subscription accounts). When one is
|
|
7
|
+
// rate-limited or rejected, the next one takes over.
|
|
8
|
+
// 2. A route may name fallbacks. When a provider is exhausted altogether, the next provider
|
|
9
|
+
// answers instead.
|
|
10
|
+
//
|
|
11
|
+
// Three rules the implementation is built around:
|
|
12
|
+
//
|
|
13
|
+
// **Stickiness is not an optimisation, it is the cache.** A conversation that moves to another
|
|
14
|
+
// credential moves to a cold prompt cache, and the acceptance metric for this product is ≥90% cache
|
|
15
|
+
// hit (ARCHITECTURE §4). So a healthy credential keeps the conversation it already has, and only a
|
|
16
|
+
// failure moves it.
|
|
17
|
+
//
|
|
18
|
+
// **Not every failure deserves a retry.** A 400 is our request being wrong; sending it to every
|
|
19
|
+
// credential and then every provider burns the lot and still fails. Only failures that another
|
|
20
|
+
// credential could plausibly answer are worth moving for.
|
|
21
|
+
//
|
|
22
|
+
// **Failover ends at the first byte.** Once any of the answer has reached the client, the turn is
|
|
23
|
+
// committed: replacing it mid-stream produces two half-answers spliced together. The caller
|
|
24
|
+
// enforces this; `Outcome.retryable` only ever means "if nothing has been sent yet".
|
|
25
|
+
/** A rate limit with no stated reset. Long enough not to hammer, short enough to recover a session. */
|
|
26
|
+
const DEFAULT_RATE_LIMIT_MS = 60_000;
|
|
27
|
+
const TRANSIENT_MS = 10_000;
|
|
28
|
+
const EXHAUSTED_MS = 30 * 60_000;
|
|
29
|
+
/** A `Retry-After` beyond this is treated as "not within this session"; the credential is parked. */
|
|
30
|
+
const MAX_COOLDOWN_MS = 6 * 60 * 60_000;
|
|
31
|
+
/**
|
|
32
|
+
* What an upstream status means for the credential that produced it. `retryAfterMs` comes from a
|
|
33
|
+
* `Retry-After` header or a vendor reset field when there is one; guessing is a last resort.
|
|
34
|
+
*/
|
|
35
|
+
export function classify(status, retryAfterMs, body = "") {
|
|
36
|
+
const bounded = (ms) => Math.max(1_000, Math.min(MAX_COOLDOWN_MS, Math.round(ms)));
|
|
37
|
+
// 401 is the credential being rejected, and that does not heal: park it.
|
|
38
|
+
if (status === 401)
|
|
39
|
+
return { retryable: true, kind: "auth", cooldownMs: 0, quarantine: true };
|
|
40
|
+
// 403 is not only that. It is also a content policy, a blocked region, a model the account may
|
|
41
|
+
// not use, or an edge refusing what it took for a bot — none of which mean the key is dead.
|
|
42
|
+
// Parking a working credential until someone notices is the worse mistake, so this waits instead.
|
|
43
|
+
if (status === 403) {
|
|
44
|
+
// Except when the vendor says the refusal was not about us. A relay passing on a broken upstream
|
|
45
|
+
// answers 403, and that is not a statement about the credential at all: charging it a minute of
|
|
46
|
+
// cooldown made every turn in that minute come back to the user as an authentication error
|
|
47
|
+
// (2026-09-21, OpenCode Go relaying a 403 for DeepSeek). The credential did nothing, so it is
|
|
48
|
+
// held for no time at all rather than for a shorter time — any cooldown at all would move the
|
|
49
|
+
// conversation to another credential and take the prompt cache with it (§4). `bounded` is
|
|
50
|
+
// bypassed deliberately: its one-second floor is exactly the cache cost this avoids.
|
|
51
|
+
if (/upstream (request )?failed/i.test(body)) {
|
|
52
|
+
return { retryable: true, kind: "transient", cooldownMs: 0, quarantine: false };
|
|
53
|
+
}
|
|
54
|
+
return { retryable: true, kind: "auth", cooldownMs: bounded(retryAfterMs ?? DEFAULT_RATE_LIMIT_MS), quarantine: false };
|
|
55
|
+
}
|
|
56
|
+
if (status === 429)
|
|
57
|
+
return { retryable: true, kind: "rate-limit", cooldownMs: bounded(retryAfterMs ?? DEFAULT_RATE_LIMIT_MS), quarantine: false };
|
|
58
|
+
if (status === 402)
|
|
59
|
+
return { retryable: true, kind: "exhausted", cooldownMs: bounded(retryAfterMs ?? EXHAUSTED_MS), quarantine: false };
|
|
60
|
+
// 408 and 425 are the server saying "ask again"; 5xx is the server being broken. Both may work
|
|
61
|
+
// somewhere else. 501 and 505 are the server saying it will never do this, so they are not here.
|
|
62
|
+
if (status === 408 || status === 425 || (status >= 500 && status !== 501 && status !== 505)) {
|
|
63
|
+
return { retryable: true, kind: "transient", cooldownMs: bounded(retryAfterMs ?? TRANSIENT_MS), quarantine: false };
|
|
64
|
+
}
|
|
65
|
+
// 0 is our own connect/DNS failure: nothing reached the provider, so another one may still work.
|
|
66
|
+
if (status === 0)
|
|
67
|
+
return { retryable: true, kind: "transient", cooldownMs: TRANSIENT_MS, quarantine: false };
|
|
68
|
+
// Everything else is the request itself — a bad body, an unknown model, an oversized payload.
|
|
69
|
+
// Another credential would reject it the same way, so stop.
|
|
70
|
+
return { retryable: false, reason: `status ${status} is not a credential or provider problem` };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* A vendor's own account of when to come back, from `retry-after` (seconds, or an HTTP date) or a
|
|
74
|
+
* vendor reset field. Preferred over a guess, and ignored when it is not a number we can use.
|
|
75
|
+
*/
|
|
76
|
+
export function retryAfterMs(headers) {
|
|
77
|
+
const pick = (name) => {
|
|
78
|
+
const v = headers[name];
|
|
79
|
+
return Array.isArray(v) ? v[0] : v;
|
|
80
|
+
};
|
|
81
|
+
const header = pick("retry-after");
|
|
82
|
+
if (header) {
|
|
83
|
+
const seconds = Number(header);
|
|
84
|
+
// Anything numeric is seconds, including a negative one — which is not a wait, and must not
|
|
85
|
+
// fall through to the date branch, where `Date.parse("-5")` succeeds and means something else.
|
|
86
|
+
if (Number.isFinite(seconds))
|
|
87
|
+
return seconds >= 0 ? seconds * 1000 : undefined;
|
|
88
|
+
const at = Date.parse(header);
|
|
89
|
+
if (Number.isFinite(at))
|
|
90
|
+
return Math.max(0, at - Date.now());
|
|
91
|
+
}
|
|
92
|
+
// The Codex backend states its window this way rather than with retry-after.
|
|
93
|
+
const codex = Number(pick("x-codex-primary-reset-after-seconds"));
|
|
94
|
+
if (Number.isFinite(codex) && codex >= 0)
|
|
95
|
+
return codex * 1000;
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Health and stickiness for one router's credentials, keyed `provider/credential`. In memory only:
|
|
100
|
+
* a cooldown that outlived a restart would be a restart that made things worse, and a quarantine is
|
|
101
|
+
* re-earned on the first request anyway.
|
|
102
|
+
*/
|
|
103
|
+
export class CredentialPool {
|
|
104
|
+
health = new Map();
|
|
105
|
+
/** conversation → durable owner id (or runtime id when there is no owner), preserving its cache across token refreshes. */
|
|
106
|
+
sticky = new Map();
|
|
107
|
+
now;
|
|
108
|
+
/** How long an idle conversation keeps its claim on a credential. */
|
|
109
|
+
stickyTtlMs;
|
|
110
|
+
constructor(opts = {}) {
|
|
111
|
+
this.now = opts.now ?? Date.now;
|
|
112
|
+
this.stickyTtlMs = opts.stickyTtlMs ?? 60 * 60_000;
|
|
113
|
+
}
|
|
114
|
+
key(provider, id) { return `${provider}/${id}`; }
|
|
115
|
+
healthOf(provider, id) {
|
|
116
|
+
const key = this.key(provider, id);
|
|
117
|
+
let h = this.health.get(key);
|
|
118
|
+
if (!h) {
|
|
119
|
+
h = { cooldownUntil: 0, quarantined: false, failures: 0 };
|
|
120
|
+
this.health.set(key, h);
|
|
121
|
+
}
|
|
122
|
+
return h;
|
|
123
|
+
}
|
|
124
|
+
usable(provider, id) {
|
|
125
|
+
const h = this.healthOf(provider, id);
|
|
126
|
+
return !h.quarantined && h.cooldownUntil <= this.now();
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The credential to use, or null when every one of them is cooling or quarantined. The
|
|
130
|
+
* conversation's existing credential wins while it is healthy; otherwise the first usable one in
|
|
131
|
+
* configured order, which makes the order meaningful rather than incidental.
|
|
132
|
+
*/
|
|
133
|
+
pick(provider, credentials, conversation) {
|
|
134
|
+
if (credentials.length === 0)
|
|
135
|
+
return null;
|
|
136
|
+
this.expireSticky();
|
|
137
|
+
if (conversation) {
|
|
138
|
+
const held = this.sticky.get(conversation);
|
|
139
|
+
if (held && held.provider === provider) {
|
|
140
|
+
const current = credentials.find((c) => (c.ownerId ?? c.id) === held.ownerId);
|
|
141
|
+
if (current && this.usable(provider, current.id)) {
|
|
142
|
+
held.at = this.now();
|
|
143
|
+
return current;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const next = credentials.find((c) => this.usable(provider, c.id));
|
|
148
|
+
if (!next)
|
|
149
|
+
return null;
|
|
150
|
+
if (conversation)
|
|
151
|
+
this.sticky.set(conversation, { provider, ownerId: next.ownerId ?? next.id, at: this.now() });
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Whether this provider has any credential that could answer right now. Asked before a turn is
|
|
156
|
+
* committed to a provider, so a session whose primary is rate-limited for the next hour goes to
|
|
157
|
+
* its fallback instead of failing once per request until the window resets.
|
|
158
|
+
*/
|
|
159
|
+
hasUsable(provider, credentials) {
|
|
160
|
+
return credentials.some((c) => this.usable(provider, c.id));
|
|
161
|
+
}
|
|
162
|
+
/** Record a failure. Returns the verdict so the caller can decide whether to try the next one. */
|
|
163
|
+
penalise(provider, id, status, retryAfterMs, body = "") {
|
|
164
|
+
const verdict = classify(status, retryAfterMs, body);
|
|
165
|
+
if (!verdict.retryable)
|
|
166
|
+
return verdict;
|
|
167
|
+
const h = this.healthOf(provider, id);
|
|
168
|
+
h.failures++;
|
|
169
|
+
if (verdict.quarantine)
|
|
170
|
+
h.quarantined = true;
|
|
171
|
+
else
|
|
172
|
+
h.cooldownUntil = Math.max(h.cooldownUntil, this.now() + verdict.cooldownMs);
|
|
173
|
+
return verdict;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* A credential that answered is not rejected, so a quarantine lifts and the failure count resets.
|
|
177
|
+
*
|
|
178
|
+
* An unexpired cooldown is left alone. Requests overlap, and a 200 arriving after a concurrent
|
|
179
|
+
* 429 does not mean the rate limit went away — clearing it here would send the next turn straight
|
|
180
|
+
* back into the limit. The cooldown expires on its own soon enough.
|
|
181
|
+
*/
|
|
182
|
+
succeed(provider, id) {
|
|
183
|
+
const h = this.healthOf(provider, id);
|
|
184
|
+
if (h.cooldownUntil <= this.now())
|
|
185
|
+
h.cooldownUntil = 0;
|
|
186
|
+
h.quarantined = false;
|
|
187
|
+
h.failures = 0;
|
|
188
|
+
}
|
|
189
|
+
/** Operator action from the dashboard: give a parked credential another chance now. */
|
|
190
|
+
clear(provider, id) {
|
|
191
|
+
if (id) {
|
|
192
|
+
this.health.delete(this.key(provider, id));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
for (const key of [...this.health.keys()])
|
|
196
|
+
if (key.startsWith(`${provider}/`))
|
|
197
|
+
this.health.delete(key);
|
|
198
|
+
}
|
|
199
|
+
/** For the Health screen: why a credential is not being used, and for how long. */
|
|
200
|
+
report(provider, credentials) {
|
|
201
|
+
const now = this.now();
|
|
202
|
+
return credentials.map((c) => {
|
|
203
|
+
const h = this.healthOf(provider, c.id);
|
|
204
|
+
const cooling = !h.quarantined && h.cooldownUntil > now;
|
|
205
|
+
return {
|
|
206
|
+
id: c.id,
|
|
207
|
+
...(c.label ? { label: c.label } : {}),
|
|
208
|
+
state: h.quarantined ? "quarantined" : cooling ? "cooling" : "ready",
|
|
209
|
+
...(cooling ? { cooldownSeconds: Math.ceil((h.cooldownUntil - now) / 1000) } : {}),
|
|
210
|
+
failures: h.failures,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
expireSticky() {
|
|
215
|
+
const cutoff = this.now() - this.stickyTtlMs;
|
|
216
|
+
for (const [conversation, held] of this.sticky)
|
|
217
|
+
if (held.at < cutoff)
|
|
218
|
+
this.sticky.delete(conversation);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The targets to try, in order, with duplicates removed. A fallback naming the same provider and
|
|
223
|
+
* model as one already in the list is a configuration mistake rather than a second chance.
|
|
224
|
+
*/
|
|
225
|
+
export function targets(primary, fallbacks = []) {
|
|
226
|
+
const out = [primary];
|
|
227
|
+
for (const f of fallbacks) {
|
|
228
|
+
if (out.some((t) => t.provider === f.provider && t.model === f.model))
|
|
229
|
+
continue;
|
|
230
|
+
out.push({ ...f, tag: `${f.provider}/${f.model}` });
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
@@ -18,8 +18,12 @@ export const PRESETS = [
|
|
|
18
18
|
// but no accepted effort-level set for its Anthropic endpoint; strip effort rather than guess.
|
|
19
19
|
effortLevels: [],
|
|
20
20
|
thinking: "enabled",
|
|
21
|
+
// Measured 2026-09-17, not documented: a `web_search_20250305` tool sent to this endpoint came
|
|
22
|
+
// back with server_tool_use, a web_search_tool_result holding ten hits, and
|
|
23
|
+
// usage.server_tool_use.web_search_requests = 1. DeepSeek runs the search itself.
|
|
24
|
+
serverTools: true,
|
|
21
25
|
verified: true,
|
|
22
|
-
notes: "Anthropic thinking is accepted; budget_tokens is ignored.",
|
|
26
|
+
notes: "Anthropic thinking is accepted; budget_tokens is ignored. Runs web_search server-side (measured).",
|
|
23
27
|
docsUrl: "https://api-docs.deepseek.com/guides/anthropic_api/",
|
|
24
28
|
},
|
|
25
29
|
// Docs: https://platform.kimi.ai/docs/api/overview
|
|
@@ -51,7 +55,13 @@ export const PRESETS = [
|
|
|
51
55
|
vendorUrl: "https://z.ai/",
|
|
52
56
|
anthropicBaseUrl: "https://api.z.ai/api/anthropic",
|
|
53
57
|
authHeader: "authorization-bearer",
|
|
54
|
-
|
|
58
|
+
// No model list is served, so this list is the picker. glm-5.3-flash is named in
|
|
59
|
+
// https://docs.z.ai/guides/vlm/glm-5.3-flash; its FlashX sibling is not on the Coding Plan yet (issue #14).
|
|
60
|
+
fallbackModels: [
|
|
61
|
+
{ id: "glm-5.3", name: "GLM-5.3" },
|
|
62
|
+
{ id: "glm-5.3-flash", name: "GLM-5.3 Flash" },
|
|
63
|
+
{ id: "glm-5.2", name: "GLM-5.2" },
|
|
64
|
+
],
|
|
55
65
|
// https://docs.z.ai/devpack/tool/others documents the endpoint but not an Anthropic effort/thinking contract.
|
|
56
66
|
effortLevels: [],
|
|
57
67
|
thinking: "none",
|
|
@@ -161,6 +171,157 @@ export const PRESETS = [
|
|
|
161
171
|
notes: "Responses supports reasoning.effort; Chat Completions may vary by model. Model discovery is used instead of a stale fallback id.",
|
|
162
172
|
docsUrl: "https://docs.x.ai/docs/guides/reasoning",
|
|
163
173
|
},
|
|
174
|
+
// Docs: https://opencode.ai/docs/go/
|
|
175
|
+
//
|
|
176
|
+
// One subscription, one key, one `/models` catalog, three endpoints by which wire each model
|
|
177
|
+
// speaks: `/responses` for Muse Spark, Grok and GPT; `/chat/completions` for GLM, Kimi, DeepSeek,
|
|
178
|
+
// LongCat and MiMo; `/messages` (Anthropic) for MiniMax and Qwen. This used to be three presets
|
|
179
|
+
// because a provider carried one url and one wire; now a model carries its own wire, url and auth
|
|
180
|
+
// header (`ProviderModel`), so the account is one preset and `providerFor` folds the override in
|
|
181
|
+
// when the model is known. The provider-level values below are the Responses base — which is where
|
|
182
|
+
// Muse Spark lives, and the wire a model with no override gets.
|
|
183
|
+
//
|
|
184
|
+
// `x-opencode-session` is not optional at all: the vendor answers 400 `MissingSessionID` without
|
|
185
|
+
// it — "cannot be routed efficiently" — so it gates the request rather than just the cache.
|
|
186
|
+
//
|
|
187
|
+
// `/models` lists every model on the plan, the other two endpoints' models among them (measured
|
|
188
|
+
// 2026-09-18). Discovery therefore offers all three groups, and the wire each needs comes from
|
|
189
|
+
// this fallback list, which is the only place that fact is written down.
|
|
190
|
+
{
|
|
191
|
+
id: "opencode-go",
|
|
192
|
+
kind: "openai-compatible",
|
|
193
|
+
name: "OpenCode Go",
|
|
194
|
+
vendorUrl: "https://opencode.ai/go",
|
|
195
|
+
anthropicBaseUrl: "https://opencode.ai/zen/go/v1",
|
|
196
|
+
authHeader: "authorization-bearer",
|
|
197
|
+
wire: "responses",
|
|
198
|
+
sessionHeader: "x-opencode-session",
|
|
199
|
+
modelsUrl: "https://opencode.ai/zen/go/v1/models",
|
|
200
|
+
modelsAuthHeader: "authorization-bearer",
|
|
201
|
+
// Every wire below is from the endpoint table OpenCode publishes per model
|
|
202
|
+
// (https://opencode.ai/docs/ko/go/, read 2026-09-22) — the only place any of it is written
|
|
203
|
+
// down, since `/models` reports ids alone. Reading it off the family name instead is what left
|
|
204
|
+
// mimo-v2.6-pro on Responses, where the vendor answered 503 and the user saw an unexplained 529.
|
|
205
|
+
//
|
|
206
|
+
// No model states an effort ladder. The blanket empty one this list used to carry was an
|
|
207
|
+
// assumption rather than a measurement, and it was wrong: deepseek-v4.1-flash accepts all eight
|
|
208
|
+
// levels including max, and mimo-v2.6-pro accepts none/low/medium/high (measured 2026-09-22).
|
|
209
|
+
// It could not correct itself either — an empty ladder is never measured. Left unstated, each
|
|
210
|
+
// model's ladder is established on the first save.
|
|
211
|
+
fallbackModels: [
|
|
212
|
+
// Responses — the preset's own wire, so these carry no override.
|
|
213
|
+
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 (Contributor)" },
|
|
214
|
+
{ id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 (Contributor)" },
|
|
215
|
+
{ id: "grok-4.7", name: "Grok 4.7" },
|
|
216
|
+
{ id: "grok-4.6", name: "Grok 4.6" },
|
|
217
|
+
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna" },
|
|
218
|
+
// Chat Completions.
|
|
219
|
+
{ id: "glm-5.3", name: "GLM-5.3", wire: "chat" },
|
|
220
|
+
{ id: "glm-5.3-flash", name: "GLM-5.3 Flash", wire: "chat" },
|
|
221
|
+
{ id: "glm-5.2", name: "GLM-5.2", wire: "chat" },
|
|
222
|
+
{ id: "glm-5.1", name: "GLM-5.1", wire: "chat" },
|
|
223
|
+
{ id: "kimi-k3", name: "Kimi K3", wire: "chat" },
|
|
224
|
+
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", wire: "chat" },
|
|
225
|
+
{ id: "kimi-k2.6", name: "Kimi K2.6", wire: "chat" },
|
|
226
|
+
{ id: "longcat-2.0", name: "LongCat 2.0", wire: "chat" },
|
|
227
|
+
{ id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", wire: "chat" },
|
|
228
|
+
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", wire: "chat" },
|
|
229
|
+
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", wire: "chat" },
|
|
230
|
+
{ id: "mimo-v2.6-pro", name: "MiMo V2.6 Pro", wire: "chat" },
|
|
231
|
+
{ id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", wire: "chat" },
|
|
232
|
+
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", wire: "chat" },
|
|
233
|
+
{ id: "mimo-v2.5", name: "MiMo V2.5", wire: "chat" },
|
|
234
|
+
{ id: "hy4-preview", name: "Hy4 Preview", wire: "chat" },
|
|
235
|
+
{ id: "hy3", name: "Hy3", wire: "chat" },
|
|
236
|
+
// Anthropic Messages, which needs no translation. The url is deliberately one segment shorter
|
|
237
|
+
// than the provider's: an openai-compatible provider appends the endpoint name (`/responses`),
|
|
238
|
+
// so its base carries the `/v1`, but an anthropic-compatible one appends the caller's whole
|
|
239
|
+
// `/v1/messages` — a base ending in `/v1` would ask for `/v1/v1/messages`, which this vendor
|
|
240
|
+
// answers with its website, as a 404 page of HTML. Each endpoint follows the auth convention of
|
|
241
|
+
// the API it imitates, so these want Anthropic's header where the two OpenAI-wire groups on the
|
|
242
|
+
// same key want a bearer: measured 2026-09-18 with a deliberately wrong key, the header it does
|
|
243
|
+
// not recognise answers "Missing API key" and the one it does answers "Invalid API key".
|
|
244
|
+
{ id: "minimax-m3", name: "MiniMax M3", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
245
|
+
{ id: "minimax-m2.7", name: "MiniMax M2.7", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
246
|
+
{ id: "qwen3.8-max", name: "Qwen3.8 Max", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
247
|
+
{ id: "qwen3.8-flash", name: "Qwen3.8 Flash", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
248
|
+
{ id: "qwen3.7-max", name: "Qwen3.7 Max", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
249
|
+
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
250
|
+
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
|
|
251
|
+
],
|
|
252
|
+
// Measured 2026-09-18 against the live endpoint, one effort at a time: none, minimal, low,
|
|
253
|
+
// medium, high and xhigh are accepted; **max and ultra are refused** with invalid_request_error.
|
|
254
|
+
// The ladder therefore tops out at xhigh, which is unusual enough that reading it off the model
|
|
255
|
+
// card would have got it wrong twice — the first list here had max in it and no xhigh. It applies
|
|
256
|
+
// to the Responses models above; the Chat and Anthropic ones carry their own empty ladder.
|
|
257
|
+
effortLevels: ["none", "minimal", "low", "medium", "high", "xhigh"],
|
|
258
|
+
thinking: "none",
|
|
259
|
+
verified: true,
|
|
260
|
+
notes: "One subscription and key, three endpoints by wire. Measured 2026-09-18. Responses: Muse Spark answers, effort ladder tops out at xhigh, prompt cache reached 96% on a repeated turn. Chat: glm-5.3, glm-5.3-flash, kimi-k3, kimi-k2.7-code, deepseek-v4.1-flash, deepseek-v4-pro, longcat-2.0 and mimo-v2.5-pro all answered. Anthropic: minimax-m3, qwen3.8-max and qwen3.8-flash answered with the prompt cache at 99% on a repeated turn, while union-alpha, the free row, answered \"Model is unavailable\". The Muse Spark Contributor tier is ~90% cheaper because Meta states those interactions improve its products, and OpenCode refuses the model until the workspace opts in (403 DataPolicyError); it is also limited to some regions.",
|
|
261
|
+
docsUrl: "https://opencode.ai/docs/go/",
|
|
262
|
+
},
|
|
263
|
+
// Docs: https://opencode.ai/docs/zen/
|
|
264
|
+
//
|
|
265
|
+
// Zen and Go are two products on one account, not two names for one thing. Measured 2026-09-22:
|
|
266
|
+
// `/zen/v1/models` lists 76 models and `/zen/go/v1/models` 40, and neither contains the other —
|
|
267
|
+
// Muse Spark and the MiMo rows are Go's, the Claude and GPT rows are Zen's. The key is shared: a
|
|
268
|
+
// Go key answered 402 on Zen rather than 401, so what separates them is the base URL. Pointing a
|
|
269
|
+
// Zen key at Go's preset is what answers "This Go model requires Global regions", because the
|
|
270
|
+
// model it then asks for is one of Go's.
|
|
271
|
+
//
|
|
272
|
+
// Zen bills from a balance, not from the Go subscription: on an account holding a Go plan and no
|
|
273
|
+
// Zen credit, all three wires answered 402 "Insufficient account funds".
|
|
274
|
+
//
|
|
275
|
+
// It wants no session header. Go refuses a request without `x-opencode-session` (400
|
|
276
|
+
// MissingSessionID); Zen answered the same with it and without it, so none is sent.
|
|
277
|
+
{
|
|
278
|
+
id: "opencode-zen",
|
|
279
|
+
kind: "openai-compatible",
|
|
280
|
+
name: "OpenCode Zen",
|
|
281
|
+
vendorUrl: "https://opencode.ai/zen",
|
|
282
|
+
anthropicBaseUrl: "https://opencode.ai/zen/v1",
|
|
283
|
+
authHeader: "authorization-bearer",
|
|
284
|
+
modelsUrl: "https://opencode.ai/zen/v1/models",
|
|
285
|
+
modelsAuthHeader: "authorization-bearer",
|
|
286
|
+
wire: "chat",
|
|
287
|
+
// Every wire here is from the endpoint table OpenCode publishes per model
|
|
288
|
+
// (https://opencode.ai/docs/ko/zen/, read 2026-09-22), not from guessing by family — guessing
|
|
289
|
+
// put Grok on Chat when it is on Responses, and Gemini on Chat when it is on neither.
|
|
290
|
+
//
|
|
291
|
+
// Two families are left out on purpose. Gemini is served at /zen/v1/models/<id>, Google's own
|
|
292
|
+
// shape, and jev at /systemone; ClaudeRipple speaks Chat Completions, Responses and Anthropic
|
|
293
|
+
// Messages, so there is nothing here that could carry them. They are still in the catalogue, so
|
|
294
|
+
// discovery will offer them; the measurement then finds no wire that answers and writes none.
|
|
295
|
+
//
|
|
296
|
+
// The Chat rows come first because the connection test posts to Chat Completions and takes the
|
|
297
|
+
// preset's first Chat model — deepseek-v4.1-flash, which the table says is served there.
|
|
298
|
+
fallbackModels: [
|
|
299
|
+
{ id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", wire: "chat" },
|
|
300
|
+
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", wire: "chat" },
|
|
301
|
+
{ id: "minimax-m3", name: "MiniMax M3", wire: "chat" },
|
|
302
|
+
{ id: "glm-5.3", name: "GLM 5.3", wire: "chat" },
|
|
303
|
+
{ id: "kimi-k3", name: "Kimi K3", wire: "chat" },
|
|
304
|
+
{ id: "gpt-6-astra", name: "GPT 6 Astra", wire: "responses" },
|
|
305
|
+
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", wire: "responses" },
|
|
306
|
+
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", wire: "responses" },
|
|
307
|
+
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", wire: "responses" },
|
|
308
|
+
{ id: "grok-4.7", name: "Grok 4.7", wire: "responses" },
|
|
309
|
+
{ id: "muse-spark-1.3", name: "Muse Spark 1.3", wire: "responses" },
|
|
310
|
+
{ id: "claude-opus-5", name: "Claude Opus 5", wire: "anthropic", url: "https://opencode.ai/zen", authHeader: "x-api-key" },
|
|
311
|
+
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", wire: "anthropic", url: "https://opencode.ai/zen", authHeader: "x-api-key" },
|
|
312
|
+
{ id: "claude-fable-5-1", name: "Claude Fable 5.1", wire: "anthropic", url: "https://opencode.ai/zen", authHeader: "x-api-key" },
|
|
313
|
+
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", wire: "anthropic", url: "https://opencode.ai/zen", authHeader: "x-api-key" },
|
|
314
|
+
{ id: "qwen3.8-flash", name: "Qwen3.8 Flash", wire: "anthropic", url: "https://opencode.ai/zen", authHeader: "x-api-key" },
|
|
315
|
+
],
|
|
316
|
+
// A starting point for the measurement, not a measurement: an empty ladder would tell the
|
|
317
|
+
// router this provider takes no effort at all, and a model whose ladder is empty is never
|
|
318
|
+
// measured, so the levels Zen does take could never be found.
|
|
319
|
+
effortLevels: ["none", "minimal", "low", "medium", "high", "xhigh"],
|
|
320
|
+
thinking: "none",
|
|
321
|
+
verified: true,
|
|
322
|
+
notes: "Separate from OpenCode Go and separately billed: Zen draws on a credit balance while Go is the subscription, and the two catalogues differ (76 models against 40, neither a subset of the other). Measured 2026-09-22: the key is shared \u2014 a Go key reached Zen's billing rather than being refused \u2014 and no session header is required, unlike Go. Each model's endpoint is published per model and the three wires are split across families: DeepSeek, MiniMax, GLM and Kimi on Chat Completions, the GPT and Grok rows plus Muse Spark on Responses, and the Claude and Qwen rows on Anthropic Messages. Gemini (/zen/v1/models/<id>) and jev (/systemone) are served in shapes ClaudeRipple does not speak and are not offered here. Effort ladders are unverified: the account had no Zen balance, so the per-model measurement settles them on the first save.",
|
|
323
|
+
docsUrl: "https://opencode.ai/docs/zen/",
|
|
324
|
+
},
|
|
164
325
|
// Docs: https://docs.mistral.ai/api/endpoint/chat and https://docs.mistral.ai/api/endpoint/models
|
|
165
326
|
{
|
|
166
327
|
id: "mistral",
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Runtime view of Claude subscription accounts: current Claude Code/Desktop login first, followed by
|
|
2
|
+
// ClaudeRipple-owned OAuth accounts. Stored grants are refreshed just before expiry and projected as
|
|
3
|
+
// ordinary proxy credentials, so the shared CredentialPool supplies affinity, cooldown and retry.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import { redactErrorText } from "../redact.js";
|
|
6
|
+
import { markClaudeAccountNeedsReauth, readClaudeOAuthAccounts, replaceClaudeOAuthAccount, } from "./anthropic-accounts.js";
|
|
7
|
+
import { ClaudeCodeAuthStore, nativeAnthropicHeaders, observedAnthropicHeaders, } from "./anthropic.js";
|
|
8
|
+
import { CLAUDE_OAUTH, ClaudeOAuthTokenError, refreshClaudeOAuth, } from "./claude-oauth.js";
|
|
9
|
+
function credentialId(ownerId, ...generation) {
|
|
10
|
+
// A fresh access or refresh token must not inherit the previous generation's quarantine. The digest
|
|
11
|
+
// is local runtime identity only; ownerId remains the durable account id shown by admin surfaces.
|
|
12
|
+
const hash = crypto.createHash("sha256");
|
|
13
|
+
for (const part of generation)
|
|
14
|
+
hash.update(String(part.length)).update(":").update(part);
|
|
15
|
+
return `${ownerId}:${hash.digest("hex").slice(0, 12)}`;
|
|
16
|
+
}
|
|
17
|
+
/** All account projections in this process share refresh work; the key contains no token material. */
|
|
18
|
+
const refreshing = new Map();
|
|
19
|
+
function refreshKey(home, account) {
|
|
20
|
+
const generation = crypto.createHash("sha256").update(account.refreshToken).digest("hex");
|
|
21
|
+
return `${home}\0${account.id}\0${generation}`;
|
|
22
|
+
}
|
|
23
|
+
export class ClaudeAccountAuthPool {
|
|
24
|
+
home;
|
|
25
|
+
now;
|
|
26
|
+
log;
|
|
27
|
+
fetchImpl;
|
|
28
|
+
current;
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.home = options.home;
|
|
31
|
+
this.now = options.now ?? Date.now;
|
|
32
|
+
this.log = options.log;
|
|
33
|
+
this.fetchImpl = options.fetch;
|
|
34
|
+
this.current = options.current ?? new ClaudeCodeAuthStore(undefined, {
|
|
35
|
+
home: options.home,
|
|
36
|
+
...(options.observed ? { observed: options.observed } : {}),
|
|
37
|
+
...(options.env ? { env: options.env } : {}),
|
|
38
|
+
...(options.now ? { now: options.now } : {}),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Refresh all due stored accounts, with one network request per account in this process. */
|
|
42
|
+
async refreshIfNeeded() {
|
|
43
|
+
const due = readClaudeOAuthAccounts(this.home).filter((account) => !account.needsReauth && this.now() >= account.expiresAt - CLAUDE_OAUTH.refreshLeadMs);
|
|
44
|
+
await Promise.all(due.map((account) => this.refresh(account)));
|
|
45
|
+
}
|
|
46
|
+
refresh(account) {
|
|
47
|
+
const key = refreshKey(this.home, account);
|
|
48
|
+
const running = refreshing.get(key);
|
|
49
|
+
if (running)
|
|
50
|
+
return running;
|
|
51
|
+
const task = refreshClaudeOAuth(account.refreshToken, {
|
|
52
|
+
...(this.fetchImpl ? { fetch: this.fetchImpl } : {}),
|
|
53
|
+
now: this.now,
|
|
54
|
+
}).then((grant) => {
|
|
55
|
+
if (replaceClaudeOAuthAccount(this.home, account.id, account.refreshToken, grant)) {
|
|
56
|
+
this.log.info(`claude account ${account.id.slice(0, 8)}: token refreshed, valid until ${new Date(grant.expiresAt).toISOString()}`);
|
|
57
|
+
}
|
|
58
|
+
}, (error) => {
|
|
59
|
+
// Only a definitive refresh-token rejection asks the user to sign in again. Network and 5xx
|
|
60
|
+
// failures leave the account recoverable; if its access token expires it simply drops out.
|
|
61
|
+
if (error instanceof ClaudeOAuthTokenError && error.needsReauth) {
|
|
62
|
+
markClaudeAccountNeedsReauth(this.home, account.id, account.refreshToken);
|
|
63
|
+
this.log.warn(`claude account ${account.id.slice(0, 8)}: refresh rejected; sign-in required`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const detail = redactErrorText(error.message, [account.token, account.refreshToken], 300);
|
|
67
|
+
this.log.warn(`claude account ${account.id.slice(0, 8)}: refresh failed: ${detail}`);
|
|
68
|
+
}
|
|
69
|
+
}).finally(() => refreshing.delete(key));
|
|
70
|
+
refreshing.set(key, task);
|
|
71
|
+
return task;
|
|
72
|
+
}
|
|
73
|
+
/** Fresh credentials for one turn. No token leaves this return value except as an HTTP header. */
|
|
74
|
+
async credentials() {
|
|
75
|
+
const ready = this.peekCredentials();
|
|
76
|
+
const refreshing = this.refreshIfNeeded();
|
|
77
|
+
// A due-but-unexpired access token remains usable while its refresh runs. Do not turn the
|
|
78
|
+
// five-minute refresh lead into request latency; wait only when no credential can be sent now.
|
|
79
|
+
if (ready.length > 0) {
|
|
80
|
+
void refreshing.catch((error) => this.log.warn(`claude account refresh task failed: ${error.message}`));
|
|
81
|
+
return ready;
|
|
82
|
+
}
|
|
83
|
+
await refreshing;
|
|
84
|
+
return this.peekCredentials();
|
|
85
|
+
}
|
|
86
|
+
/** Current projection without network I/O, used by status and pre-routing health checks. */
|
|
87
|
+
peekCredentials() {
|
|
88
|
+
const out = [];
|
|
89
|
+
const current = this.current.get();
|
|
90
|
+
let currentToken;
|
|
91
|
+
if (!(current instanceof Error)) {
|
|
92
|
+
if (current.source === "observed") {
|
|
93
|
+
currentToken = current.observed.authorization.replace(/^Bearer\s+/i, "");
|
|
94
|
+
out.push({
|
|
95
|
+
id: credentialId("current", currentToken),
|
|
96
|
+
ownerId: "current",
|
|
97
|
+
label: "Current Claude session",
|
|
98
|
+
headers: observedAnthropicHeaders(current.observed),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
currentToken = current.credentials.accessToken;
|
|
103
|
+
out.push({
|
|
104
|
+
id: credentialId("current", currentToken),
|
|
105
|
+
ownerId: "current",
|
|
106
|
+
label: "Current Claude login",
|
|
107
|
+
headers: nativeAnthropicHeaders({ type: "anthropic", auth: "claude-code" }, current.credentials),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const account of readClaudeOAuthAccounts(this.home)) {
|
|
112
|
+
if (account.needsReauth || this.now() >= account.expiresAt)
|
|
113
|
+
continue;
|
|
114
|
+
// The legacy single-account file is also what ClaudeCodeAuthStore read as current. Until the
|
|
115
|
+
// first migration write, omit that duplicate instead of trying the same token twice.
|
|
116
|
+
if (currentToken && account.token === currentToken)
|
|
117
|
+
continue;
|
|
118
|
+
out.push({
|
|
119
|
+
id: credentialId(account.id, account.token, account.refreshToken),
|
|
120
|
+
ownerId: account.id,
|
|
121
|
+
label: account.label,
|
|
122
|
+
headers: nativeAnthropicHeaders({ type: "anthropic", auth: "claude-code" }, { accessToken: account.token, expiresAt: account.expiresAt }),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
/** Persist a rejected stored account as requiring re-login. The current CLI account stays external. */
|
|
128
|
+
reject(id) {
|
|
129
|
+
const ownerId = id.split(":", 1)[0];
|
|
130
|
+
if (ownerId === "current")
|
|
131
|
+
return;
|
|
132
|
+
const account = readClaudeOAuthAccounts(this.home).find((candidate) => candidate.id === ownerId);
|
|
133
|
+
// The 401 belongs to the token generation that made the request. A refresh or re-login may have
|
|
134
|
+
// replaced it while the request was in flight; never quarantine that newer grant for an old 401.
|
|
135
|
+
if (!account || credentialId(ownerId, account.token, account.refreshToken) !== id)
|
|
136
|
+
return;
|
|
137
|
+
markClaudeAccountNeedsReauth(this.home, ownerId, account.refreshToken);
|
|
138
|
+
}
|
|
139
|
+
}
|