clauderipple 0.2.0 → 0.3.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 +76 -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/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 +156 -1
- 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 +697 -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
|
@@ -6,6 +6,76 @@
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
+
/**
|
|
10
|
+
* The provider's own key, re-sent under the header another wire on the same account expects.
|
|
11
|
+
*
|
|
12
|
+
* The key is never restated in a model entry, so there is exactly one copy of it to rotate and one
|
|
13
|
+
* to leak. Every other header the provider set is kept: only the auth one is replaced, because
|
|
14
|
+
* sending both conventions at once would hand the endpoint a credential it did not ask for.
|
|
15
|
+
*/
|
|
16
|
+
function reauthorized(headers, kind) {
|
|
17
|
+
if (!headers)
|
|
18
|
+
return headers;
|
|
19
|
+
const kept = {};
|
|
20
|
+
let key;
|
|
21
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
22
|
+
const lower = name.toLowerCase();
|
|
23
|
+
if (lower === "x-api-key") {
|
|
24
|
+
key ??= value;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (lower === "authorization") {
|
|
28
|
+
key ??= value.replace(/^Bearer\s+/i, "");
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
kept[name] = value;
|
|
32
|
+
}
|
|
33
|
+
// Nothing recognisable to move: leave the headers exactly as they were rather than inventing an
|
|
34
|
+
// empty credential, which would read to the endpoint as a missing key instead of a config error.
|
|
35
|
+
if (key === undefined)
|
|
36
|
+
return headers;
|
|
37
|
+
if (kind === "x-api-key")
|
|
38
|
+
kept["x-api-key"] = key;
|
|
39
|
+
else
|
|
40
|
+
kept.authorization = `Bearer ${key}`;
|
|
41
|
+
return kept;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The provider shape a request for `model` is actually talking to.
|
|
45
|
+
*
|
|
46
|
+
* A model may speak another wire than the rest of its provider, on another path, under another auth
|
|
47
|
+
* convention (see `ProviderModel.wire`). Those overrides are folded back into an ordinary provider
|
|
48
|
+
* here, so every `provider.type` branch downstream keeps working against one unchanged contract —
|
|
49
|
+
* and one subscription stays one provider instead of three (2026-09-22).
|
|
50
|
+
*
|
|
51
|
+
* A provider with no override for this model is returned as it is, by identity, so nothing on the
|
|
52
|
+
* ordinary path pays for this.
|
|
53
|
+
*/
|
|
54
|
+
export function providerFor(provider, model) {
|
|
55
|
+
if (!model || !("models" in provider) || !provider.models)
|
|
56
|
+
return provider;
|
|
57
|
+
const entry = provider.models.find((m) => m.id === model);
|
|
58
|
+
if (!entry || (entry.wire === undefined && entry.url === undefined && entry.authHeader === undefined))
|
|
59
|
+
return provider;
|
|
60
|
+
const url = entry.url ?? ("url" in provider ? provider.url : undefined);
|
|
61
|
+
const headers = entry.authHeader
|
|
62
|
+
? reauthorized("headers" in provider ? provider.headers : undefined, entry.authHeader)
|
|
63
|
+
: ("headers" in provider ? provider.headers : undefined);
|
|
64
|
+
const shared = {
|
|
65
|
+
...provider,
|
|
66
|
+
...(url !== undefined ? { url } : {}),
|
|
67
|
+
...(headers !== undefined ? { headers } : {}),
|
|
68
|
+
};
|
|
69
|
+
// Anthropic Messages needs no translation, so this model is served by the anthropic-compatible
|
|
70
|
+
// adapter instead. The two `caps` shapes do not describe the same things, and only an effort
|
|
71
|
+
// ladder carries over; the rest fall back to the strict defaults `resolveCompatibleCaps` applies,
|
|
72
|
+
// which is what the separate provider did before this.
|
|
73
|
+
if (entry.wire === "anthropic") {
|
|
74
|
+
const { wire: _wire, caps: _caps, ...rest } = shared;
|
|
75
|
+
return { ...rest, type: "anthropic-compatible", url: url ?? "", caps: { effortLevels: entry.effortLevels ?? [] } };
|
|
76
|
+
}
|
|
77
|
+
return { ...shared, type: "openai-compatible", url: url ?? "", ...(entry.wire ? { wire: entry.wire } : {}) };
|
|
78
|
+
}
|
|
9
79
|
export const PICKER_HOSTS_DEFAULT = ["claude.ai"];
|
|
10
80
|
export function terminateHosts(c) {
|
|
11
81
|
const hosts = [c.upstream];
|
|
@@ -21,7 +91,7 @@ export const DEFAULTS = {
|
|
|
21
91
|
direct: [],
|
|
22
92
|
aliases: {},
|
|
23
93
|
effortClamp: { ultra: "max" },
|
|
24
|
-
cli: { extraModels: [] },
|
|
94
|
+
cli: { extraModels: [], agentFiles: true },
|
|
25
95
|
health: { maxConsecutiveUpstreamFailures: 20 },
|
|
26
96
|
log: { maxBytes: 5 * 1024 * 1024, keep: 3 },
|
|
27
97
|
};
|
|
@@ -44,12 +114,128 @@ function merge(base, over) {
|
|
|
44
114
|
...(over.picker ? { picker: { ...over.picker } } : {}),
|
|
45
115
|
};
|
|
46
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* An HTTP header value is bytes, so anything above U+00FF cannot travel in one. A key pasted with
|
|
119
|
+
* the label beside it — "API 키 sk-…" — fails deep inside the HTTP client with a message about
|
|
120
|
+
* ByteString conversion and a character code, which tells the person nothing about what they did.
|
|
121
|
+
* Returns what to say instead, or null when the value is fine.
|
|
122
|
+
*/
|
|
123
|
+
export function headerValueProblem(value) {
|
|
124
|
+
for (let i = 0; i < value.length; i++) {
|
|
125
|
+
const code = value.charCodeAt(i);
|
|
126
|
+
if (code > 0xff)
|
|
127
|
+
return `character ${i + 1} is "${value[i]}", which cannot travel in an HTTP header — paste the key on its own, without any label or surrounding text`;
|
|
128
|
+
// A newline would split the header; a stray one usually means a copied line rather than a key.
|
|
129
|
+
if (code === 0x0a || code === 0x0d)
|
|
130
|
+
return `there is a line break at character ${i + 1} — paste the key on its own, without the line around it`;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/** Named once because three provider kinds report it, and a drifting copy would describe the wrong shape. */
|
|
135
|
+
const MODELS_SHAPE = 'models must be entries with a string id and optional name, effortLevels, url, wire ("chat" | "responses" | "anthropic") and authHeader ("x-api-key" | "authorization-bearer")';
|
|
47
136
|
function validModels(models) {
|
|
48
|
-
return Array.isArray(models) && models.every((model) =>
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
137
|
+
return Array.isArray(models) && models.every((model) => {
|
|
138
|
+
if (model === null || typeof model !== "object")
|
|
139
|
+
return false;
|
|
140
|
+
const m = model;
|
|
141
|
+
if (typeof m.id !== "string")
|
|
142
|
+
return false;
|
|
143
|
+
if (m.name !== undefined && typeof m.name !== "string")
|
|
144
|
+
return false;
|
|
145
|
+
if (m.effortLevels !== undefined && (!Array.isArray(m.effortLevels) || m.effortLevels.some((level) => typeof level !== "string")))
|
|
146
|
+
return false;
|
|
147
|
+
// A model's own wire, endpoint and auth convention: wrong here and the request goes out in the
|
|
148
|
+
// wrong shape to the wrong path, which reads as a provider that rejects a working key.
|
|
149
|
+
if (m.wire !== undefined && m.wire !== "chat" && m.wire !== "responses" && m.wire !== "anthropic")
|
|
150
|
+
return false;
|
|
151
|
+
if (m.url !== undefined && (typeof m.url !== "string" || !/^https?:\/\//.test(m.url)))
|
|
152
|
+
return false;
|
|
153
|
+
if (m.authHeader !== undefined && m.authHeader !== "x-api-key" && m.authHeader !== "authorization-bearer")
|
|
154
|
+
return false;
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The presets one OpenCode Go account had to be split across, and what each third actually was.
|
|
160
|
+
*
|
|
161
|
+
* Keyed on `preset` rather than on the provider's name: the GUI chose those names itself
|
|
162
|
+
* (`opencode-go-responses`) and the user may have chosen others since.
|
|
163
|
+
*/
|
|
164
|
+
const SPLIT_BY_PRESET = {
|
|
165
|
+
"opencode-go": { wire: "responses", authHeader: "authorization-bearer" },
|
|
166
|
+
"opencode-go-chat": { wire: "chat", authHeader: "authorization-bearer" },
|
|
167
|
+
"opencode-go-anthropic": { wire: "anthropic", authHeader: "x-api-key" },
|
|
168
|
+
};
|
|
169
|
+
/** The effort ladder a provider actually offered, so it can travel with the models that had it. */
|
|
170
|
+
function ladderOf(p) {
|
|
171
|
+
if (p.type === "openai-compatible")
|
|
172
|
+
return p.caps?.reasoning === "effort" ? [...(p.caps.effortLevels ?? [])] : [];
|
|
173
|
+
if (p.type === "anthropic-compatible")
|
|
174
|
+
return [...(p.caps?.effortLevels ?? [])];
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The three providers one OpenCode Go account used to need, read as the single provider it is.
|
|
179
|
+
*
|
|
180
|
+
* `wire` and `url` sat on the provider, so an account whose models speak Responses, Chat
|
|
181
|
+
* Completions and Anthropic Messages had to be configured three times — and every `direct` rule had
|
|
182
|
+
* to name whichever third its model lived in. A model carries its own wire now, so a config written
|
|
183
|
+
* against the old shape is folded here on the way in.
|
|
184
|
+
*
|
|
185
|
+
* Nothing is rewritten on disk. A save from the GUI is what eventually settles it, and until then
|
|
186
|
+
* this stays reversible: the file still describes what an older router would read correctly.
|
|
187
|
+
*
|
|
188
|
+
* Every model keeps its own endpoint, wire and auth convention rather than inheriting the merged
|
|
189
|
+
* provider's, so which third happens to become the base cannot change where a request goes.
|
|
190
|
+
*/
|
|
191
|
+
function foldSplitProviders(c) {
|
|
192
|
+
const parts = Object.entries(c.providers).filter(([, p]) => "preset" in p && typeof p.preset === "string" && p.preset in SPLIT_BY_PRESET);
|
|
193
|
+
// One of them on its own is not a split, and is left exactly as it is.
|
|
194
|
+
if (parts.length < 2)
|
|
195
|
+
return c;
|
|
196
|
+
const [baseName, baseProvider] = parts.find(([, p]) => p.preset === "opencode-go") ?? parts[0];
|
|
197
|
+
// Take the canonical name unless something outside the split already holds it.
|
|
198
|
+
const name = parts.some(([n]) => n === "opencode-go") || !("opencode-go" in c.providers) ? "opencode-go" : baseName;
|
|
199
|
+
const models = [];
|
|
200
|
+
for (const [, p] of parts) {
|
|
201
|
+
const split = SPLIT_BY_PRESET[p.preset];
|
|
202
|
+
const ladder = ladderOf(p);
|
|
203
|
+
for (const model of ("models" in p && p.models) || []) {
|
|
204
|
+
// `/models` lists every model on the plan, so the same id can appear under more than one
|
|
205
|
+
// third. The first one wins: they describe one model, and a duplicate entry would shadow it.
|
|
206
|
+
if (models.some((m) => m.id === model.id))
|
|
207
|
+
continue;
|
|
208
|
+
models.push({
|
|
209
|
+
...model,
|
|
210
|
+
wire: split.wire,
|
|
211
|
+
authHeader: split.authHeader,
|
|
212
|
+
...("url" in p ? { url: p.url } : {}),
|
|
213
|
+
// The merged provider's caps no longer describe this model, so the ladder its own endpoint
|
|
214
|
+
// published travels with it. Inheriting the Responses one would start sending an effort to
|
|
215
|
+
// an endpoint that never accepted one.
|
|
216
|
+
effortLevels: model.effortLevels ?? ladder,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const providers = {};
|
|
221
|
+
for (const [n, p] of Object.entries(c.providers))
|
|
222
|
+
if (!parts.some(([partName]) => partName === n))
|
|
223
|
+
providers[n] = p;
|
|
224
|
+
providers[name] = { ...baseProvider, preset: "opencode-go", models };
|
|
225
|
+
// Every reference to a name that has just gone follows it, or the config stops validating on a
|
|
226
|
+
// provider the user never removed.
|
|
227
|
+
const moved = new Map(parts.map(([n]) => [n, name]).filter(([from, to]) => from !== to));
|
|
228
|
+
const renamed = (of) => moved.get(of) ?? of;
|
|
229
|
+
return {
|
|
230
|
+
...c,
|
|
231
|
+
providers,
|
|
232
|
+
direct: c.direct.map((d) => ({ ...d, provider: renamed(d.provider) })),
|
|
233
|
+
routes: Object.fromEntries(Object.entries(c.routes).map(([alias, r]) => [alias, {
|
|
234
|
+
...r,
|
|
235
|
+
provider: renamed(r.provider),
|
|
236
|
+
...(r.fallbacks ? { fallbacks: r.fallbacks.map((f) => ({ ...f, provider: renamed(f.provider) })) } : {}),
|
|
237
|
+
}])),
|
|
238
|
+
};
|
|
53
239
|
}
|
|
54
240
|
export function validate(c) {
|
|
55
241
|
const errors = [];
|
|
@@ -58,6 +244,29 @@ export function validate(c) {
|
|
|
58
244
|
errors.push(`route ${alias}: unknown provider "${r.provider}"`);
|
|
59
245
|
if (!r.model)
|
|
60
246
|
errors.push(`route ${alias}: missing model`);
|
|
247
|
+
// A fallback is only reached when something has already gone wrong, so a broken one is found
|
|
248
|
+
// on the worst possible day. Refuse it at save time instead.
|
|
249
|
+
if (r.fallbacks !== undefined) {
|
|
250
|
+
if (!Array.isArray(r.fallbacks))
|
|
251
|
+
errors.push(`route ${alias}: fallbacks must be a list`);
|
|
252
|
+
else
|
|
253
|
+
r.fallbacks.forEach((f, i) => {
|
|
254
|
+
if (!f || typeof f !== "object") {
|
|
255
|
+
errors.push(`route ${alias}: fallback ${i} must be an object`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!c.providers[f.provider])
|
|
259
|
+
errors.push(`route ${alias}: fallback ${i} names unknown provider "${f.provider}"`);
|
|
260
|
+
// A native `anthropic` provider is routable only when its Claude OAuth pool is explicitly
|
|
261
|
+
// enabled. Existing native providers remain ingress-only to avoid hijacking Claude traffic.
|
|
262
|
+
else if (c.providers[f.provider].type === "anthropic" && !c.providers[f.provider].accountPool)
|
|
263
|
+
errors.push(`route ${alias}: fallback ${i} names "${f.provider}", which serves the OpenAI ingress only`);
|
|
264
|
+
if (!f.model || typeof f.model !== "string")
|
|
265
|
+
errors.push(`route ${alias}: fallback ${i} missing model`);
|
|
266
|
+
if (f.provider === r.provider && f.model === r.model)
|
|
267
|
+
errors.push(`route ${alias}: fallback ${i} repeats the primary target`);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
61
270
|
}
|
|
62
271
|
for (const d of c.direct) {
|
|
63
272
|
if (!c.providers[d.provider])
|
|
@@ -70,13 +279,49 @@ export function validate(c) {
|
|
|
70
279
|
errors.push(`provider ${name}: identity must be true or false`);
|
|
71
280
|
if (shared.instructionsAppend !== undefined && typeof shared.instructionsAppend !== "string")
|
|
72
281
|
errors.push(`provider ${name}: instructionsAppend must be a string`);
|
|
282
|
+
// Ids key the router's cooldown and quarantine state, so a duplicate would make two credentials
|
|
283
|
+
// share one health record and take each other down.
|
|
284
|
+
const pool = p.credentials;
|
|
285
|
+
if (pool !== undefined) {
|
|
286
|
+
if (!Array.isArray(pool))
|
|
287
|
+
errors.push(`provider ${name}: credentials must be a list`);
|
|
288
|
+
else {
|
|
289
|
+
const seen = new Set();
|
|
290
|
+
pool.forEach((entry, i) => {
|
|
291
|
+
const cred = entry;
|
|
292
|
+
if (!cred || typeof cred !== "object") {
|
|
293
|
+
errors.push(`provider ${name}: credential ${i} must be an object`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (typeof cred.id !== "string" || !cred.id)
|
|
297
|
+
errors.push(`provider ${name}: credential ${i} needs a non-empty id`);
|
|
298
|
+
else if (seen.has(cred.id))
|
|
299
|
+
errors.push(`provider ${name}: credential id "${cred.id}" is used twice`);
|
|
300
|
+
else
|
|
301
|
+
seen.add(cred.id);
|
|
302
|
+
if (!cred.headers || typeof cred.headers !== "object" || Array.isArray(cred.headers) ||
|
|
303
|
+
Object.values(cred.headers).some((value) => typeof value !== "string")) {
|
|
304
|
+
errors.push(`provider ${name}: credential ${i} headers must be a string record`);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
for (const [header, value] of Object.entries(cred.headers)) {
|
|
308
|
+
const problem = headerValueProblem(value);
|
|
309
|
+
if (problem)
|
|
310
|
+
errors.push(`provider ${name}: credential ${i} header "${header}": ${problem}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (cred.label !== undefined && typeof cred.label !== "string")
|
|
314
|
+
errors.push(`provider ${name}: credential ${i} label must be a string`);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
73
318
|
if (p.type === "anthropic-compatible") {
|
|
74
319
|
if (!/^https?:\/\//.test(p.url))
|
|
75
320
|
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
76
321
|
if (p.preset !== undefined && typeof p.preset !== "string")
|
|
77
322
|
errors.push(`provider ${name}: preset must be a string`);
|
|
78
323
|
if (p.models !== undefined && !validModels(p.models)) {
|
|
79
|
-
errors.push(`provider ${name}:
|
|
324
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
80
325
|
}
|
|
81
326
|
if (p.caps !== undefined) {
|
|
82
327
|
const caps = p.caps;
|
|
@@ -91,7 +336,7 @@ export function validate(c) {
|
|
|
91
336
|
}
|
|
92
337
|
else if (p.type === "chatgpt") {
|
|
93
338
|
if (p.models !== undefined && !validModels(p.models)) {
|
|
94
|
-
errors.push(`provider ${name}:
|
|
339
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
95
340
|
}
|
|
96
341
|
if (p.url && !/^https?:\/\//.test(p.url))
|
|
97
342
|
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
@@ -107,7 +352,7 @@ export function validate(c) {
|
|
|
107
352
|
errors.push(`provider ${name}: headers must be a string record`);
|
|
108
353
|
}
|
|
109
354
|
if (p.models !== undefined && !validModels(p.models)) {
|
|
110
|
-
errors.push(`provider ${name}:
|
|
355
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
111
356
|
}
|
|
112
357
|
if (p.caps !== undefined && (!p.caps || typeof p.caps !== "object" || Array.isArray(p.caps) ||
|
|
113
358
|
(p.caps.effortLevels !== undefined && (!Array.isArray(p.caps.effortLevels) || p.caps.effortLevels.some((level) => typeof level !== "string"))) ||
|
|
@@ -120,8 +365,12 @@ export function validate(c) {
|
|
|
120
365
|
errors.push(`provider ${name}: auth must be "api-key" or "claude-code"`);
|
|
121
366
|
if (p.apiKey !== undefined && typeof p.apiKey !== "string")
|
|
122
367
|
errors.push(`provider ${name}: apiKey must be a string`);
|
|
368
|
+
if (p.accountPool !== undefined && typeof p.accountPool !== "boolean")
|
|
369
|
+
errors.push(`provider ${name}: accountPool must be true or false`);
|
|
370
|
+
if (p.accountPool && p.auth !== "claude-code")
|
|
371
|
+
errors.push(`provider ${name}: accountPool requires auth "claude-code"`);
|
|
123
372
|
if (p.models !== undefined && !validModels(p.models)) {
|
|
124
|
-
errors.push(`provider ${name}:
|
|
373
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
125
374
|
}
|
|
126
375
|
}
|
|
127
376
|
else {
|
|
@@ -130,6 +379,8 @@ export function validate(c) {
|
|
|
130
379
|
}
|
|
131
380
|
if (!(c.listen.port > 0 && c.listen.port < 65536))
|
|
132
381
|
errors.push("listen.port out of range");
|
|
382
|
+
if (c.cli.agentFiles !== undefined && typeof c.cli.agentFiles !== "boolean")
|
|
383
|
+
errors.push("cli.agentFiles must be true or false");
|
|
133
384
|
if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
|
|
134
385
|
errors.push("listen.openaiPort out of range");
|
|
135
386
|
return errors;
|
|
@@ -164,7 +415,9 @@ export class ConfigStore {
|
|
|
164
415
|
return;
|
|
165
416
|
try {
|
|
166
417
|
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
167
|
-
|
|
418
|
+
// Folded before validation, because the old three-provider shape is what is on disk and the
|
|
419
|
+
// references it carries have to be the ones that get checked.
|
|
420
|
+
const next = foldSplitProviders(merge(DEFAULTS, parsed));
|
|
168
421
|
const errors = validate(next);
|
|
169
422
|
if (errors.length === 0 || initial)
|
|
170
423
|
this.current = next;
|
package/dist/router/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import path from "node:path";
|
|
|
9
9
|
import { codexEnabled, writeCodexCatalog } from "../../cli/src/codex.js";
|
|
10
10
|
import { ingressModels } from "./ingress/models.js";
|
|
11
11
|
import { ConfigStore, configPath, homeDir, terminateHosts } from "./config.js";
|
|
12
|
+
import { defaultAgentDir, syncAgentFiles } from "./agents.js";
|
|
12
13
|
import { CertStore } from "./certs.js";
|
|
13
14
|
import { Logger } from "./log.js";
|
|
14
15
|
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
|
|
@@ -40,6 +41,14 @@ const store = new ConfigStore(undefined, (c, errors) => {
|
|
|
40
41
|
catch (e) {
|
|
41
42
|
log?.warn(`codex catalog: ${e.message}`);
|
|
42
43
|
}
|
|
44
|
+
// One place for the truth: the agent files a worker's model comes from are derived from this
|
|
45
|
+
// config, so a model ticked here cannot disagree with the alias a marker resolves to.
|
|
46
|
+
try {
|
|
47
|
+
syncAgentFiles(c, defaultAgentDir(), log ?? console);
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
(log ?? console).warn(`agent files: ${e.message}`);
|
|
51
|
+
}
|
|
43
52
|
});
|
|
44
53
|
const cfg0 = store.get();
|
|
45
54
|
log = new Logger(logFile, cfg0.log.maxBytes, cfg0.log.keep, !!process.env.CLAUDERIPPLE_ECHO || !logFile);
|
|
@@ -122,6 +131,9 @@ proxy
|
|
|
122
131
|
` (startup ${since(T_EXEC)}ms: node ${T_MODULE - T_EXEC}, config ${T_CONFIG - T_MODULE}, listen ${since(T_CONFIG)})`);
|
|
123
132
|
const ingressPort = await ingress.listen();
|
|
124
133
|
log.info(`clauderipple OpenAI ingress listening on 127.0.0.1:${ingressPort}`);
|
|
134
|
+
// Quota only arrives on GPT response headers, so a quiet day left the status 11 hours stale
|
|
135
|
+
// (2026-09-20). Ask once now, after listen(), and never wait for the answer.
|
|
136
|
+
proxy.chatgptRefreshAll();
|
|
125
137
|
const admin = await startAdmin({
|
|
126
138
|
config: () => store.get(),
|
|
127
139
|
configFile: configPath(),
|
|
@@ -130,7 +142,8 @@ proxy
|
|
|
130
142
|
health: () => health.consecutiveFailures,
|
|
131
143
|
version: VERSION,
|
|
132
144
|
requests,
|
|
133
|
-
chatgpt: () => ({ quota: proxy.chatgptRateLimits, auth: proxy.chatgptAuthStatus() }),
|
|
145
|
+
chatgpt: () => ({ quota: proxy.chatgptRateLimits, auth: proxy.chatgptAuthStatus(), refresh: (name) => proxy.chatgptFetchRateLimits(name), models: (name) => proxy.chatgptFetchModels(name) }),
|
|
146
|
+
credentials: () => proxy.credentialHealth(),
|
|
134
147
|
picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
|
|
135
148
|
observedClaudeCodeAuth,
|
|
136
149
|
shutdown: () => beginDrain("shutdown requested"),
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// intentionally never copied to the Anthropic-compatible upstream.
|
|
4
4
|
import http from "node:http";
|
|
5
5
|
import https from "node:https";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { providerFor } from "../config.js";
|
|
6
9
|
import { resolve } from "../routing.js";
|
|
7
10
|
import { PRESETS } from "../presets.js";
|
|
8
11
|
import { resolveCompatibleCaps, sanitizeForCompatible } from "../compat.js";
|
|
@@ -10,7 +13,8 @@ import { SseParser } from "../providers/chatgpt/sse.js";
|
|
|
10
13
|
import { ingressModels } from "./models.js";
|
|
11
14
|
import { requestId } from "../requestlog.js";
|
|
12
15
|
import { credentialHeaderValues, redactErrorText } from "../redact.js";
|
|
13
|
-
import { CLAUDE_CODE_IDENTITY,
|
|
16
|
+
import { CLAUDE_CODE_IDENTITY, fromClaudeCodeToolName, nativeAnthropicHeaders, toClaudeCodeToolName } from "../providers/anthropic.js";
|
|
17
|
+
import { ClaudeAccountAuthPool } from "../providers/anthropic-account-pool.js";
|
|
14
18
|
import { ObservedClaudeCodeAuth } from "../providers/anthropic-observed.js";
|
|
15
19
|
import { ResponsesEventMapper, chatToAnthropic, formatDataSse, formatSse, responsesEventsToChatChunks, responsesToAnthropic, responsesToChatCompletion, } from "./translate.js";
|
|
16
20
|
const MAX_BODY = 64 * 1024 * 1024;
|
|
@@ -91,15 +95,15 @@ export class OpenAiIngress {
|
|
|
91
95
|
server;
|
|
92
96
|
agents = new Map();
|
|
93
97
|
sockets = new Set();
|
|
94
|
-
|
|
98
|
+
claudeAccounts;
|
|
95
99
|
deps;
|
|
96
100
|
draining = false;
|
|
97
101
|
constructor(deps) {
|
|
98
102
|
this.deps = deps;
|
|
99
|
-
this.
|
|
103
|
+
this.claudeAccounts = deps.claudeAccounts ?? new ClaudeAccountAuthPool({
|
|
104
|
+
home: deps.home ?? (process.env.CLAUDERIPPLE_HOME?.trim() || path.join(os.homedir(), ".clauderipple")),
|
|
100
105
|
...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
|
|
101
|
-
|
|
102
|
-
log: (line) => deps.log.info(line),
|
|
106
|
+
log: deps.log,
|
|
103
107
|
});
|
|
104
108
|
this.server = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => void this.handle(req, res));
|
|
105
109
|
this.server.keepAliveTimeout = 65_000;
|
|
@@ -244,12 +248,16 @@ export class OpenAiIngress {
|
|
|
244
248
|
finish(400, bytes);
|
|
245
249
|
return;
|
|
246
250
|
}
|
|
247
|
-
const
|
|
248
|
-
if (!
|
|
251
|
+
const configured = cfg.providers[route.provider];
|
|
252
|
+
if (!configured) {
|
|
249
253
|
const bytes = sendJson(res, 500, openAiError(`Configured provider ${route.provider} is missing`, "server_error"));
|
|
250
254
|
finish(500, bytes);
|
|
251
255
|
return;
|
|
252
256
|
}
|
|
257
|
+
// A model that speaks Anthropic Messages is servable here even when the rest of its provider
|
|
258
|
+
// is an OpenAI wire, which the refusal below would otherwise turn away on the provider's type
|
|
259
|
+
// alone. One subscription is one provider, so the model decides.
|
|
260
|
+
const provider = providerFor(configured, route.model);
|
|
253
261
|
if (provider.type === "chatgpt" || provider.type === "openai-compatible") {
|
|
254
262
|
const bytes = sendJson(res, 400, openAiError(`${provider.type === "chatgpt" ? "ChatGPT" : "OpenAI-compatible"} provider is not available through OpenAI ingress`, "invalid_request_error", "unsupported_provider"));
|
|
255
263
|
finish(400, bytes, { note: `model ${requested} -> ${provider.type} provider unsupported` });
|
|
@@ -291,16 +299,18 @@ export class OpenAiIngress {
|
|
|
291
299
|
}
|
|
292
300
|
async forward(res, provider, wire, model, chat, stream, onTerminal) {
|
|
293
301
|
const native = provider.type === "anthropic";
|
|
294
|
-
const upstream = new URL(native ? "https://api.anthropic.com" : provider.url);
|
|
302
|
+
const upstream = new URL(native ? (this.deps.nativeUpstream ?? "https://api.anthropic.com") : provider.url);
|
|
295
303
|
const protocol = upstream.protocol === "https:" ? "https:" : "http:";
|
|
296
304
|
const body = Buffer.from(JSON.stringify(wire));
|
|
297
305
|
let authentication;
|
|
298
306
|
if (native && provider.auth === "claude-code") {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
307
|
+
// Codex ingress deliberately uses one credential per request and does not fail over. The shared
|
|
308
|
+
// projection preserves source precedence and keeps ClaudeRipple's added accounts as the fallback
|
|
309
|
+
// that the old single OAuth file provided before multi-account storage.
|
|
310
|
+
const credential = (await this.claudeAccounts.credentials())[0];
|
|
311
|
+
if (!credential)
|
|
312
|
+
return { status: 401, bytes: sendJson(res, 401, openAiError("Claude login unavailable — connect a Claude subscription", "authentication_error")), note: "Claude OAuth unavailable" };
|
|
313
|
+
authentication = credential.headers;
|
|
304
314
|
}
|
|
305
315
|
else if (native) {
|
|
306
316
|
try {
|
|
@@ -315,7 +325,7 @@ export class OpenAiIngress {
|
|
|
315
325
|
const headers = { "content-type": "application/json", accept: stream ? "text/event-stream" : "application/json", "content-length": String(body.length), ...authentication };
|
|
316
326
|
const lib = protocol === "https:" ? https : http;
|
|
317
327
|
const response = await new Promise((resolveP, reject) => {
|
|
318
|
-
const request = lib.request({ protocol, hostname: upstream.hostname, port: Number(upstream.port) || (protocol === "https:" ? 443 : 80), method: "POST", path: `${upstream.pathname.replace(/\/+$/, "")}/v1/messages`, headers, agent: this.agentFor(
|
|
328
|
+
const request = lib.request({ protocol, hostname: upstream.hostname, port: Number(upstream.port) || (protocol === "https:" ? 443 : 80), method: "POST", path: `${upstream.pathname.replace(/\/+$/, "")}/v1/messages`, headers, agent: this.agentFor(upstream.origin, protocol), ...(protocol === "https:" ? { servername: upstream.hostname } : {}) }, resolveP);
|
|
319
329
|
request.once("error", reject);
|
|
320
330
|
res.once("close", () => request.destroy());
|
|
321
331
|
request.end(body);
|
|
@@ -48,6 +48,11 @@ export function injectPickerModels(json, extra, contextWindow) {
|
|
|
48
48
|
if (existing.has(e.model))
|
|
49
49
|
continue;
|
|
50
50
|
const entry = { ...structuredClone(template), id: e.model, name: e.name };
|
|
51
|
+
const w = e.contextWindow ?? contextWindow;
|
|
52
|
+
if (w)
|
|
53
|
+
entry.context_window = w;
|
|
54
|
+
else
|
|
55
|
+
delete entry.context_window;
|
|
51
56
|
if (e.description)
|
|
52
57
|
entry.description = e.description;
|
|
53
58
|
else
|
|
@@ -58,12 +63,15 @@ export function injectPickerModels(json, extra, contextWindow) {
|
|
|
58
63
|
surface.models.push(entry);
|
|
59
64
|
result.injected++;
|
|
60
65
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Per-entry window first: routed models do not share one, and `contextWindow` is only the fallback.
|
|
67
|
+
for (const key of ["context_window_by_model", "contextWindowByModel"]) {
|
|
68
|
+
const map = surface[key];
|
|
69
|
+
if (!map || typeof map !== "object")
|
|
70
|
+
continue;
|
|
71
|
+
for (const e of extra) {
|
|
72
|
+
const w = e.contextWindow ?? contextWindow;
|
|
73
|
+
if (w)
|
|
74
|
+
map[e.model] = w;
|
|
67
75
|
}
|
|
68
76
|
}
|
|
69
77
|
const recorded = result.surfaces[result.surfaces.length - 1];
|