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
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
// ChatGPT subscription adapter: serves an Anthropic Messages request by calling the
|
|
2
2
|
// Codex backend (OpenAI Responses over SSE) and streaming the translated answer back.
|
|
3
|
+
import crypto from "node:crypto";
|
|
3
4
|
import http from "node:http";
|
|
4
5
|
import { CredentialStore } from "./auth.js";
|
|
5
6
|
import { SseParser } from "./sse.js";
|
|
6
|
-
import {
|
|
7
|
+
import { fetchWithRetry } from "../retry.js";
|
|
8
|
+
import { looksLikeAuth } from "../openai/index.js";
|
|
9
|
+
import { StreamMapper, conversationKey, estimateTokens, formatSse, serverToolNames, toResponsesRequest, toolNameRestoreMap } from "./translate.js";
|
|
7
10
|
import { credentialHeaderValues, redactErrorText } from "../../redact.js";
|
|
11
|
+
import { codexClientVersion, parseCodexCatalog } from "./catalog.js";
|
|
8
12
|
import fs from "node:fs";
|
|
9
13
|
import path from "node:path";
|
|
10
14
|
import { homeDir } from "../../config.js";
|
|
11
15
|
export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
12
16
|
const PING_MS = 15_000;
|
|
17
|
+
/** Active quota lookup. Measured 2026-09-20: GET {base}/wham/usage → 200 JSON. The binary also
|
|
18
|
+
* carries `/api/codex/usage`, but that path answers 403 here; `wham` is the one that works. */
|
|
19
|
+
const USAGE_PATH = "/wham/usage";
|
|
20
|
+
const USAGE_TIMEOUT_MS = 10_000;
|
|
21
|
+
/** Model catalogue. Measured 2026-09-23: the backend filters this list by `client_version`, and an
|
|
22
|
+
* hour's cache is short enough that a model announced this morning shows up the same day. */
|
|
23
|
+
const MODELS_PATH = "/codex/models";
|
|
24
|
+
const MODELS_TIMEOUT_MS = 15_000;
|
|
25
|
+
const MODELS_CACHE_MS = 60 * 60 * 1000;
|
|
13
26
|
function anthropicError(status, type, message) {
|
|
14
27
|
return { status, body: JSON.stringify({ type: "error", error: { type, message } }) };
|
|
15
28
|
}
|
|
@@ -22,8 +35,15 @@ function mapHttpError(status, text) {
|
|
|
22
35
|
catch {
|
|
23
36
|
/* keep raw */
|
|
24
37
|
}
|
|
25
|
-
if (status === 401
|
|
38
|
+
if (status === 401)
|
|
26
39
|
return anthropicError(401, "authentication_error", `ChatGPT: ${msg}`);
|
|
40
|
+
// Same reasoning as the openai-compatible adapter: a 403 is often about what the account may do,
|
|
41
|
+
// not about the credential, and naming it an auth error sends the user to check the wrong thing.
|
|
42
|
+
if (status === 403) {
|
|
43
|
+
return looksLikeAuth(text)
|
|
44
|
+
? anthropicError(401, "authentication_error", `ChatGPT: ${msg}`)
|
|
45
|
+
: anthropicError(403, "permission_error", `ChatGPT: ${msg}`);
|
|
46
|
+
}
|
|
27
47
|
if (status === 429)
|
|
28
48
|
return anthropicError(429, "rate_limit_error", `ChatGPT: ${msg}`);
|
|
29
49
|
if (status >= 500)
|
|
@@ -70,6 +90,40 @@ export function rateLimitsFromHeaders(h) {
|
|
|
70
90
|
at: Date.now(),
|
|
71
91
|
};
|
|
72
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Map the `/wham/usage` JSON body onto the same shape as `rateLimitsFromHeaders`, so `/api/status`
|
|
95
|
+
* and the GUI read one thing whether the snapshot came from response headers or the active call.
|
|
96
|
+
* Shape measured 2026-09-20: `{plan_type, rate_limit: {primary_window:{used_percent,
|
|
97
|
+
* limit_window_seconds, reset_after_seconds, reset_at}, secondary_window}}`; window minutes are
|
|
98
|
+
* derived (the body reports seconds), and as with the headers a secondary window only counts when
|
|
99
|
+
* it has a real length.
|
|
100
|
+
*/
|
|
101
|
+
export function rateLimitsFromUsage(body) {
|
|
102
|
+
const b = body;
|
|
103
|
+
const win = (w) => {
|
|
104
|
+
const used = typeof w?.used_percent === "number" ? w.used_percent : undefined;
|
|
105
|
+
if (used === undefined)
|
|
106
|
+
return null;
|
|
107
|
+
const out = { used_percent: used };
|
|
108
|
+
if (typeof w.limit_window_seconds === "number")
|
|
109
|
+
out.window_minutes = Math.round(w.limit_window_seconds / 60);
|
|
110
|
+
if (typeof w.reset_after_seconds === "number")
|
|
111
|
+
out.reset_after_seconds = w.reset_after_seconds;
|
|
112
|
+
if (typeof w.reset_at === "number")
|
|
113
|
+
out.reset_at = w.reset_at;
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
const primary = win(b?.rate_limit?.primary_window);
|
|
117
|
+
if (!primary)
|
|
118
|
+
return null;
|
|
119
|
+
const secondary = win(b?.rate_limit?.secondary_window);
|
|
120
|
+
return {
|
|
121
|
+
type: "codex.rate_limits",
|
|
122
|
+
plan_type: typeof b?.plan_type === "string" ? b.plan_type : undefined,
|
|
123
|
+
rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
|
|
124
|
+
at: Date.now(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
73
127
|
export class ChatGptAdapter {
|
|
74
128
|
name;
|
|
75
129
|
cfg;
|
|
@@ -85,8 +139,254 @@ export class ChatGptAdapter {
|
|
|
85
139
|
describeAuth() {
|
|
86
140
|
return this.creds.describe();
|
|
87
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Hosted web search through the same Codex backend and credential as ordinary ChatGPT turns.
|
|
144
|
+
* Wire measured 2026-09-20 against the live backend: a `web_search` Responses tool emits one
|
|
145
|
+
* `web_search_call`, URL citation annotations on the final output text, and
|
|
146
|
+
* `response.completed.response.tool_usage.web_search.num_requests`.
|
|
147
|
+
*/
|
|
148
|
+
webSearch(model, maxResults) {
|
|
149
|
+
return {
|
|
150
|
+
name: this.name,
|
|
151
|
+
search: (query, signal) => this.searchWeb(model, query, maxResults, signal),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
async searchWeb(model, query, maxResults = 10, signal) {
|
|
155
|
+
// The Responses tool exposes an allowed-domain filter but no exclusion filter. Ignoring a block
|
|
156
|
+
// would violate the caller's request; fail visibly so the proxy can choose another backend.
|
|
157
|
+
if (query.blockedDomains?.length)
|
|
158
|
+
throw new Error("ChatGPT web search does not support blocked_domains");
|
|
159
|
+
const tokens = await this.creds.get();
|
|
160
|
+
if (tokens instanceof Error)
|
|
161
|
+
throw tokens;
|
|
162
|
+
const id = crypto.randomUUID();
|
|
163
|
+
const filters = query.allowedDomains?.length ? { allowed_domains: query.allowedDomains } : undefined;
|
|
164
|
+
const tool = {
|
|
165
|
+
type: "web_search",
|
|
166
|
+
search_context_size: "low",
|
|
167
|
+
external_web_access: true,
|
|
168
|
+
...(filters ? { filters } : {}),
|
|
169
|
+
};
|
|
170
|
+
const body = {
|
|
171
|
+
model,
|
|
172
|
+
instructions: "Perform the requested web search. Answer briefly and cite every source used.",
|
|
173
|
+
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query.query }] }],
|
|
174
|
+
tools: [tool],
|
|
175
|
+
tool_choice: "required",
|
|
176
|
+
reasoning: { effort: "low", summary: "auto" },
|
|
177
|
+
text: { verbosity: "low" },
|
|
178
|
+
store: false,
|
|
179
|
+
stream: true,
|
|
180
|
+
prompt_cache_key: id,
|
|
181
|
+
client_metadata: { session_id: id, thread_id: id, turn_id: crypto.randomUUID(), "x-codex-window-id": `${id}:0` },
|
|
182
|
+
};
|
|
183
|
+
const timeout = AbortSignal.timeout(60_000);
|
|
184
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
185
|
+
const res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: {
|
|
188
|
+
"content-type": "application/json",
|
|
189
|
+
accept: "text/event-stream",
|
|
190
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
191
|
+
"chatgpt-account-id": tokens.accountId,
|
|
192
|
+
"OpenAI-Beta": "responses=experimental",
|
|
193
|
+
originator: "codex_cli_rs",
|
|
194
|
+
"session-id": id,
|
|
195
|
+
"thread-id": id,
|
|
196
|
+
"x-client-request-id": id,
|
|
197
|
+
"x-codex-window-id": `${id}:0`,
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify(body),
|
|
200
|
+
signal: requestSignal,
|
|
201
|
+
});
|
|
202
|
+
const rateLimits = rateLimitsFromHeaders(res.headers);
|
|
203
|
+
if (rateLimits)
|
|
204
|
+
this.lastRateLimits = rateLimits;
|
|
205
|
+
if (!res.ok || !res.body) {
|
|
206
|
+
const text = await res.text().catch(() => "");
|
|
207
|
+
if (res.status === 401)
|
|
208
|
+
this.creds.invalidate();
|
|
209
|
+
throw new Error(`ChatGPT web search: HTTP ${res.status}${text ? ` ${redactErrorText(text, [tokens.accessToken], 200)}` : ""}`);
|
|
210
|
+
}
|
|
211
|
+
const parser = new SseParser();
|
|
212
|
+
const reader = res.body.getReader();
|
|
213
|
+
const decoder = new TextDecoder();
|
|
214
|
+
const hits = [];
|
|
215
|
+
let text = "";
|
|
216
|
+
let searches = 0;
|
|
217
|
+
try {
|
|
218
|
+
for (;;) {
|
|
219
|
+
const { done, value } = await reader.read();
|
|
220
|
+
if (done)
|
|
221
|
+
break;
|
|
222
|
+
for (const event of parser.feed(decoder.decode(value, { stream: true }))) {
|
|
223
|
+
if (event.type === "response.output_text.delta")
|
|
224
|
+
text += String(event.delta ?? "");
|
|
225
|
+
if (event.type === "response.output_text.annotation.added") {
|
|
226
|
+
const a = event.annotation;
|
|
227
|
+
if (a?.type === "url_citation" && typeof a.url === "string")
|
|
228
|
+
hits.push({ title: typeof a.title === "string" && a.title ? a.title : a.url, url: a.url });
|
|
229
|
+
}
|
|
230
|
+
if (event.type === "response.completed") {
|
|
231
|
+
const completed = event.response;
|
|
232
|
+
searches = typeof completed?.tool_usage?.web_search?.num_requests === "number" ? completed.tool_usage.web_search.num_requests : searches;
|
|
233
|
+
}
|
|
234
|
+
if (event.type === "error")
|
|
235
|
+
throw new Error(`ChatGPT web search: ${String(event.error?.message ?? "backend error")}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
try {
|
|
241
|
+
await reader.cancel();
|
|
242
|
+
}
|
|
243
|
+
catch { /* already closed */ }
|
|
244
|
+
}
|
|
245
|
+
const unique = new Map();
|
|
246
|
+
for (const hit of hits)
|
|
247
|
+
if (!unique.has(hit.url))
|
|
248
|
+
unique.set(hit.url, hit);
|
|
249
|
+
const selected = [...unique.values()].slice(0, maxResults);
|
|
250
|
+
if (searches < 1 || selected.length === 0)
|
|
251
|
+
throw new Error(`ChatGPT web search: no cited results returned (searches=${searches})`);
|
|
252
|
+
const prose = text.trim();
|
|
253
|
+
return prose ? { hits: selected, text: prose } : { hits: selected };
|
|
254
|
+
}
|
|
255
|
+
/** One in-flight lookup shared by every caller (the status route and the startup refresh). */
|
|
256
|
+
rateLimitsInFlight = null;
|
|
257
|
+
/**
|
|
258
|
+
* Ask the backend for the current quota instead of waiting for a request to carry it in the
|
|
259
|
+
* response headers. Without this, `/api/status` shows the last time GPT traffic flowed — 11
|
|
260
|
+
* hours stale in one measurement (2026-09-20) — and the product's GPT budget read is wrong.
|
|
261
|
+
* Never throws and never clears a good snapshot; returns the new one, or null on failure.
|
|
262
|
+
*/
|
|
263
|
+
fetchRateLimits() {
|
|
264
|
+
if (this.rateLimitsInFlight)
|
|
265
|
+
return this.rateLimitsInFlight;
|
|
266
|
+
this.rateLimitsInFlight = this.fetchRateLimitsOnce().finally(() => {
|
|
267
|
+
this.rateLimitsInFlight = null;
|
|
268
|
+
});
|
|
269
|
+
return this.rateLimitsInFlight;
|
|
270
|
+
}
|
|
271
|
+
async fetchRateLimitsOnce() {
|
|
272
|
+
const tokens = await this.creds.get();
|
|
273
|
+
if (tokens instanceof Error) {
|
|
274
|
+
this.log.warn(`chatgpt ${this.name}: rate-limit fetch skipped: ${tokens.message}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
const ac = new AbortController();
|
|
278
|
+
const timer = setTimeout(() => ac.abort(), USAGE_TIMEOUT_MS);
|
|
279
|
+
let res;
|
|
280
|
+
try {
|
|
281
|
+
res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${USAGE_PATH}`, {
|
|
282
|
+
method: "GET",
|
|
283
|
+
headers: {
|
|
284
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
285
|
+
"chatgpt-account-id": tokens.accountId,
|
|
286
|
+
originator: "codex_cli_rs",
|
|
287
|
+
accept: "application/json",
|
|
288
|
+
},
|
|
289
|
+
signal: ac.signal,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
catch (e) {
|
|
293
|
+
this.log.warn(`chatgpt ${this.name}: rate-limit fetch failed: ${e.message}`);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
}
|
|
299
|
+
if (!res.ok) {
|
|
300
|
+
if (res.status === 401)
|
|
301
|
+
this.creds.invalidate();
|
|
302
|
+
this.log.warn(`chatgpt ${this.name}: rate-limit fetch HTTP ${res.status}`);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const parsed = rateLimitsFromUsage(await res.json().catch(() => null));
|
|
306
|
+
if (!parsed) {
|
|
307
|
+
this.log.warn(`chatgpt ${this.name}: rate-limit fetch returned no primary window`);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
this.lastRateLimits = parsed;
|
|
311
|
+
return parsed;
|
|
312
|
+
}
|
|
313
|
+
/** One in-flight catalogue lookup shared by every caller, and the last good list for an hour. */
|
|
314
|
+
modelsInFlight = null;
|
|
315
|
+
modelsCache = null;
|
|
316
|
+
/**
|
|
317
|
+
* The models this subscription can actually reach, from the backend's own catalogue. Without it
|
|
318
|
+
* a model OpenAI ships is invisible here until a router release names it (gpt-6-sol and gpt-6-luna,
|
|
319
|
+
* 2026-09-23). Never throws; null on any failure, and null
|
|
320
|
+
* rather than [] when parsing yields nothing, so the caller falls back instead of showing nothing.
|
|
321
|
+
*/
|
|
322
|
+
fetchModels() {
|
|
323
|
+
if (this.modelsCache && Date.now() - this.modelsCache.at < MODELS_CACHE_MS)
|
|
324
|
+
return Promise.resolve(this.modelsCache.models);
|
|
325
|
+
if (this.modelsInFlight)
|
|
326
|
+
return this.modelsInFlight;
|
|
327
|
+
// A failed refresh keeps the last list it read: an expired catalogue is still closer to the
|
|
328
|
+
// backend than the fallback written into this repo.
|
|
329
|
+
this.modelsInFlight = this.fetchModelsOnce().then((models) => models ?? this.modelsCache?.models ?? null).finally(() => {
|
|
330
|
+
this.modelsInFlight = null;
|
|
331
|
+
});
|
|
332
|
+
return this.modelsInFlight;
|
|
333
|
+
}
|
|
334
|
+
async fetchModelsOnce() {
|
|
335
|
+
const tokens = await this.creds.get();
|
|
336
|
+
if (tokens instanceof Error) {
|
|
337
|
+
this.log.warn(`chatgpt ${this.name}: model-catalog fetch skipped: ${tokens.message}`);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
const ac = new AbortController();
|
|
341
|
+
const timer = setTimeout(() => ac.abort(), MODELS_TIMEOUT_MS);
|
|
342
|
+
let res;
|
|
343
|
+
try {
|
|
344
|
+
res = await fetch(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}${MODELS_PATH}?client_version=${encodeURIComponent(codexClientVersion())}`, {
|
|
345
|
+
method: "GET",
|
|
346
|
+
headers: {
|
|
347
|
+
authorization: `Bearer ${tokens.accessToken}`,
|
|
348
|
+
"chatgpt-account-id": tokens.accountId,
|
|
349
|
+
originator: "codex_cli_rs",
|
|
350
|
+
accept: "application/json",
|
|
351
|
+
},
|
|
352
|
+
signal: ac.signal,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
catch (e) {
|
|
356
|
+
this.log.warn(`chatgpt ${this.name}: model-catalog fetch failed: ${e.message}`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
clearTimeout(timer);
|
|
361
|
+
}
|
|
362
|
+
if (!res.ok) {
|
|
363
|
+
// The status only: the body can echo the request, and the token rides on it.
|
|
364
|
+
if (res.status === 401)
|
|
365
|
+
this.creds.invalidate();
|
|
366
|
+
this.log.warn(`chatgpt ${this.name}: model-catalog fetch HTTP ${res.status}`);
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
const models = parseCodexCatalog(await res.json().catch(() => null));
|
|
370
|
+
if (models.length === 0) {
|
|
371
|
+
this.log.warn(`chatgpt ${this.name}: model catalog returned no listed models`);
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
this.modelsCache = { at: Date.now(), models };
|
|
375
|
+
return models;
|
|
376
|
+
}
|
|
88
377
|
/** Last measured total input (uncached + cached) per conversation, for the next message_start estimate. */
|
|
89
378
|
lastInputByKey = new Map();
|
|
379
|
+
/**
|
|
380
|
+
* The backend's `x-codex-turn-state` per conversation. Every response carries this opaque
|
|
381
|
+
* token and the Codex CLI sends it back on the conversation's next turn (it sits in the
|
|
382
|
+
* binary's request-header list beside `x-codex-installation-id`). Without it the backend
|
|
383
|
+
* answered `cached_tokens: 0` on every turn of a conversation whose prompt_cache_key,
|
|
384
|
+
* instructions, tools and input prefix were byte-identical 3–6s apart (measured 2026-09-20,
|
|
385
|
+
* five turns, GPT-6 Astra) — the same adapter read 93% on 2026-09-13, so the backend began
|
|
386
|
+
* keying cache affinity on this token in between. Keyed on the cache key, which is what a
|
|
387
|
+
* conversation is to us.
|
|
388
|
+
*/
|
|
389
|
+
turnStateByKey = new Map();
|
|
90
390
|
rememberInput(key, u) {
|
|
91
391
|
const total = u.input_tokens + u.cache_read_input_tokens;
|
|
92
392
|
if (total > 0) {
|
|
@@ -123,6 +423,11 @@ export class ChatGptAdapter {
|
|
|
123
423
|
res.writeHead(e.status, { "content-type": "application/json" }).end(e.body);
|
|
124
424
|
return { status: e.status, bytes: e.body.length, note: "no credentials" };
|
|
125
425
|
}
|
|
426
|
+
// Dropping a tool the model was meant to have is worth a line: the alternative to this drop is
|
|
427
|
+
// an empty answer with nothing logged anywhere, which is what made it expensive to find.
|
|
428
|
+
const serverTools = serverToolNames(json.tools);
|
|
429
|
+
if (serverTools.size > 0)
|
|
430
|
+
this.log.warn(`chatgpt ${this.name}: dropped server tools for ${model}: ${[...serverTools].join(", ")} (Anthropic runs these; this provider cannot)`);
|
|
126
431
|
const upstreamReq = toResponsesRequest(json, {
|
|
127
432
|
model,
|
|
128
433
|
effort: effort ?? this.cfg.defaultEffort ?? "high",
|
|
@@ -136,6 +441,7 @@ export class ChatGptAdapter {
|
|
|
136
441
|
const ac = new AbortController();
|
|
137
442
|
const onClose = () => ac.abort();
|
|
138
443
|
res.on("close", onClose);
|
|
444
|
+
const turnState = this.turnStateByKey.get(cacheKey);
|
|
139
445
|
const upstreamHeaders = {
|
|
140
446
|
"content-type": "application/json",
|
|
141
447
|
accept: "text/event-stream",
|
|
@@ -143,16 +449,25 @@ export class ChatGptAdapter {
|
|
|
143
449
|
"chatgpt-account-id": tokens.accountId,
|
|
144
450
|
"OpenAI-Beta": "responses=experimental",
|
|
145
451
|
originator: "codex_cli_rs",
|
|
452
|
+
// The conversation's identity, as the Codex CLI states it. This is what the backend keys
|
|
453
|
+
// the prompt cache on since mid-September 2026 (see `conversationId` in translate.ts).
|
|
454
|
+
"session-id": cacheKey,
|
|
455
|
+
"thread-id": cacheKey,
|
|
456
|
+
"x-client-request-id": cacheKey,
|
|
457
|
+
"x-codex-window-id": `${cacheKey}:0`,
|
|
458
|
+
...(turnState ? { "x-codex-turn-state": turnState } : {}),
|
|
146
459
|
};
|
|
147
460
|
const upstreamSecrets = credentialHeaderValues(Object.entries(upstreamHeaders));
|
|
148
461
|
let upstream;
|
|
149
462
|
try {
|
|
150
|
-
|
|
463
|
+
// Retried here, before any status or byte reaches the client, so a relay's hiccup is absorbed
|
|
464
|
+
// inside the turn instead of arriving as an error the user has to retry by hand.
|
|
465
|
+
upstream = await fetchWithRetry(`${(this.cfg.url ?? DEFAULT_BASE).replace(/\/$/, "")}/codex/responses`, {
|
|
151
466
|
method: "POST",
|
|
152
467
|
headers: upstreamHeaders,
|
|
153
468
|
body,
|
|
154
469
|
signal: ac.signal,
|
|
155
|
-
});
|
|
470
|
+
}, { log: (line) => this.log.info(`chatgpt ${this.name}: ${line}`) });
|
|
156
471
|
}
|
|
157
472
|
catch (e) {
|
|
158
473
|
res.off("close", onClose);
|
|
@@ -166,6 +481,12 @@ export class ChatGptAdapter {
|
|
|
166
481
|
const fromHeaders = rateLimitsFromHeaders(upstream.headers);
|
|
167
482
|
if (fromHeaders)
|
|
168
483
|
this.lastRateLimits = fromHeaders;
|
|
484
|
+
const nextTurnState = upstream.headers.get("x-codex-turn-state");
|
|
485
|
+
if (nextTurnState) {
|
|
486
|
+
this.turnStateByKey.set(cacheKey, nextTurnState);
|
|
487
|
+
if (this.turnStateByKey.size > 500)
|
|
488
|
+
this.turnStateByKey.delete(this.turnStateByKey.keys().next().value);
|
|
489
|
+
}
|
|
169
490
|
if (!upstream.ok || !upstream.body) {
|
|
170
491
|
const text = await upstream.text().catch(() => "");
|
|
171
492
|
const safeText = redactErrorText(text, upstreamSecrets);
|
|
@@ -182,7 +503,7 @@ export class ChatGptAdapter {
|
|
|
182
503
|
if (this.cfg.debugDump === "all")
|
|
183
504
|
this.dump(upstream.status, json, upstreamReq, "");
|
|
184
505
|
const wantStream = json.stream === true;
|
|
185
|
-
const mapper = new StreamMapper(model, startInput);
|
|
506
|
+
const mapper = new StreamMapper(model, startInput, toolNameRestoreMap(json));
|
|
186
507
|
const parser = new SseParser();
|
|
187
508
|
let bytes = 0;
|
|
188
509
|
let ping = null;
|
|
@@ -217,8 +538,12 @@ export class ChatGptAdapter {
|
|
|
217
538
|
break;
|
|
218
539
|
}
|
|
219
540
|
if (!mapper.isFinished) {
|
|
220
|
-
// Upstream ended without response.completed
|
|
221
|
-
|
|
541
|
+
// Upstream ended without response.completed. This used to be finished as done-with-what-we-
|
|
542
|
+
// have, which gave the client an empty or cut-off turn it accepted as final (gpt-6-astra,
|
|
543
|
+
// 12 empty turns at a median 105s, 2026-09). Overloaded is what Claude Code retries.
|
|
544
|
+
const tail = parser.sawDone
|
|
545
|
+
? mapper.finish()
|
|
546
|
+
: mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
|
|
222
547
|
if (wantStream)
|
|
223
548
|
for (const o of tail)
|
|
224
549
|
bytes += write(res, formatSse(o));
|
|
@@ -226,7 +551,7 @@ export class ChatGptAdapter {
|
|
|
226
551
|
}
|
|
227
552
|
catch (e) {
|
|
228
553
|
if (!ac.signal.aborted) {
|
|
229
|
-
const tail = mapper.fail(`stream interrupted: ${e.message}
|
|
554
|
+
const tail = mapper.fail(`stream interrupted: ${e.message}`, "server_is_overloaded");
|
|
230
555
|
if (wantStream)
|
|
231
556
|
for (const o of tail)
|
|
232
557
|
bytes += write(res, formatSse(o));
|
|
@@ -243,9 +568,12 @@ export class ChatGptAdapter {
|
|
|
243
568
|
/* already closed */
|
|
244
569
|
}
|
|
245
570
|
}
|
|
571
|
+
const failure = mapper.failure;
|
|
572
|
+
const failedStatus = failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
246
573
|
if (!wantStream) {
|
|
247
|
-
|
|
248
|
-
|
|
574
|
+
// A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
|
|
575
|
+
const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
|
|
576
|
+
res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
|
|
249
577
|
bytes = msg.length;
|
|
250
578
|
}
|
|
251
579
|
else if (!res.writableEnded) {
|
|
@@ -253,9 +581,12 @@ export class ChatGptAdapter {
|
|
|
253
581
|
}
|
|
254
582
|
const u = mapper.usage;
|
|
255
583
|
return {
|
|
256
|
-
|
|
584
|
+
// A stream has already sent 200; the record still says the turn failed.
|
|
585
|
+
status: failure ? failedStatus : 200,
|
|
257
586
|
bytes,
|
|
258
|
-
note:
|
|
587
|
+
note: failure
|
|
588
|
+
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
|
|
589
|
+
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
|
|
259
590
|
usage: {
|
|
260
591
|
input: u.input_tokens,
|
|
261
592
|
cached: u.cache_read_input_tokens,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Minimal SSE parser: bytes in, `data:` payloads out. Handles multi-line data and CRLF.
|
|
2
2
|
export class SseParser {
|
|
3
3
|
buf = "";
|
|
4
|
+
/** The stream's own end marker arrived — a vendor that ends on `[DONE]` finished on purpose. */
|
|
5
|
+
sawDone = false;
|
|
4
6
|
/** Feed a chunk; returns the parsed JSON objects of complete events (non-JSON data is skipped). */
|
|
5
7
|
feed(chunk) {
|
|
6
8
|
this.buf += chunk;
|
|
@@ -14,6 +16,8 @@ export class SseParser {
|
|
|
14
16
|
.filter((l) => l.startsWith("data:"))
|
|
15
17
|
.map((l) => l.slice(5).replace(/^ /, ""))
|
|
16
18
|
.join("\n");
|
|
19
|
+
if (data === "[DONE]")
|
|
20
|
+
this.sawDone = true;
|
|
17
21
|
if (!data || data === "[DONE]")
|
|
18
22
|
continue;
|
|
19
23
|
try {
|