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.
Files changed (43) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.ko.md +48 -4
  3. package/README.md +58 -4
  4. package/dist/cli/src/claude-auth.js +3 -2
  5. package/dist/cli/src/codex.js +20 -1
  6. package/dist/cli/src/hooks/agent-title.js +1 -1
  7. package/dist/cli/src/index.js +4 -4
  8. package/dist/cli/src/schtasks.js +43 -1
  9. package/dist/cli/src/settings.js +73 -6
  10. package/dist/router/src/admin.js +489 -56
  11. package/dist/router/src/agents.js +250 -0
  12. package/dist/router/src/bootstrap.js +24 -8
  13. package/dist/router/src/capabilities.js +214 -0
  14. package/dist/router/src/compat.js +5 -1
  15. package/dist/router/src/config.js +264 -11
  16. package/dist/router/src/index.js +14 -1
  17. package/dist/router/src/ingress/server.js +24 -14
  18. package/dist/router/src/picker.js +14 -6
  19. package/dist/router/src/pool.js +233 -0
  20. package/dist/router/src/presets.js +156 -1
  21. package/dist/router/src/providers/anthropic-account-pool.js +139 -0
  22. package/dist/router/src/providers/anthropic-accounts.js +281 -0
  23. package/dist/router/src/providers/chatgpt/catalog.js +97 -0
  24. package/dist/router/src/providers/chatgpt/index.js +343 -12
  25. package/dist/router/src/providers/chatgpt/sse.js +4 -0
  26. package/dist/router/src/providers/chatgpt/translate.js +156 -14
  27. package/dist/router/src/providers/claude-oauth.js +61 -19
  28. package/dist/router/src/providers/openai/index.js +55 -11
  29. package/dist/router/src/providers/openai/translate.js +82 -14
  30. package/dist/router/src/providers/retry.js +88 -0
  31. package/dist/router/src/proxy.js +697 -82
  32. package/dist/router/src/requestlog.js +5 -2
  33. package/dist/router/src/routing.js +151 -17
  34. package/dist/router/src/version.js +1 -1
  35. package/dist/router/src/websearch.js +307 -0
  36. package/dist/router/src/x509.js +7 -2
  37. package/dist/ui/app.js +740 -160
  38. package/dist/ui/i18n.js +14 -6
  39. package/dist/ui/index.html +18 -5
  40. package/dist/ui/presets-fallback.js +2 -0
  41. package/dist/ui/style.css +133 -9
  42. package/docs/ARCHITECTURE.md +381 -20
  43. 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) => model !== null && typeof model === "object" &&
49
- typeof model.id === "string" &&
50
- (model.name === undefined || typeof model.name === "string") &&
51
- (model.effortLevels === undefined ||
52
- (Array.isArray(model.effortLevels) && model.effortLevels.every((level) => typeof level === "string"))));
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}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
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}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
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}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
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}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
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
- const next = merge(DEFAULTS, parsed);
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;
@@ -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, ClaudeCodeAuthStore, fromClaudeCodeToolName, nativeAnthropicHeaders, observedAnthropicHeaders, toClaudeCodeToolName } from "../providers/anthropic.js";
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
- claudeCodeAuth;
98
+ claudeAccounts;
95
99
  deps;
96
100
  draining = false;
97
101
  constructor(deps) {
98
102
  this.deps = deps;
99
- this.claudeCodeAuth = new ClaudeCodeAuthStore(undefined, {
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
- ...(deps.home ? { home: deps.home } : {}),
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 provider = cfg.providers[route.provider];
248
- if (!provider) {
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
- await this.claudeCodeAuth.refreshIfNeeded();
300
- const auth = this.claudeCodeAuth.get();
301
- if (auth instanceof Error)
302
- return { status: 401, bytes: sendJson(res, 401, openAiError(auth.message, "authentication_error")), note: "Claude Code OAuth unavailable" };
303
- authentication = auth.source === "observed" ? observedAnthropicHeaders(auth.observed) : nativeAnthropicHeaders(provider, auth.credentials);
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(native ? "https://api.anthropic.com" : provider.url, protocol), ...(protocol === "https:" ? { servername: upstream.hostname } : {}) }, resolveP);
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
- if (contextWindow) {
62
- for (const key of ["context_window_by_model", "contextWindowByModel"]) {
63
- const map = surface[key];
64
- if (map && typeof map === "object")
65
- for (const e of extra)
66
- map[e.model] = contextWindow;
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];