clauderipple 0.2.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 (71) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +674 -0
  3. package/README.ko.md +328 -0
  4. package/README.md +372 -0
  5. package/bin/clauderipple.js +12 -0
  6. package/dist/app/assets/trayDownTemplate.png +0 -0
  7. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  8. package/dist/app/assets/trayTemplate.png +0 -0
  9. package/dist/app/assets/trayTemplate@2x.png +0 -0
  10. package/dist/app/assets/trayWarnTemplate.png +0 -0
  11. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  12. package/dist/app/assets/trayWin.png +0 -0
  13. package/dist/app/assets/trayWin@2x.png +0 -0
  14. package/dist/app/assets/trayWinDown.png +0 -0
  15. package/dist/app/assets/trayWinDown@2x.png +0 -0
  16. package/dist/app/assets/trayWinWarn.png +0 -0
  17. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  18. package/dist/app/dist/main.js +518 -0
  19. package/dist/cli/src/browser.js +21 -0
  20. package/dist/cli/src/bundle.js +51 -0
  21. package/dist/cli/src/certs.js +33 -0
  22. package/dist/cli/src/claude-auth.js +112 -0
  23. package/dist/cli/src/codex.js +172 -0
  24. package/dist/cli/src/gen-certs.js +7 -0
  25. package/dist/cli/src/hooks/agent-title.js +160 -0
  26. package/dist/cli/src/index.js +489 -0
  27. package/dist/cli/src/launchd.js +183 -0
  28. package/dist/cli/src/picker.js +166 -0
  29. package/dist/cli/src/probe.js +55 -0
  30. package/dist/cli/src/runtime.js +62 -0
  31. package/dist/cli/src/schtasks.js +134 -0
  32. package/dist/cli/src/settings.js +142 -0
  33. package/dist/cli/src/supervisor.js +100 -0
  34. package/dist/cli/src/tray.js +85 -0
  35. package/dist/router/src/admin.js +945 -0
  36. package/dist/router/src/bootstrap.js +80 -0
  37. package/dist/router/src/certs.js +65 -0
  38. package/dist/router/src/compat.js +172 -0
  39. package/dist/router/src/config.js +179 -0
  40. package/dist/router/src/health.js +45 -0
  41. package/dist/router/src/identity.js +51 -0
  42. package/dist/router/src/index.js +144 -0
  43. package/dist/router/src/ingress/models.js +29 -0
  44. package/dist/router/src/ingress/server.js +400 -0
  45. package/dist/router/src/ingress/translate.js +457 -0
  46. package/dist/router/src/log.js +81 -0
  47. package/dist/router/src/picker.js +74 -0
  48. package/dist/router/src/presets.js +267 -0
  49. package/dist/router/src/providers/anthropic-observed.js +88 -0
  50. package/dist/router/src/providers/anthropic-token-file.js +48 -0
  51. package/dist/router/src/providers/anthropic.js +203 -0
  52. package/dist/router/src/providers/chatgpt/auth.js +226 -0
  53. package/dist/router/src/providers/chatgpt/index.js +274 -0
  54. package/dist/router/src/providers/chatgpt/sse.js +28 -0
  55. package/dist/router/src/providers/chatgpt/translate.js +393 -0
  56. package/dist/router/src/providers/claude-oauth.js +252 -0
  57. package/dist/router/src/providers/openai/index.js +193 -0
  58. package/dist/router/src/providers/openai/translate.js +504 -0
  59. package/dist/router/src/proxy.js +724 -0
  60. package/dist/router/src/redact.js +43 -0
  61. package/dist/router/src/requestlog.js +346 -0
  62. package/dist/router/src/routing.js +113 -0
  63. package/dist/router/src/version.js +8 -0
  64. package/dist/router/src/x509.js +203 -0
  65. package/dist/ui/app.js +1228 -0
  66. package/dist/ui/i18n.js +95 -0
  67. package/dist/ui/index.html +104 -0
  68. package/dist/ui/presets-fallback.js +61 -0
  69. package/dist/ui/style.css +347 -0
  70. package/docs/ARCHITECTURE.md +441 -0
  71. package/package.json +66 -0
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // ClaudeRipple router entry point.
3
+ // CLAUDERIPPLE_HOME config + certs + logs directory (default ~/.clauderipple)
4
+ // CLAUDERIPPLE_ECHO=1 also print log lines to stdout
5
+ //
6
+ // Exit codes: 0 clean, 2 bad config/certs, 75 upstream unreachable (supervisor should restart).
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { codexEnabled, writeCodexCatalog } from "../../cli/src/codex.js";
10
+ import { ingressModels } from "./ingress/models.js";
11
+ import { ConfigStore, configPath, homeDir, terminateHosts } from "./config.js";
12
+ import { CertStore } from "./certs.js";
13
+ import { Logger } from "./log.js";
14
+ import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
15
+ import { Proxy } from "./proxy.js";
16
+ import { startAdmin } from "./admin.js";
17
+ import { RequestLog } from "./requestlog.js";
18
+ import { OpenAiIngress } from "./ingress/server.js";
19
+ import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
20
+ import { VERSION } from "./version.js";
21
+ // Startup timing. Claude Desktop routes *all* its traffic through us in picker mode, so every
22
+ // second before the socket is open is a second the app cannot reach anything (ERR_PROXY_-
23
+ // CONNECTION_FAILED, observed 2026-09-14 after a reboot: 26s between exec and listening).
24
+ // `timeOrigin` is process start, so `node` covers interpreter boot + module loading.
25
+ const T_MODULE = Date.now();
26
+ const T_EXEC = Math.round(performance.timeOrigin);
27
+ const since = (t) => Date.now() - t;
28
+ const home = homeDir();
29
+ const logFile = process.env.CLAUDERIPPLE_NO_LOGFILE ? null : path.join(home, "logs", "router.log");
30
+ let log = null;
31
+ const store = new ConfigStore(undefined, (c, errors) => {
32
+ for (const e of errors)
33
+ (log ?? console).warn(`config: ${e}`);
34
+ log?.info(`config loaded: ${Object.keys(c.routes).length} routes, ${Object.keys(c.providers).length} providers, direct=${c.direct.map((d) => d.prefix).join(",") || "-"}`);
35
+ // Keep the Codex app's model list in step with the models we serve (only while `codex on` is in effect).
36
+ try {
37
+ if (codexEnabled())
38
+ writeCodexCatalog(ingressModels(c));
39
+ }
40
+ catch (e) {
41
+ log?.warn(`codex catalog: ${e.message}`);
42
+ }
43
+ });
44
+ const cfg0 = store.get();
45
+ log = new Logger(logFile, cfg0.log.maxBytes, cfg0.log.keep, !!process.env.CLAUDERIPPLE_ECHO || !logFile);
46
+ const T_CONFIG = Date.now();
47
+ const certs = new CertStore(home);
48
+ const requests = new RequestLog(path.join(home, "logs", "requests.jsonl"));
49
+ try {
50
+ certs.register(cfg0.upstream, fs.readFileSync(path.join(home, "leaf.pem")), fs.readFileSync(path.join(home, "leaf.key")));
51
+ }
52
+ catch (e) {
53
+ log.error(`cannot load leaf.pem/leaf.key from ${home}: ${e.message}. Run the installer first.`);
54
+ process.exit(2);
55
+ }
56
+ // Picker mode: mint leaves for the app's own hosts so the first CONNECT is not slowed down.
57
+ // Runs *after* listen(): minting generates an RSA key pair, and a client that has to wait a moment
58
+ // for its first CONNECT is far better off than one whose connection is refused outright. Lazy
59
+ // minting in the CONNECT path (proxy.ts) covers anything that arrives before this finishes.
60
+ function premintLeaves() {
61
+ for (const h of terminateHosts(store.get())) {
62
+ if (certs.has(h))
63
+ continue;
64
+ try {
65
+ certs.contextFor(h);
66
+ log.info(`picker: certificate ready for ${h}`);
67
+ }
68
+ catch (e) {
69
+ log.error(`picker: cannot mint certificate for ${h}: ${e.message}`);
70
+ }
71
+ }
72
+ }
73
+ const health = new UpstreamHealth(() => store.get().health.maxConsecutiveUpstreamFailures, (n) => {
74
+ log.error(`${n} consecutive upstream connect failures; exiting ${EXIT_UPSTREAM_UNREACHABLE} for the supervisor to restart`);
75
+ setTimeout(() => process.exit(EXIT_UPSTREAM_UNREACHABLE), 100);
76
+ });
77
+ const observedClaudeCodeAuth = new ObservedClaudeCodeAuth(path.join(home, "claude-auth-observed.json"));
78
+ const proxy = new Proxy({ config: () => store.get(), log, certs, health, home, requests, observedClaudeCodeAuth });
79
+ const ingress = new OpenAiIngress({ config: () => store.get(), log, requests, home, observedClaudeCodeAuth });
80
+ process.on("uncaughtException", (e) => log.error(`uncaught ${e.stack ?? e}`));
81
+ process.on("unhandledRejection", (e) => log.error(`unhandled ${e?.stack ?? e}`));
82
+ const DRAIN_MS = 90_000; // new model calls are refused during drain, so this is the longest single call we wait for; `clauderipple restart` waits 120s
83
+ let draining = false;
84
+ /**
85
+ * Stop accepting, let in-flight model calls finish, exit. Idempotent.
86
+ *
87
+ * Reachable two ways: a POSIX signal, and `POST /api/shutdown`. The second exists because Windows
88
+ * has no SIGTERM — `process.kill(pid, "SIGTERM")` terminates the target outright there, which
89
+ * would cut the drain — so a supervisor asks over the admin API instead and waits for the process
90
+ * to go. The same path serves both platforms rather than only being exercised on one.
91
+ */
92
+ function beginDrain(reason) {
93
+ if (draining)
94
+ return;
95
+ draining = true;
96
+ log.info(`${reason}: draining, proxy=${proxy.stats.messagesInFlight} ingress=${ingress.stats.messagesInFlight} model calls`);
97
+ let lastReported = -1;
98
+ void Promise.all([
99
+ proxy.drain(DRAIN_MS, () => { }),
100
+ ingress.drain(DRAIN_MS, () => { }),
101
+ ])
102
+ .then(() => {
103
+ const left = proxy.stats.messagesInFlight + ingress.stats.messagesInFlight;
104
+ if (left !== lastReported)
105
+ log.info(`drain complete, exiting (model calls still open: ${left})`);
106
+ setTimeout(() => process.exit(0), 200).unref();
107
+ });
108
+ }
109
+ for (const sig of ["SIGINT", "SIGTERM"])
110
+ process.on(sig, () => beginDrain(sig));
111
+ const statsEvery = 5 * 60 * 1000;
112
+ setInterval(() => {
113
+ const s = proxy.stats;
114
+ const oi = ingress.stats;
115
+ log.info(`stats proxy=${s.started}/${s.completed}/${s.failed} inFlight=${s.inFlight} ingress=${oi.started}/${oi.completed}/${oi.failed} inFlight=${oi.inFlight} consecutiveUpstreamFailures=${health.consecutiveFailures}`);
116
+ }, statsEvery).unref();
117
+ proxy
118
+ .listen()
119
+ .then(async () => {
120
+ const c = store.get();
121
+ log.info(`clauderipple router listening on ${c.listen.host}:${c.listen.port} upstream=${c.upstream} home=${home}` +
122
+ ` (startup ${since(T_EXEC)}ms: node ${T_MODULE - T_EXEC}, config ${T_CONFIG - T_MODULE}, listen ${since(T_CONFIG)})`);
123
+ const ingressPort = await ingress.listen();
124
+ log.info(`clauderipple OpenAI ingress listening on 127.0.0.1:${ingressPort}`);
125
+ const admin = await startAdmin({
126
+ config: () => store.get(),
127
+ configFile: configPath(),
128
+ log: log,
129
+ stats: () => proxy.stats,
130
+ health: () => health.consecutiveFailures,
131
+ version: VERSION,
132
+ requests,
133
+ chatgpt: () => ({ quota: proxy.chatgptRateLimits, auth: proxy.chatgptAuthStatus() }),
134
+ picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
135
+ observedClaudeCodeAuth,
136
+ shutdown: () => beginDrain("shutdown requested"),
137
+ });
138
+ log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
139
+ setImmediate(premintLeaves);
140
+ })
141
+ .catch((e) => {
142
+ log.error(`listen failed: ${e.message}`);
143
+ process.exit(2);
144
+ });
@@ -0,0 +1,29 @@
1
+ // Models the OpenAI ingress can serve to Codex: Claude (native `anthropic` provider) and any
2
+ // Anthropic-compatible provider's models, plus the routes that point at them. Shared by
3
+ // `GET /v1/models` and by the Codex model catalog that puts these names in the Codex app picker.
4
+ import { effortLevels } from "../admin.js";
5
+ import { claudeSupportsEffort } from "./translate.js";
6
+ export function ingressModels(cfg) {
7
+ const levels = effortLevels(cfg).providers;
8
+ const out = new Map();
9
+ const add = (id, provider, name, explicit) => {
10
+ if (out.has(id))
11
+ return;
12
+ const p = cfg.providers[provider];
13
+ if (!p || (p.type !== "anthropic" && p.type !== "anthropic-compatible"))
14
+ return;
15
+ let supported = explicit ?? levels[provider]?.models?.[id] ?? levels[provider]?.default ?? [];
16
+ if (p.type === "anthropic" && !claudeSupportsEffort(id))
17
+ supported = [];
18
+ out.set(id, { id, provider, ...(name ? { name } : {}), effortLevels: supported });
19
+ };
20
+ for (const [name, provider] of Object.entries(cfg.providers)) {
21
+ for (const model of provider.models ?? [])
22
+ add(model.id, name, model.name, model.effortLevels);
23
+ }
24
+ for (const [id, route] of Object.entries(cfg.routes)) {
25
+ const target = cfg.providers[route.provider]?.models?.find((m) => m.id === route.model);
26
+ add(id, route.provider, target?.name, target?.effortLevels);
27
+ }
28
+ return [...out.values()];
29
+ }
@@ -0,0 +1,400 @@
1
+ // Local OpenAI-compatible ingress for Codex CLI and other OpenAI clients.
2
+ // Binds loopback only. Client Authorization is accepted only as a local-presence check and is
3
+ // intentionally never copied to the Anthropic-compatible upstream.
4
+ import http from "node:http";
5
+ import https from "node:https";
6
+ import { resolve } from "../routing.js";
7
+ import { PRESETS } from "../presets.js";
8
+ import { resolveCompatibleCaps, sanitizeForCompatible } from "../compat.js";
9
+ import { SseParser } from "../providers/chatgpt/sse.js";
10
+ import { ingressModels } from "./models.js";
11
+ import { requestId } from "../requestlog.js";
12
+ import { credentialHeaderValues, redactErrorText } from "../redact.js";
13
+ import { CLAUDE_CODE_IDENTITY, ClaudeCodeAuthStore, fromClaudeCodeToolName, nativeAnthropicHeaders, observedAnthropicHeaders, toClaudeCodeToolName } from "../providers/anthropic.js";
14
+ import { ObservedClaudeCodeAuth } from "../providers/anthropic-observed.js";
15
+ import { ResponsesEventMapper, chatToAnthropic, formatDataSse, formatSse, responsesEventsToChatChunks, responsesToAnthropic, responsesToChatCompletion, } from "./translate.js";
16
+ const MAX_BODY = 64 * 1024 * 1024;
17
+ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "host", "content-length", "authorization"]);
18
+ function openAiError(message, type = "invalid_request_error", code = null) {
19
+ return { error: { message, type, param: null, code } };
20
+ }
21
+ function sendJson(res, status, body) {
22
+ const text = JSON.stringify(body);
23
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "content-length": String(Buffer.byteLength(text)) });
24
+ res.end(text);
25
+ return Buffer.byteLength(text);
26
+ }
27
+ function readBody(req) {
28
+ return new Promise((resolveP, reject) => {
29
+ const chunks = [];
30
+ let total = 0;
31
+ req.on("data", (chunk) => {
32
+ total += chunk.length;
33
+ if (total > MAX_BODY) {
34
+ req.destroy();
35
+ reject(new Error("request body too large"));
36
+ return;
37
+ }
38
+ chunks.push(chunk);
39
+ });
40
+ req.on("end", () => resolveP(Buffer.concat(chunks)));
41
+ req.on("error", reject);
42
+ });
43
+ }
44
+ function contentType(headers) {
45
+ const value = headers.get("content-type");
46
+ return value ?? undefined;
47
+ }
48
+ function httpStatusError(status, body) {
49
+ let message = body.replace(/\s+/g, " ").slice(0, 500) || `Anthropic-compatible provider returned HTTP ${status}`;
50
+ try {
51
+ const parsed = JSON.parse(body);
52
+ if (typeof parsed.error?.message === "string")
53
+ message = parsed.error.message;
54
+ else if (typeof parsed.message === "string")
55
+ message = parsed.message;
56
+ }
57
+ catch {
58
+ // Preserve the compact text fallback.
59
+ }
60
+ const mapped = status === 401 || status === 403 ? 401 : status === 429 ? 429 : status >= 500 ? 502 : 400;
61
+ const type = mapped === 401 ? "authentication_error" : mapped === 429 ? "rate_limit_error" : mapped === 502 ? "api_error" : "invalid_request_error";
62
+ return { status: mapped, error: openAiError(message, type) };
63
+ }
64
+ function usage(mapper) {
65
+ return { input: mapper.usage.input_tokens - mapper.usage.input_tokens_details.cached_tokens, cached: mapper.usage.input_tokens_details.cached_tokens, output: mapper.usage.output_tokens };
66
+ }
67
+ function anthropicJsonEvents(value) {
68
+ const out = [{ type: "message_start", message: { usage: value.usage ?? {} } }];
69
+ const content = Array.isArray(value.content) ? value.content : [];
70
+ for (let index = 0; index < content.length; index++) {
71
+ const raw = content[index];
72
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
73
+ continue;
74
+ const block = raw;
75
+ out.push({ type: "content_block_start", index, content_block: block });
76
+ if (block.type === "text" && typeof block.text === "string")
77
+ out.push({ type: "content_block_delta", index, delta: { type: "text_delta", text: block.text } });
78
+ if (block.type === "tool_use")
79
+ out.push({ type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: JSON.stringify(block.input ?? {}) } });
80
+ out.push({ type: "content_block_stop", index });
81
+ }
82
+ out.push({ type: "message_delta", delta: { stop_reason: value.stop_reason ?? "end_turn" }, usage: value.usage ?? {} });
83
+ out.push({ type: "message_stop" });
84
+ return out;
85
+ }
86
+ export function openaiPort(cfg) {
87
+ return cfg.listen.openaiPort ?? cfg.listen.port + 2;
88
+ }
89
+ export class OpenAiIngress {
90
+ stats = { inFlight: 0, messagesInFlight: 0, started: 0, completed: 0, failed: 0 };
91
+ server;
92
+ agents = new Map();
93
+ sockets = new Set();
94
+ claudeCodeAuth;
95
+ deps;
96
+ draining = false;
97
+ constructor(deps) {
98
+ this.deps = deps;
99
+ this.claudeCodeAuth = new ClaudeCodeAuthStore(undefined, {
100
+ ...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}),
101
+ ...(deps.home ? { home: deps.home } : {}),
102
+ log: (line) => deps.log.info(line),
103
+ });
104
+ this.server = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => void this.handle(req, res));
105
+ this.server.keepAliveTimeout = 65_000;
106
+ this.server.on("connection", (socket) => {
107
+ this.sockets.add(socket);
108
+ socket.once("close", () => this.sockets.delete(socket));
109
+ });
110
+ this.server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\nconnection: close\r\n\r\n"));
111
+ }
112
+ listen() {
113
+ const cfg = this.deps.config();
114
+ const port = openaiPort(cfg);
115
+ return new Promise((resolveP, reject) => {
116
+ this.server.once("error", reject);
117
+ this.server.listen(port, "127.0.0.1", () => {
118
+ this.server.off("error", reject);
119
+ const address = this.server.address();
120
+ resolveP(address && typeof address === "object" ? address.port : port);
121
+ });
122
+ });
123
+ }
124
+ /** Stop new connections, reject new model work on keep-alive sockets, then close after model calls drain. */
125
+ async drain(maxMs, onProgress) {
126
+ this.draining = true;
127
+ this.server.close();
128
+ const start = Date.now();
129
+ while (this.stats.messagesInFlight > 0 && Date.now() - start < maxMs) {
130
+ onProgress?.(this.stats.messagesInFlight);
131
+ await new Promise((resolveP) => setTimeout(resolveP, 250));
132
+ }
133
+ for (const socket of this.sockets)
134
+ socket.destroy();
135
+ }
136
+ agentFor(key, protocol) {
137
+ const existing = this.agents.get(key);
138
+ if (existing)
139
+ return existing;
140
+ const agent = protocol === "https:" ? new https.Agent({ keepAlive: true, maxSockets: 64 }) : new http.Agent({ keepAlive: true, maxSockets: 64 });
141
+ this.agents.set(key, agent);
142
+ return agent;
143
+ }
144
+ async handle(req, res) {
145
+ const path = (req.url ?? "/").split("?")[0] ?? "/";
146
+ const method = req.method ?? "GET";
147
+ const isCall = method === "POST" && (path === "/v1/responses" || path === "/v1/chat/completions");
148
+ this.stats.started++;
149
+ this.stats.inFlight++;
150
+ if (isCall)
151
+ this.stats.messagesInFlight++;
152
+ const started = Date.now();
153
+ let record = { kind: "messages", source: "-", target: "-", provider: "-", stream: false };
154
+ let completed = false;
155
+ const finish = (status, bytes, extra = {}) => {
156
+ if (completed)
157
+ return;
158
+ completed = true;
159
+ this.stats.inFlight--;
160
+ if (isCall)
161
+ this.stats.messagesInFlight--;
162
+ if (status >= 200 && status < 400)
163
+ this.stats.completed++;
164
+ else
165
+ this.stats.failed++;
166
+ const ms = Date.now() - started;
167
+ const tagged = `${method} ${path} -> ${status} ${bytes}B ${(ms / 1000).toFixed(1)}s${extra.note ? ` ${extra.note}` : ""}`;
168
+ this.deps.log.info(`OPENAI ${tagged}`);
169
+ if (isCall) {
170
+ const entry = { ...record, id: requestId(), at: new Date(started).toISOString(), ms, status, ok: status >= 200 && status < 400 };
171
+ if (extra.usage)
172
+ entry.usage = extra.usage;
173
+ if (extra.stopReason)
174
+ entry.stopReason = extra.stopReason;
175
+ if (extra.note)
176
+ entry.note = extra.note;
177
+ this.deps.requests.add(entry);
178
+ }
179
+ };
180
+ // Codex closes the socket the moment it reads `response.completed`, often before Anthropic's
181
+ // stream has ended on our side; that is a finished request, not an abort.
182
+ let terminal = null;
183
+ res.once("close", () => {
184
+ if (completed || res.writableEnded)
185
+ return;
186
+ if (terminal)
187
+ finish(terminal.status, terminal.bytes, { ...(terminal.usage ? { usage: terminal.usage } : {}), ...(terminal.stopReason ? { stopReason: terminal.stopReason } : {}), note: `${terminal.note ?? ""}; client closed after completion`.replace(/^; /, "") });
188
+ else
189
+ finish(499, 0, { note: "client closed" });
190
+ });
191
+ try {
192
+ if (path === "/v1/models" && method === "GET") {
193
+ const data = ingressModels(this.deps.config()).map((m) => ({ id: m.id, object: "model", created: 0, owned_by: m.provider }));
194
+ const bytes = sendJson(res, 200, { object: "list", data });
195
+ finish(200, bytes);
196
+ return;
197
+ }
198
+ if (!isCall) {
199
+ const bytes = sendJson(res, 404, openAiError("not found", "invalid_request_error"));
200
+ finish(404, bytes);
201
+ return;
202
+ }
203
+ if (this.draining) {
204
+ req.resume();
205
+ const bytes = sendJson(res, 503, openAiError("ClaudeRipple is restarting; retry", "server_error"));
206
+ finish(503, bytes, { note: "refused during drain" });
207
+ return;
208
+ }
209
+ // Loopback-only listener: a client may omit Authorization (the Codex desktop app has no shell
210
+ // env to carry a placeholder key). When present it must at least look like a bearer token.
211
+ const authorization = req.headers.authorization;
212
+ if (authorization !== undefined && (typeof authorization !== "string" || !/^Bearer\s+\S+$/i.test(authorization))) {
213
+ const bytes = sendJson(res, 401, openAiError("Malformed Authorization header; use Bearer <token> or omit it", "authentication_error"));
214
+ finish(401, bytes);
215
+ return;
216
+ }
217
+ let body;
218
+ try {
219
+ body = JSON.parse((await readBody(req)).toString("utf8"));
220
+ }
221
+ catch (error) {
222
+ const bytes = sendJson(res, 400, openAiError(`Invalid JSON request: ${error.message}`));
223
+ finish(400, bytes);
224
+ return;
225
+ }
226
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
227
+ const bytes = sendJson(res, 400, openAiError("Request body must be a JSON object"));
228
+ finish(400, bytes);
229
+ return;
230
+ }
231
+ if (path === "/v1/responses" && body.previous_response_id != null) {
232
+ const bytes = sendJson(res, 400, openAiError("previous_response_id unsupported; ClaudeRipple OpenAI ingress is stateless", "invalid_request_error", "previous_response_id_unsupported"));
233
+ finish(400, bytes);
234
+ return;
235
+ }
236
+ const requested = typeof body.model === "string" ? body.model : "";
237
+ const cfg = this.deps.config();
238
+ // Unmapped models default to the native `anthropic` provider when one is configured, so a
239
+ // Codex user can name any Claude model directly (`-m claude-sonnet-5`) without a mapping.
240
+ const fallback = Object.entries(cfg.providers).find(([, p]) => p.type === "anthropic");
241
+ const route = resolve(requested, {}, cfg) ?? (fallback && requested ? { provider: fallback[0], model: requested, effort: undefined, tag: `${requested}->${requested}` } : null);
242
+ if (!route) {
243
+ const bytes = sendJson(res, 400, openAiError(`No ClaudeRipple route for model ${requested || "(missing)"}`, "invalid_request_error", "model_not_found"));
244
+ finish(400, bytes);
245
+ return;
246
+ }
247
+ const provider = cfg.providers[route.provider];
248
+ if (!provider) {
249
+ const bytes = sendJson(res, 500, openAiError(`Configured provider ${route.provider} is missing`, "server_error"));
250
+ finish(500, bytes);
251
+ return;
252
+ }
253
+ if (provider.type === "chatgpt" || provider.type === "openai-compatible") {
254
+ 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
+ finish(400, bytes, { note: `model ${requested} -> ${provider.type} provider unsupported` });
256
+ return;
257
+ }
258
+ const native = provider.type === "anthropic";
259
+ const prefix = native && provider.auth === "claude-code" ? CLAUDE_CODE_IDENTITY : undefined;
260
+ const anthropic = path === "/v1/responses" ? responsesToAnthropic(body, route.model, prefix) : chatToAnthropic(body, route.model, prefix);
261
+ const wire = native ? anthropic : sanitizeForCompatible(anthropic, resolveCompatibleCaps(provider.preset ? (() => { const preset = PRESETS.find((entry) => entry.id === provider.preset); return preset ? { effortLevels: preset.effortLevels, thinking: preset.thinking } : undefined; })() : undefined, provider.caps)).json;
262
+ if (native && provider.auth === "claude-code" && Array.isArray(wire.tools)) {
263
+ wire.tools = wire.tools.map((raw) => {
264
+ const tool = raw;
265
+ return typeof tool.name === "string" ? { ...tool, name: toClaudeCodeToolName(tool.name) } : tool;
266
+ });
267
+ const choice = wire.tool_choice;
268
+ if (choice?.type === "tool" && typeof choice.name === "string")
269
+ wire.tool_choice = { ...choice, name: toClaudeCodeToolName(choice.name) };
270
+ }
271
+ const stream = body.stream === true;
272
+ wire.stream = stream;
273
+ record = { kind: "messages", source: requested, target: route.model, provider: route.provider, ...(route.effort ? { effort: route.effort } : {}), stream };
274
+ const kindNote = `openai-ingress ${path === "/v1/responses" ? "responses" : "chat"}`;
275
+ const outcome = await this.forward(res, provider, wire, route.model, path === "/v1/chat/completions", stream, (done) => { terminal = { ...done, note: kindNote }; });
276
+ finish(outcome.status, outcome.bytes, { ...(outcome.usage ? { usage: outcome.usage } : {}), ...(outcome.stopReason ? { stopReason: outcome.stopReason } : {}), note: `${kindNote}${outcome.note ? `; ${outcome.note}` : ""}` });
277
+ }
278
+ catch (error) {
279
+ if (completed)
280
+ return; // already recorded (e.g. client closed after the terminal event)
281
+ this.deps.log.warn(`OPENAI ingress error: ${error.message}`);
282
+ if (!res.headersSent) {
283
+ const bytes = sendJson(res, 502, openAiError(`Anthropic-compatible upstream unreachable: ${error.message}`, "api_error"));
284
+ finish(502, bytes);
285
+ }
286
+ else {
287
+ res.destroy();
288
+ finish(502, 0, { note: "response interrupted" });
289
+ }
290
+ }
291
+ }
292
+ async forward(res, provider, wire, model, chat, stream, onTerminal) {
293
+ const native = provider.type === "anthropic";
294
+ const upstream = new URL(native ? "https://api.anthropic.com" : provider.url);
295
+ const protocol = upstream.protocol === "https:" ? "https:" : "http:";
296
+ const body = Buffer.from(JSON.stringify(wire));
297
+ let authentication;
298
+ 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);
304
+ }
305
+ else if (native) {
306
+ try {
307
+ authentication = nativeAnthropicHeaders(provider);
308
+ }
309
+ catch (error) {
310
+ return { status: 401, bytes: sendJson(res, 401, openAiError(error.message, "authentication_error")), note: "Anthropic API key unavailable" };
311
+ }
312
+ }
313
+ else
314
+ authentication = provider.headers ?? {};
315
+ const headers = { "content-type": "application/json", accept: stream ? "text/event-stream" : "application/json", "content-length": String(body.length), ...authentication };
316
+ const lib = protocol === "https:" ? https : http;
317
+ 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);
319
+ request.once("error", reject);
320
+ res.once("close", () => request.destroy());
321
+ request.end(body);
322
+ });
323
+ if ((response.statusCode ?? 0) < 200 || (response.statusCode ?? 0) >= 300) {
324
+ const text = await new Promise((resolveP) => {
325
+ let output = "";
326
+ response.on("data", (chunk) => { output = (output + chunk.toString("utf8")).slice(0, 1024); });
327
+ response.on("end", () => resolveP(output));
328
+ response.on("error", () => resolveP(output));
329
+ });
330
+ const safeText = redactErrorText(text, credentialHeaderValues(Object.entries(headers)));
331
+ this.deps.log.warn(`OPENAI ingress upstream ${response.statusCode ?? 0} (${model}): ${safeText.slice(0, 400)}`);
332
+ const mapped = httpStatusError(response.statusCode ?? 502, safeText);
333
+ return { status: mapped.status, bytes: sendJson(res, mapped.status, mapped.error), note: `upstream ${response.statusCode ?? 0}` };
334
+ }
335
+ const mapper = new ResponsesEventMapper(model, native && provider.auth === "claude-code" ? fromClaudeCodeToolName : undefined);
336
+ let bytes = 0;
337
+ const writeResponses = (events) => {
338
+ for (const event of events)
339
+ bytes += Buffer.byteLength(formatSse(event)), res.write(formatSse(event));
340
+ };
341
+ const writeChat = (events) => {
342
+ for (const chunk of responsesEventsToChatChunks(events, mapper))
343
+ bytes += Buffer.byteLength(formatDataSse(chunk)), res.write(formatDataSse(chunk));
344
+ };
345
+ const write = chat ? writeChat : writeResponses;
346
+ if (stream) {
347
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
348
+ if (!chat)
349
+ write(mapper.start());
350
+ }
351
+ if (/^text\/event-stream\b/i.test(contentType(new Headers(response.headers)) ?? "")) {
352
+ const parser = new SseParser();
353
+ const decoder = new TextDecoder();
354
+ await new Promise((resolveP, reject) => {
355
+ response.on("data", (chunk) => {
356
+ for (const event of parser.feed(decoder.decode(chunk, { stream: true }))) {
357
+ const mapped = mapper.feed(event);
358
+ if (stream)
359
+ write(mapped);
360
+ if (stream && mapped.some((e) => e.type === "response.completed")) {
361
+ if (chat) {
362
+ bytes += Buffer.byteLength("data: [DONE]\n\n");
363
+ res.write("data: [DONE]\n\n");
364
+ }
365
+ onTerminal?.({ status: 200, bytes, usage: usage(mapper), stopReason: mapper.output.some((item) => item.type === "function_call") ? "tool_use" : "end_turn" });
366
+ }
367
+ }
368
+ });
369
+ response.on("end", resolveP);
370
+ response.on("error", reject);
371
+ });
372
+ }
373
+ else {
374
+ const raw = await new Promise((resolveP, reject) => {
375
+ const chunks = [];
376
+ response.on("data", (chunk) => chunks.push(chunk));
377
+ response.on("end", () => resolveP(Buffer.concat(chunks)));
378
+ response.on("error", reject);
379
+ });
380
+ const parsed = JSON.parse(raw.toString("utf8"));
381
+ const mapped = anthropicJsonEvents(parsed).flatMap((event) => mapper.feed(event));
382
+ if (stream)
383
+ write(mapped);
384
+ }
385
+ const tail = mapper.finish();
386
+ if (stream) {
387
+ write(tail);
388
+ if (chat && tail.length) {
389
+ bytes += Buffer.byteLength("data: [DONE]\n\n");
390
+ res.write("data: [DONE]\n\n");
391
+ }
392
+ res.end();
393
+ }
394
+ else {
395
+ const result = chat ? responsesToChatCompletion(mapper) : mapper.response("completed");
396
+ bytes = sendJson(res, 200, result);
397
+ }
398
+ return { status: 200, bytes, usage: usage(mapper), stopReason: mapper.output.some((item) => item.type === "function_call") ? "tool_use" : "end_turn" };
399
+ }
400
+ }