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.
- package/CHANGELOG.md +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ChatGPT subscription credentials for the Codex backend.
|
|
2
|
+
//
|
|
3
|
+
// Two sources:
|
|
4
|
+
// own <home>/chatgpt-auth.json written by `clauderipple login` (OAuth PKCE). We refresh it.
|
|
5
|
+
// borrow-codex ~/.codex/auth.json written by the Codex CLI. Read-only: refresh tokens rotate, and
|
|
6
|
+
// refreshing someone else's grant would break their login. If it expires, the user runs
|
|
7
|
+
// Codex once or logs in with us.
|
|
8
|
+
//
|
|
9
|
+
// OAuth constants are the public ones used by the Codex CLI (auth.openai.com, PKCE S256).
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
export const OAUTH = {
|
|
16
|
+
authorizeUrl: "https://auth.openai.com/oauth/authorize",
|
|
17
|
+
tokenUrl: "https://auth.openai.com/oauth/token",
|
|
18
|
+
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
19
|
+
redirectUri: "http://localhost:1455/auth/callback",
|
|
20
|
+
port: 1455,
|
|
21
|
+
scope: "openid profile email offline_access",
|
|
22
|
+
};
|
|
23
|
+
export function ownAuthPath(home) {
|
|
24
|
+
return path.join(home, "chatgpt-auth.json");
|
|
25
|
+
}
|
|
26
|
+
export function codexAuthPath() {
|
|
27
|
+
return process.env.CODEX_HOME ? path.join(process.env.CODEX_HOME, "auth.json") : path.join(os.homedir(), ".codex", "auth.json");
|
|
28
|
+
}
|
|
29
|
+
function b64url(buf) {
|
|
30
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
31
|
+
}
|
|
32
|
+
export function decodeJwt(token) {
|
|
33
|
+
const parts = token.split(".");
|
|
34
|
+
if (parts.length < 2)
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export function accountIdFromToken(token) {
|
|
44
|
+
const claims = decodeJwt(token);
|
|
45
|
+
const auth = claims?.["https://api.openai.com/auth"];
|
|
46
|
+
return auth?.chatgpt_account_id ?? null;
|
|
47
|
+
}
|
|
48
|
+
function expiryFromToken(token) {
|
|
49
|
+
const exp = decodeJwt(token)?.exp;
|
|
50
|
+
return typeof exp === "number" ? exp * 1000 : 0;
|
|
51
|
+
}
|
|
52
|
+
export function readOwn(home) {
|
|
53
|
+
try {
|
|
54
|
+
const j = JSON.parse(fs.readFileSync(ownAuthPath(home), "utf8"));
|
|
55
|
+
if (!j.accessToken || !j.accountId)
|
|
56
|
+
return null;
|
|
57
|
+
return { ...j, source: "own" };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function readBorrowed() {
|
|
64
|
+
try {
|
|
65
|
+
const j = JSON.parse(fs.readFileSync(codexAuthPath(), "utf8"));
|
|
66
|
+
const t = j.tokens;
|
|
67
|
+
if (!t?.access_token)
|
|
68
|
+
return null;
|
|
69
|
+
const accountId = t.account_id ?? accountIdFromToken(t.access_token);
|
|
70
|
+
if (!accountId)
|
|
71
|
+
return null;
|
|
72
|
+
return {
|
|
73
|
+
accessToken: t.access_token,
|
|
74
|
+
...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
|
|
75
|
+
...(t.id_token ? { idToken: t.id_token } : {}),
|
|
76
|
+
accountId,
|
|
77
|
+
expiresAt: expiryFromToken(t.access_token),
|
|
78
|
+
source: "borrow-codex",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function writeOwn(home, t) {
|
|
86
|
+
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
87
|
+
fs.writeFileSync(ownAuthPath(home), JSON.stringify(t, null, 2), { mode: 0o600 });
|
|
88
|
+
}
|
|
89
|
+
export async function refreshOwn(home, t) {
|
|
90
|
+
if (!t.refreshToken)
|
|
91
|
+
throw new Error("no refresh token; run `clauderipple login`");
|
|
92
|
+
const res = await fetch(OAUTH.tokenUrl, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
95
|
+
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: t.refreshToken, client_id: OAUTH.clientId }),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok)
|
|
98
|
+
throw new Error(`token refresh failed: HTTP ${res.status}`);
|
|
99
|
+
const j = (await res.json());
|
|
100
|
+
const next = {
|
|
101
|
+
accessToken: j.access_token,
|
|
102
|
+
refreshToken: j.refresh_token ?? t.refreshToken,
|
|
103
|
+
...(j.id_token ? { idToken: j.id_token } : {}),
|
|
104
|
+
accountId: accountIdFromToken(j.access_token) ?? t.accountId,
|
|
105
|
+
expiresAt: j.expires_in ? Date.now() + j.expires_in * 1000 : expiryFromToken(j.access_token),
|
|
106
|
+
source: "own",
|
|
107
|
+
};
|
|
108
|
+
writeOwn(home, next);
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
export class CredentialStore {
|
|
112
|
+
cached = null;
|
|
113
|
+
home;
|
|
114
|
+
mode;
|
|
115
|
+
constructor(home, mode = "auto") {
|
|
116
|
+
this.home = home;
|
|
117
|
+
this.mode = mode;
|
|
118
|
+
}
|
|
119
|
+
/** Valid tokens or an Error describing what the user must do. Never throws. */
|
|
120
|
+
async get() {
|
|
121
|
+
const skew = 5 * 60 * 1000;
|
|
122
|
+
let t = this.cached;
|
|
123
|
+
if (!t || Date.now() > t.expiresAt - skew) {
|
|
124
|
+
t = this.mode === "borrow-codex" ? readBorrowed() : (readOwn(this.home) ?? (this.mode === "own" ? null : readBorrowed()));
|
|
125
|
+
if (!t)
|
|
126
|
+
return new Error("no ChatGPT credentials: run `clauderipple login`, or sign in to the Codex CLI once");
|
|
127
|
+
if (Date.now() > t.expiresAt - skew) {
|
|
128
|
+
if (t.source === "own") {
|
|
129
|
+
try {
|
|
130
|
+
t = await refreshOwn(this.home, t);
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
return new Error(`ChatGPT login expired and refresh failed (${e.message}); run \`clauderipple login\``);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
return new Error("borrowed Codex CLI login has expired; run `codex` once to refresh it, or `clauderipple login` for a login of our own");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.cached = t;
|
|
141
|
+
}
|
|
142
|
+
return t;
|
|
143
|
+
}
|
|
144
|
+
invalidate() {
|
|
145
|
+
this.cached = null;
|
|
146
|
+
}
|
|
147
|
+
describe() {
|
|
148
|
+
const own = readOwn(this.home);
|
|
149
|
+
const bor = readBorrowed();
|
|
150
|
+
const fmt = (t) => (t ? `${t.source} (expires ${new Date(t.expiresAt).toISOString().slice(0, 16)}Z)` : "none");
|
|
151
|
+
return `own=${fmt(own)} borrow-codex=${fmt(bor)} mode=${this.mode}`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Interactive OAuth PKCE login. Opens the browser; the user signs in themselves. Resolves when tokens are stored. */
|
|
155
|
+
export async function login(home, openBrowser) {
|
|
156
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
157
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
158
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
159
|
+
const url = new URL(OAUTH.authorizeUrl);
|
|
160
|
+
url.search = new URLSearchParams({
|
|
161
|
+
response_type: "code",
|
|
162
|
+
client_id: OAUTH.clientId,
|
|
163
|
+
redirect_uri: OAUTH.redirectUri,
|
|
164
|
+
scope: OAUTH.scope,
|
|
165
|
+
code_challenge: challenge,
|
|
166
|
+
code_challenge_method: "S256",
|
|
167
|
+
state,
|
|
168
|
+
codex_cli_simplified_flow: "true",
|
|
169
|
+
originator: "codex_cli_rs",
|
|
170
|
+
}).toString();
|
|
171
|
+
const code = await new Promise((resolve, reject) => {
|
|
172
|
+
const server = http.createServer((req, res) => {
|
|
173
|
+
const u = new URL(req.url ?? "/", "http://localhost");
|
|
174
|
+
if (u.pathname !== "/auth/callback") {
|
|
175
|
+
res.writeHead(404).end();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const err = u.searchParams.get("error");
|
|
179
|
+
if (err || u.searchParams.get("state") !== state) {
|
|
180
|
+
res.writeHead(400, { "content-type": "text/plain" }).end(`Login failed: ${err ?? "state mismatch"}. You can close this tab.`);
|
|
181
|
+
server.close();
|
|
182
|
+
reject(new Error(err ?? "state mismatch"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
res.writeHead(200, { "content-type": "text/html" }).end("<p>ClaudeRipple is signed in. You can close this tab.</p>");
|
|
186
|
+
server.close();
|
|
187
|
+
resolve(u.searchParams.get("code") ?? "");
|
|
188
|
+
});
|
|
189
|
+
server.on("error", reject);
|
|
190
|
+
server.listen(OAUTH.port, "127.0.0.1", () => openBrowser(url.toString()));
|
|
191
|
+
setTimeout(() => {
|
|
192
|
+
server.close();
|
|
193
|
+
reject(new Error("login timed out after 5 minutes"));
|
|
194
|
+
}, 5 * 60 * 1000).unref();
|
|
195
|
+
});
|
|
196
|
+
const res = await fetch(OAUTH.tokenUrl, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
199
|
+
body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: OAUTH.redirectUri, client_id: OAUTH.clientId, code_verifier: verifier }),
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok)
|
|
202
|
+
throw new Error(`token exchange failed: HTTP ${res.status} ${await res.text()}`);
|
|
203
|
+
const j = (await res.json());
|
|
204
|
+
const accountId = accountIdFromToken(j.access_token) ?? (j.id_token ? accountIdFromToken(j.id_token) : null);
|
|
205
|
+
if (!accountId)
|
|
206
|
+
throw new Error("token has no chatgpt_account_id claim");
|
|
207
|
+
const t = {
|
|
208
|
+
accessToken: j.access_token,
|
|
209
|
+
...(j.refresh_token ? { refreshToken: j.refresh_token } : {}),
|
|
210
|
+
...(j.id_token ? { idToken: j.id_token } : {}),
|
|
211
|
+
accountId,
|
|
212
|
+
expiresAt: j.expires_in ? Date.now() + j.expires_in * 1000 : expiryFromToken(j.access_token),
|
|
213
|
+
source: "own",
|
|
214
|
+
};
|
|
215
|
+
writeOwn(home, t);
|
|
216
|
+
return t;
|
|
217
|
+
}
|
|
218
|
+
export function logout(home) {
|
|
219
|
+
try {
|
|
220
|
+
fs.unlinkSync(ownAuthPath(home));
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// ChatGPT subscription adapter: serves an Anthropic Messages request by calling the
|
|
2
|
+
// Codex backend (OpenAI Responses over SSE) and streaming the translated answer back.
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { CredentialStore } from "./auth.js";
|
|
5
|
+
import { SseParser } from "./sse.js";
|
|
6
|
+
import { StreamMapper, conversationKey, estimateTokens, formatSse, toResponsesRequest } from "./translate.js";
|
|
7
|
+
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { homeDir } from "../../config.js";
|
|
11
|
+
export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
12
|
+
const PING_MS = 15_000;
|
|
13
|
+
function anthropicError(status, type, message) {
|
|
14
|
+
return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
|
|
15
|
+
}
|
|
16
|
+
function mapHttpError(status, text) {
|
|
17
|
+
let msg = text.slice(0, 500);
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(text);
|
|
20
|
+
msg = j.error?.message ?? j.detail ?? msg;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* keep raw */
|
|
24
|
+
}
|
|
25
|
+
if (status === 401 || status === 403)
|
|
26
|
+
return anthropicError(401, "authentication_error", `ChatGPT: ${msg}`);
|
|
27
|
+
if (status === 429)
|
|
28
|
+
return anthropicError(429, "rate_limit_error", `ChatGPT: ${msg}`);
|
|
29
|
+
if (status >= 500)
|
|
30
|
+
return anthropicError(529, "overloaded_error", `ChatGPT: ${msg}`);
|
|
31
|
+
return anthropicError(400, "invalid_request_error", `ChatGPT: ${msg}`);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Quota snapshot from the backend's `x-codex-*` response headers (measured 2026-09-13: the
|
|
35
|
+
* backend reports limits there on every response; the `codex.rate_limits` SSE event is not
|
|
36
|
+
* always sent). Same shape as the event so the GUI reads either.
|
|
37
|
+
*/
|
|
38
|
+
export function rateLimitsFromHeaders(h) {
|
|
39
|
+
const num = (k) => {
|
|
40
|
+
const v = h.get(k);
|
|
41
|
+
if (v === null || v === "")
|
|
42
|
+
return undefined;
|
|
43
|
+
const n = Number(v);
|
|
44
|
+
return Number.isFinite(n) ? n : undefined;
|
|
45
|
+
};
|
|
46
|
+
const window = (p) => {
|
|
47
|
+
const used = num(`x-codex-${p}-used-percent`);
|
|
48
|
+
if (used === undefined)
|
|
49
|
+
return null;
|
|
50
|
+
const w = { used_percent: used };
|
|
51
|
+
const wm = num(`x-codex-${p}-window-minutes`);
|
|
52
|
+
const ra = num(`x-codex-${p}-reset-after-seconds`);
|
|
53
|
+
const rt = num(`x-codex-${p}-reset-at`);
|
|
54
|
+
if (wm !== undefined)
|
|
55
|
+
w.window_minutes = wm;
|
|
56
|
+
if (ra !== undefined)
|
|
57
|
+
w.reset_after_seconds = ra;
|
|
58
|
+
if (rt !== undefined)
|
|
59
|
+
w.reset_at = rt;
|
|
60
|
+
return w;
|
|
61
|
+
};
|
|
62
|
+
const primary = window("primary");
|
|
63
|
+
if (!primary)
|
|
64
|
+
return null;
|
|
65
|
+
const secondary = window("secondary");
|
|
66
|
+
return {
|
|
67
|
+
type: "codex.rate_limits",
|
|
68
|
+
plan_type: h.get("x-codex-plan-type") ?? undefined,
|
|
69
|
+
rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
|
|
70
|
+
at: Date.now(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export class ChatGptAdapter {
|
|
74
|
+
name;
|
|
75
|
+
cfg;
|
|
76
|
+
creds;
|
|
77
|
+
log;
|
|
78
|
+
lastRateLimits = null;
|
|
79
|
+
constructor(name, cfg, home, log) {
|
|
80
|
+
this.name = name;
|
|
81
|
+
this.cfg = cfg;
|
|
82
|
+
this.log = log;
|
|
83
|
+
this.creds = new CredentialStore(home, cfg.auth ?? "auto");
|
|
84
|
+
}
|
|
85
|
+
describeAuth() {
|
|
86
|
+
return this.creds.describe();
|
|
87
|
+
}
|
|
88
|
+
/** Last measured total input (uncached + cached) per conversation, for the next message_start estimate. */
|
|
89
|
+
lastInputByKey = new Map();
|
|
90
|
+
rememberInput(key, u) {
|
|
91
|
+
const total = u.input_tokens + u.cache_read_input_tokens;
|
|
92
|
+
if (total > 0) {
|
|
93
|
+
this.lastInputByKey.set(key, total);
|
|
94
|
+
if (this.lastInputByKey.size > 500)
|
|
95
|
+
this.lastInputByKey.delete(this.lastInputByKey.keys().next().value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Troubleshooting aid (provider.debugDump): the failing exchange, secrets excluded (the request carries none). */
|
|
99
|
+
dump(status, anthropic, upstreamReq, upstreamText) {
|
|
100
|
+
try {
|
|
101
|
+
const dir = path.join(homeDir(), "debug");
|
|
102
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
103
|
+
const file = path.join(dir, `upstream-${new Date().toISOString().replace(/[:.]/g, "-")}-${status}.json`);
|
|
104
|
+
fs.writeFileSync(file, JSON.stringify({ status, upstream: upstreamText, request: upstreamReq, anthropic }, null, 1));
|
|
105
|
+
const files = fs.readdirSync(dir).filter((f) => f.startsWith("upstream-")).sort();
|
|
106
|
+
for (const f of files.slice(0, Math.max(0, files.length - 60)))
|
|
107
|
+
fs.rmSync(path.join(dir, f), { force: true });
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
this.log.warn(`chatgpt ${this.name}: debug dump failed: ${e.message}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Handle a fully-read Messages request. `model`/`effort` already resolved by routing. */
|
|
114
|
+
async handle(req, res, path, json, model, effort) {
|
|
115
|
+
if (path.startsWith("/v1/messages/count_tokens")) {
|
|
116
|
+
const body = JSON.stringify({ input_tokens: estimateTokens(json) });
|
|
117
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": String(body.length) }).end(body);
|
|
118
|
+
return { status: 200, bytes: body.length, note: "estimated" };
|
|
119
|
+
}
|
|
120
|
+
const tokens = await this.creds.get();
|
|
121
|
+
if (tokens instanceof Error) {
|
|
122
|
+
const e = anthropicError(401, "authentication_error", tokens.message);
|
|
123
|
+
res.writeHead(e.status, { "content-type": "application/json" }).end(e.body);
|
|
124
|
+
return { status: e.status, bytes: e.body.length, note: "no credentials" };
|
|
125
|
+
}
|
|
126
|
+
const upstreamReq = toResponsesRequest(json, {
|
|
127
|
+
model,
|
|
128
|
+
effort: effort ?? this.cfg.defaultEffort ?? "high",
|
|
129
|
+
identity: this.cfg.identity ?? true,
|
|
130
|
+
...(this.cfg.instructionsAppend ? { instructionsAppend: this.cfg.instructionsAppend } : {}),
|
|
131
|
+
});
|
|
132
|
+
const body = JSON.stringify(upstreamReq);
|
|
133
|
+
const cacheKey = upstreamReq.prompt_cache_key;
|
|
134
|
+
// Input estimate for message_start: a conversation only grows, so the last measured total is a floor.
|
|
135
|
+
const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(cacheKey) ?? 0);
|
|
136
|
+
const ac = new AbortController();
|
|
137
|
+
const onClose = () => ac.abort();
|
|
138
|
+
res.on("close", onClose);
|
|
139
|
+
const upstreamHeaders = {
|
|
140
|
+
"content-type": "application/json",
|
|
141
|
+
accept: "text/event-stream",
|
|
142
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
143
|
+
"chatgpt-account-id": tokens.accountId,
|
|
144
|
+
"OpenAI-Beta": "responses=experimental",
|
|
145
|
+
originator: "codex_cli_rs",
|
|
146
|
+
};
|
|
147
|
+
const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
|
|
148
|
+
let upstream;
|
|
149
|
+
try {
|
|
150
|
+
upstream = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: upstreamHeaders,
|
|
153
|
+
body,
|
|
154
|
+
signal: ac.signal,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
res.off("close", onClose);
|
|
159
|
+
if (ac.signal.aborted)
|
|
160
|
+
return { status: 0, bytes: 0, note: "client closed" };
|
|
161
|
+
const err = anthropicError(502, "api_error", `ChatGPT backend unreachable: ${e.message}`);
|
|
162
|
+
if (!res.headersSent)
|
|
163
|
+
res.writeHead(err.status, { "content-type": "application/json" }).end(err.body);
|
|
164
|
+
throw e; // let the proxy feed health with the connect error
|
|
165
|
+
}
|
|
166
|
+
const fromHeaders = rateLimitsFromHeaders(upstream.headers);
|
|
167
|
+
if (fromHeaders)
|
|
168
|
+
this.lastRateLimits = fromHeaders;
|
|
169
|
+
if (!upstream.ok || !upstream.body) {
|
|
170
|
+
const text = await upstream.text().catch(() => "");
|
|
171
|
+
const safeText = redactErrorText(text, upstreamSecrets);
|
|
172
|
+
if (upstream.status === 401)
|
|
173
|
+
this.creds.invalidate();
|
|
174
|
+
const err = mapHttpError(upstream.status, safeText);
|
|
175
|
+
this.log.warn(`chatgpt ${this.name}: upstream ${upstream.status} for ${model}: ${safeText.slice(0, 400)}`);
|
|
176
|
+
if (this.cfg.debugDump)
|
|
177
|
+
this.dump(upstream.status, json, upstreamReq, safeText);
|
|
178
|
+
res.off("close", onClose);
|
|
179
|
+
res.writeHead(err.status, { "content-type": "application/json" }).end(err.body);
|
|
180
|
+
return { status: err.status, bytes: err.body.length, note: `upstream ${upstream.status}` };
|
|
181
|
+
}
|
|
182
|
+
if (this.cfg.debugDump === "all")
|
|
183
|
+
this.dump(upstream.status, json, upstreamReq, "");
|
|
184
|
+
const wantStream = json.stream === true;
|
|
185
|
+
const mapper = new StreamMapper(model, startInput);
|
|
186
|
+
const parser = new SseParser();
|
|
187
|
+
let bytes = 0;
|
|
188
|
+
let ping = null;
|
|
189
|
+
if (wantStream) {
|
|
190
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
191
|
+
for (const ev of mapper.start())
|
|
192
|
+
bytes += write(res, formatSse(ev));
|
|
193
|
+
ping = setInterval(() => {
|
|
194
|
+
if (!res.writableEnded)
|
|
195
|
+
bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
|
|
196
|
+
}, PING_MS);
|
|
197
|
+
}
|
|
198
|
+
const reader = upstream.body.getReader();
|
|
199
|
+
const decoder = new TextDecoder();
|
|
200
|
+
try {
|
|
201
|
+
for (;;) {
|
|
202
|
+
const { done, value } = await reader.read();
|
|
203
|
+
if (done)
|
|
204
|
+
break;
|
|
205
|
+
for (const ev of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
206
|
+
const outEvents = mapper.feed(ev);
|
|
207
|
+
if (mapper.rateLimits)
|
|
208
|
+
this.lastRateLimits = mapper.rateLimits;
|
|
209
|
+
this.rememberInput(cacheKey, mapper.usage);
|
|
210
|
+
if (wantStream)
|
|
211
|
+
for (const o of outEvents)
|
|
212
|
+
bytes += write(res, formatSse(o));
|
|
213
|
+
if (mapper.isFinished)
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (mapper.isFinished)
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (!mapper.isFinished) {
|
|
220
|
+
// Upstream ended without response.completed: treat as done with what we have.
|
|
221
|
+
const tail = mapper.finish();
|
|
222
|
+
if (wantStream)
|
|
223
|
+
for (const o of tail)
|
|
224
|
+
bytes += write(res, formatSse(o));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
if (!ac.signal.aborted) {
|
|
229
|
+
const tail = mapper.fail(`stream interrupted: ${e.message}`);
|
|
230
|
+
if (wantStream)
|
|
231
|
+
for (const o of tail)
|
|
232
|
+
bytes += write(res, formatSse(o));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
if (ping)
|
|
237
|
+
clearInterval(ping);
|
|
238
|
+
res.off("close", onClose);
|
|
239
|
+
try {
|
|
240
|
+
await reader.cancel();
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
/* already closed */
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (!wantStream) {
|
|
247
|
+
const msg = JSON.stringify(mapper.message());
|
|
248
|
+
res.writeHead(200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
|
|
249
|
+
bytes = msg.length;
|
|
250
|
+
}
|
|
251
|
+
else if (!res.writableEnded) {
|
|
252
|
+
res.end();
|
|
253
|
+
}
|
|
254
|
+
const u = mapper.usage;
|
|
255
|
+
return {
|
|
256
|
+
status: 200,
|
|
257
|
+
bytes,
|
|
258
|
+
note: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
|
|
259
|
+
usage: {
|
|
260
|
+
input: u.input_tokens,
|
|
261
|
+
cached: u.cache_read_input_tokens,
|
|
262
|
+
...(u.cache_creation_input_tokens > 0 ? { cacheWrite: u.cache_creation_input_tokens } : {}),
|
|
263
|
+
output: u.output_tokens,
|
|
264
|
+
},
|
|
265
|
+
stopReason: mapper.stopReason,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function write(res, s) {
|
|
270
|
+
if (res.writableEnded || res.destroyed)
|
|
271
|
+
return 0;
|
|
272
|
+
res.write(s);
|
|
273
|
+
return s.length;
|
|
274
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Minimal SSE parser: bytes in, `data:` payloads out. Handles multi-line data and CRLF.
|
|
2
|
+
export class SseParser {
|
|
3
|
+
buf = "";
|
|
4
|
+
/** Feed a chunk; returns the parsed JSON objects of complete events (non-JSON data is skipped). */
|
|
5
|
+
feed(chunk) {
|
|
6
|
+
this.buf += chunk;
|
|
7
|
+
const out = [];
|
|
8
|
+
let idx;
|
|
9
|
+
while ((idx = this.buf.search(/\r?\n\r?\n/)) >= 0) {
|
|
10
|
+
const raw = this.buf.slice(0, idx);
|
|
11
|
+
this.buf = this.buf.slice(idx).replace(/^\r?\n\r?\n/, "");
|
|
12
|
+
const data = raw
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.filter((l) => l.startsWith("data:"))
|
|
15
|
+
.map((l) => l.slice(5).replace(/^ /, ""))
|
|
16
|
+
.join("\n");
|
|
17
|
+
if (!data || data === "[DONE]")
|
|
18
|
+
continue;
|
|
19
|
+
try {
|
|
20
|
+
out.push(JSON.parse(data));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
/* ignore non-JSON frames */
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
}
|