@askalf/dario 6.0.21 → 6.0.23

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/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  <p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
20
20
 
21
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~29k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
21
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~30k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
22
22
 
23
23
  <sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 12 open tools for owning your AI infra: <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/strongroom">strongroom</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
24
24
 
@@ -165,6 +165,8 @@ curl localhost:3456/v1/messages -H 'content-type: application/json' \
165
165
 
166
166
  Streaming, tool calls, and tool-result round trips work on both shapes: dario translates chat/completions **or** Messages into the Responses API the subscription backend speaks, and translates the stream back into `chat.completion.chunk` or Anthropic message events to match what the client asked in. There is no `/v1/responses` inbound yet.
167
167
 
168
+ **Chat/completions fidelity:** text and `image_url` user-content parts (HTTPS URLs and data URIs, including the `detail` fidelity setting) carry through to Responses input items. The Codex subscription backend does not accept every chat field, so `response_format`, `stop`, `n`, `logprobs`, `stream_options` and other unmapped chat-only fields are intentionally lossy — and so are the sampling parameters `temperature`, `top_p`, `max_tokens` and `max_completion_tokens`, which translate cleanly but are then rejected by the backend and stripped before the request goes out. With `--verbose`, dario reports each field that does not reach Codex once per process.
169
+
168
170
  Codex accounts live in `~/.dario/codex-accounts/`, entirely separate from the Claude pool. Nothing about `dario login`, `dario accounts`, or Claude routing changes.
169
171
 
170
172
  ---
@@ -307,7 +309,7 @@ The split isn't live, but it was announced once on short notice and could return
307
309
 
308
310
  | Signal | Status |
309
311
  |---|---|
310
- | Source | **~29k** lines of TypeScript across **59** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
312
+ | Source | **~30k** lines of TypeScript across **65** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
311
313
  | Dependencies | **0 runtime.** Verify: `npm ls --production` |
312
314
  | Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore |
313
315
  | Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly |
package/dist/cli.js CHANGED
@@ -1121,7 +1121,7 @@ async function codex() {
1121
1121
  console.error(`[dario] Codex account "${alias}" already exists. Remove it first with \`dario codex remove ${alias}\`.`);
1122
1122
  process.exit(1);
1123
1123
  }
1124
- const { authorizeUrl, codeVerifier } = await startAddCodexAccount(alias);
1124
+ const { authorizeUrl, codeVerifier, state } = await startAddCodexAccount(alias);
1125
1125
  console.log('');
1126
1126
  console.log(' Open this URL and log in with your ChatGPT Plus/Pro account.');
1127
1127
  console.log(' The browser will land on a localhost page that does not load —');
@@ -1130,11 +1130,21 @@ async function codex() {
1130
1130
  console.log(` ${authorizeUrl}`);
1131
1131
  console.log('');
1132
1132
  const pasted = await readLineFromStdin(' Redirect URL (or code): ');
1133
- const { code } = parseCodexManualPaste(pasted);
1133
+ const { code, state: pastedState } = parseCodexManualPaste(pasted);
1134
1134
  if (!code) {
1135
1135
  console.error('[dario] No code found in what you pasted.');
1136
1136
  process.exit(1);
1137
1137
  }
1138
+ // The state parameter is what ties this paste to the URL printed above.
1139
+ // It was parsed and then discarded, which made the CSRF guard the OAuth
1140
+ // flow carries decorative: a redirect from some other authorize request
1141
+ // would have been accepted and stored. A paste with no state at all (a
1142
+ // bare code — accepted shape #3) can't be checked and is left alone.
1143
+ if (pastedState !== null && pastedState !== state) {
1144
+ console.error('[dario] The pasted redirect does not match this login attempt (state mismatch).');
1145
+ console.error(' Start over with `dario codex add ' + alias + '` and paste the URL from THAT browser tab.');
1146
+ process.exit(1);
1147
+ }
1138
1148
  try {
1139
1149
  await completeAddCodexAccount(alias, code, codeVerifier);
1140
1150
  console.log('');
@@ -22,9 +22,43 @@ export declare function startAddCodexAccount(alias: string): Promise<{
22
22
  export declare function completeAddCodexAccount(alias: string, code: string, codeVerifier: string): Promise<CodexAccountCredentials>;
23
23
  export declare function codexAccountNeedsRefresh(creds: CodexAccountCredentials): boolean;
24
24
  export declare function refreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
25
+ /**
26
+ * A refresh this process is not going to attempt right now: either the last one
27
+ * failed inside the cool-down below, or the one just attempted failed. Carries
28
+ * the alias so the caller can name the CODEX account in the client's error —
29
+ * the whole point of the fix, since the request otherwise fell through to the
30
+ * Claude path and got told to run `dario login`.
31
+ */
32
+ export declare class CodexCredentialsUnavailableError extends Error {
33
+ readonly alias: string;
34
+ readonly status: number;
35
+ readonly failedAt: number;
36
+ constructor(alias: string, failure: CodexRefreshFailure);
37
+ }
38
+ /** Last refresh rejection remembered for an alias. No token material here. */
39
+ export interface CodexRefreshFailure {
40
+ /** When the failure was observed (ms epoch). */
41
+ at: number;
42
+ /** Upstream HTTP status, or 0 when no response arrived (transport/timeout). */
43
+ status: number;
44
+ /** First ~120 chars of the upstream body / error text — never a token. */
45
+ message: string;
46
+ }
47
+ /** Last remembered refresh failure for an alias, or null. Read-only view for
48
+ * the admin surface (`GET /codex`) — never triggers an upstream call. */
49
+ export declare function getCodexRefreshFailure(alias: string): CodexRefreshFailure | null;
50
+ /** Test seam — forget every remembered failure. */
51
+ export declare function _resetCodexRefreshFailuresForTest(): void;
25
52
  /**
26
53
  * Return credentials guaranteed fresh enough to send upstream, refreshing (once,
27
54
  * per alias, per process) when within the expiry buffer.
55
+ *
56
+ * Throws {@link CodexCredentialsUnavailableError} when the refresh failed —
57
+ * including when it failed RECENTLY and this call therefore made no upstream
58
+ * attempt at all. Callers must treat the throw as "the codex route is not
59
+ * available for this request" and say so naming the codex account; swallowing
60
+ * it is what turned a dead token into a per-request token-endpoint storm with
61
+ * a misleading "run `dario login`" answer to the client.
28
62
  */
29
63
  export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
30
64
  /**
@@ -13,7 +13,7 @@ import { readFile, mkdir, readdir, unlink } from 'node:fs/promises';
13
13
  import { join, basename } from 'node:path';
14
14
  import { homedir } from 'node:os';
15
15
  import { randomBytes } from 'node:crypto';
16
- import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, } from './codex-oauth.js';
16
+ import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, CodexRefreshError, } from './codex-oauth.js';
17
17
  import { durableWriteFile } from './durable-write.js';
18
18
  const DARIO_DIR = join(homedir(), '.dario');
19
19
  const CODEX_ACCOUNTS_DIR = join(DARIO_DIR, 'codex-accounts');
@@ -106,6 +106,7 @@ export async function removeCodexAccount(alias) {
106
106
  return false;
107
107
  try {
108
108
  await unlink(path);
109
+ refreshFailures.delete(alias);
109
110
  return true;
110
111
  }
111
112
  catch {
@@ -140,6 +141,8 @@ export async function completeAddCodexAccount(alias, code, codeVerifier) {
140
141
  idToken: tokens.idToken,
141
142
  };
142
143
  await saveCodexAccount(creds);
144
+ // Freshly minted credentials — whatever the old ones failed with is history.
145
+ refreshFailures.delete(alias);
143
146
  return creds;
144
147
  }
145
148
  /**
@@ -163,6 +166,75 @@ export async function refreshCodexAccount(creds) {
163
166
  await saveCodexAccount(updated);
164
167
  return updated;
165
168
  }
169
+ /**
170
+ * A refresh this process is not going to attempt right now: either the last one
171
+ * failed inside the cool-down below, or the one just attempted failed. Carries
172
+ * the alias so the caller can name the CODEX account in the client's error —
173
+ * the whole point of the fix, since the request otherwise fell through to the
174
+ * Claude path and got told to run `dario login`.
175
+ */
176
+ export class CodexCredentialsUnavailableError extends Error {
177
+ alias;
178
+ status;
179
+ failedAt;
180
+ constructor(alias, failure) {
181
+ super(`codex account ${alias}: token refresh failed (${failure.status || 'no response'}): ${failure.message}`);
182
+ this.name = 'CodexCredentialsUnavailableError';
183
+ this.alias = alias;
184
+ this.status = failure.status;
185
+ this.failedAt = failure.at;
186
+ }
187
+ }
188
+ /**
189
+ * A dead refresh_token used to cost one token-endpoint POST PER REQUEST,
190
+ * forever, silently: getFreshCodexAccount only collapsed CONCURRENT refreshes
191
+ * and remembered nothing about a failure, so every inbound request re-asked.
192
+ * Same shape as codex-backend.ts's MODEL_CACHE_ERROR_TTL_MS — remember the
193
+ * failure briefly, so a broken account costs one attempt a minute and recovers
194
+ * on its own within a minute of the operator fixing it.
195
+ */
196
+ const REFRESH_FAILURE_TTL_MS = 60 * 1000;
197
+ const refreshFailures = new Map();
198
+ /** Last remembered refresh failure for an alias, or null. Read-only view for
199
+ * the admin surface (`GET /codex`) — never triggers an upstream call. */
200
+ export function getCodexRefreshFailure(alias) {
201
+ const hit = refreshFailures.get(alias);
202
+ return hit ? { at: hit.at, status: hit.status, message: hit.message } : null;
203
+ }
204
+ /** Test seam — forget every remembered failure. */
205
+ export function _resetCodexRefreshFailuresForTest() {
206
+ refreshFailures.clear();
207
+ }
208
+ function noteRefreshFailure(alias, err) {
209
+ const status = err instanceof CodexRefreshError ? err.status : 0;
210
+ const raw = err instanceof CodexRefreshError
211
+ ? err.bodySnippet
212
+ : (err instanceof Error ? err.message : String(err));
213
+ const previous = refreshFailures.get(alias);
214
+ const entry = {
215
+ at: Date.now(),
216
+ status,
217
+ message: raw.slice(0, 120),
218
+ retryAt: Date.now() + REFRESH_FAILURE_TTL_MS,
219
+ logged: previous?.logged ?? false,
220
+ };
221
+ if (!entry.logged) {
222
+ // ONE line per outage. The body snippet is the upstream's own error text
223
+ // (`invalid_grant`, an HTML 502 page, a timeout message) — the tokens
224
+ // themselves are never logged.
225
+ console.error(`[dario] codex account ${alias}: token refresh failed (${status || 'no response'}): ${entry.message} — ` +
226
+ `not retrying for ${Math.round(REFRESH_FAILURE_TTL_MS / 1000)}s. Re-add with \`dario codex add ${alias}\` if this persists.`);
227
+ entry.logged = true;
228
+ }
229
+ refreshFailures.set(alias, entry);
230
+ return entry;
231
+ }
232
+ function noteRefreshRecovered(alias) {
233
+ const previous = refreshFailures.get(alias);
234
+ refreshFailures.delete(alias);
235
+ if (previous?.logged)
236
+ console.log(`[dario] codex account ${alias}: token refresh recovered`);
237
+ }
166
238
  /**
167
239
  * In-process refresh serialization.
168
240
  *
@@ -183,14 +255,37 @@ const inflightRefresh = new Map();
183
255
  /**
184
256
  * Return credentials guaranteed fresh enough to send upstream, refreshing (once,
185
257
  * per alias, per process) when within the expiry buffer.
258
+ *
259
+ * Throws {@link CodexCredentialsUnavailableError} when the refresh failed —
260
+ * including when it failed RECENTLY and this call therefore made no upstream
261
+ * attempt at all. Callers must treat the throw as "the codex route is not
262
+ * available for this request" and say so naming the codex account; swallowing
263
+ * it is what turned a dead token into a per-request token-endpoint storm with
264
+ * a misleading "run `dario login`" answer to the client.
186
265
  */
187
266
  export async function getFreshCodexAccount(creds) {
188
- if (!codexAccountNeedsRefresh(creds))
267
+ if (!codexAccountNeedsRefresh(creds)) {
268
+ // Credentials on disk are fresh again — an operator re-added the account,
269
+ // or another process refreshed it. Nothing to cool down anymore.
270
+ noteRefreshRecovered(creds.alias);
189
271
  return creds;
272
+ }
190
273
  const existing = inflightRefresh.get(creds.alias);
191
274
  if (existing)
192
275
  return existing;
193
- const p = refreshCodexAccount(creds).finally(() => {
276
+ const remembered = refreshFailures.get(creds.alias);
277
+ if (remembered && Date.now() < remembered.retryAt) {
278
+ throw new CodexCredentialsUnavailableError(creds.alias, remembered);
279
+ }
280
+ const p = refreshCodexAccount(creds)
281
+ .then((fresh) => {
282
+ noteRefreshRecovered(creds.alias);
283
+ return fresh;
284
+ })
285
+ .catch((err) => {
286
+ throw new CodexCredentialsUnavailableError(creds.alias, noteRefreshFailure(creds.alias, err));
287
+ })
288
+ .finally(() => {
194
289
  inflightRefresh.delete(creds.alias);
195
290
  });
196
291
  inflightRefresh.set(creds.alias, p);
@@ -113,6 +113,24 @@ export declare function pickClaudeTarget(models: readonly string[], slugs: reado
113
113
  * header, which is what the CLI does for accounts without a workspace.
114
114
  */
115
115
  export declare function extractChatGPTAccountId(idToken: string | undefined): string | null;
116
+ /** True when a chat field survives BOTH translation and the Codex scrub. */
117
+ export declare function chatFieldReachesCodex(field: string): boolean;
118
+ /** Report each chat field that never reaches Codex, once per process. */
119
+ export declare function logUnsupportedChatFields(body: Record<string, unknown>, verbose: boolean): void;
120
+ /**
121
+ * Translate a chat/completions `tool_choice` into the Responses shape.
122
+ *
123
+ * The two APIs agree on the string forms ("auto"/"none"/"required") but not on
124
+ * the forced-tool form: chat/completions nests the name under `function`,
125
+ * Responses takes it FLAT. Forwarding the nested form verbatim makes the Codex
126
+ * backend answer 400 "Missing required parameter: 'tool_choice.name'", which is
127
+ * what every client that forces a tool (Cursor, Continue, Aider) sends. Mirrors
128
+ * translateToolChoice() on the Anthropic path.
129
+ *
130
+ * Anything else passes through untouched: an unknown object is the backend's to
131
+ * reject, not ours to guess at. Pure; exported for tests.
132
+ */
133
+ export declare function toResponsesToolChoice(choice: unknown): unknown;
116
134
  /**
117
135
  * Translate an OpenAI chat/completions request body into a Responses request.
118
136
  *
@@ -141,6 +159,8 @@ export declare function isTerminalResponsesEvent(type: string): boolean;
141
159
  export declare function isFailedResponse(resp: unknown): boolean;
142
160
  /** The upstream message on a failed Responses payload, for the error body. */
143
161
  export declare function failedResponseMessage(resp: unknown): string;
162
+ /** The upstream error code on a failed Responses payload, or null when absent. */
163
+ export declare function failedResponseCode(resp: unknown): string | null;
144
164
  /**
145
165
  * Stateful per-request translator: Responses SSE in, chat/completions out.
146
166
  *
@@ -180,6 +180,46 @@ export function extractChatGPTAccountId(idToken) {
180
180
  return null;
181
181
  }
182
182
  }
183
+ /**
184
+ * Which Responses field each chat/completions field is translated into.
185
+ *
186
+ * Translation is only half the transport: `toCodexSupportedBody` scrubs the
187
+ * translated body again, so a chat field reaches Codex only when its
188
+ * TRANSLATED name also survives that allowlist. `temperature` and `top_p`
189
+ * are translated by name and `max_tokens`/`max_completion_tokens` become
190
+ * `max_output_tokens` — all four are then scrubbed away, so the diagnostic
191
+ * has to report them as dropped. A caller passing `temperature: 0` needs to
192
+ * hear that precisely because the translation step looks like it worked.
193
+ */
194
+ const CHAT_COMPLETIONS_FIELD_TRANSLATIONS = {
195
+ model: 'model',
196
+ messages: 'input',
197
+ tools: 'tools',
198
+ tool_choice: 'tool_choice',
199
+ stream: 'stream',
200
+ reasoning_effort: 'reasoning',
201
+ temperature: 'temperature',
202
+ top_p: 'top_p',
203
+ max_tokens: 'max_output_tokens',
204
+ max_completion_tokens: 'max_output_tokens',
205
+ };
206
+ const loggedUnsupportedChatFields = new Set();
207
+ /** True when a chat field survives BOTH translation and the Codex scrub. */
208
+ export function chatFieldReachesCodex(field) {
209
+ const target = CHAT_COMPLETIONS_FIELD_TRANSLATIONS[field];
210
+ return target !== undefined && CODEX_SUPPORTED_FIELDS.includes(target);
211
+ }
212
+ /** Report each chat field that never reaches Codex, once per process. */
213
+ export function logUnsupportedChatFields(body, verbose) {
214
+ if (!verbose)
215
+ return;
216
+ for (const field of Object.keys(body)) {
217
+ if (chatFieldReachesCodex(field) || loggedUnsupportedChatFields.has(field))
218
+ continue;
219
+ loggedUnsupportedChatFields.add(field);
220
+ console.log(`[dario] codex: dropping unsupported chat field ${field}`);
221
+ }
222
+ }
183
223
  function messageContentToText(content) {
184
224
  if (typeof content === 'string')
185
225
  return content;
@@ -193,6 +233,62 @@ function messageContentToText(content) {
193
233
  }
194
234
  return content == null ? '' : JSON.stringify(content);
195
235
  }
236
+ /** Preserve OpenAI chat text and image_url parts in a Responses user item. */
237
+ function chatContentToResponsesParts(content) {
238
+ if (!Array.isArray(content))
239
+ return [{ type: 'input_text', text: messageContentToText(content) }];
240
+ const parts = [];
241
+ for (const part of content) {
242
+ const p = part;
243
+ if (p?.type === 'text' && typeof p.text === 'string') {
244
+ parts.push({ type: 'input_text', text: p.text });
245
+ continue;
246
+ }
247
+ if (p?.type === 'image_url') {
248
+ const obj = typeof p.image_url === 'object' && p.image_url !== null
249
+ ? p.image_url
250
+ : undefined;
251
+ const imageUrl = typeof p.image_url === 'string' ? p.image_url : obj?.url;
252
+ if (typeof imageUrl === 'string') {
253
+ // `detail` is a fidelity/cost control the Responses input_image part
254
+ // takes too; dropping it silently downgrades a low-detail request to
255
+ // the backend's automatic behaviour and changes image-token cost.
256
+ const detail = obj?.detail;
257
+ parts.push({
258
+ type: 'input_image',
259
+ image_url: imageUrl,
260
+ ...(detail === 'auto' || detail === 'low' || detail === 'high' ? { detail } : {}),
261
+ });
262
+ }
263
+ }
264
+ }
265
+ return parts;
266
+ }
267
+ /**
268
+ * Translate a chat/completions `tool_choice` into the Responses shape.
269
+ *
270
+ * The two APIs agree on the string forms ("auto"/"none"/"required") but not on
271
+ * the forced-tool form: chat/completions nests the name under `function`,
272
+ * Responses takes it FLAT. Forwarding the nested form verbatim makes the Codex
273
+ * backend answer 400 "Missing required parameter: 'tool_choice.name'", which is
274
+ * what every client that forces a tool (Cursor, Continue, Aider) sends. Mirrors
275
+ * translateToolChoice() on the Anthropic path.
276
+ *
277
+ * Anything else passes through untouched: an unknown object is the backend's to
278
+ * reject, not ours to guess at. Pure; exported for tests.
279
+ */
280
+ export function toResponsesToolChoice(choice) {
281
+ if (choice == null || typeof choice !== 'object')
282
+ return choice;
283
+ const c = choice;
284
+ if (c.type !== 'function')
285
+ return choice;
286
+ const name = c.function?.name;
287
+ if (typeof name === 'string' && name.length > 0) {
288
+ return { type: 'function', name };
289
+ }
290
+ return choice;
291
+ }
196
292
  /**
197
293
  * Translate an OpenAI chat/completions request body into a Responses request.
198
294
  *
@@ -239,7 +335,7 @@ export function chatCompletionsToResponses(body) {
239
335
  }
240
336
  continue;
241
337
  }
242
- input.push({ role: 'user', content: [{ type: 'input_text', text: messageContentToText(m.content) }] });
338
+ input.push({ role: 'user', content: chatContentToResponsesParts(m.content) });
243
339
  }
244
340
  const out = {
245
341
  model: String(body.model ?? ''),
@@ -259,7 +355,7 @@ export function chatCompletionsToResponses(body) {
259
355
  }));
260
356
  }
261
357
  if (body.tool_choice != null)
262
- out.tool_choice = body.tool_choice;
358
+ out.tool_choice = toResponsesToolChoice(body.tool_choice);
263
359
  if (body.temperature != null)
264
360
  out.temperature = body.temperature;
265
361
  if (body.top_p != null)
@@ -297,6 +393,11 @@ export function failedResponseMessage(resp) {
297
393
  const code = e && typeof e.code === 'string' ? e.code : '';
298
394
  return msg || code || 'the Codex backend ended the response as failed';
299
395
  }
396
+ /** The upstream error code on a failed Responses payload, or null when absent. */
397
+ export function failedResponseCode(resp) {
398
+ const e = resp?.error;
399
+ return e && typeof e.code === 'string' && e.code ? e.code : null;
400
+ }
300
401
  /**
301
402
  * Stateful per-request translator: Responses SSE in, chat/completions out.
302
403
  *
@@ -410,6 +511,21 @@ export function createResponsesTranslator(model) {
410
511
  total_tokens: (u.input_tokens ?? 0) + (u.output_tokens ?? 0),
411
512
  };
412
513
  }
514
+ if (failed) {
515
+ // A failed turn must NOT close like a finished one: a
516
+ // `finish_reason:"stop"` frame is, to every OpenAI SDK, a successful
517
+ // empty completion, so the failure disappears entirely. Emit the
518
+ // error frame instead — openai-node's ChatCompletionStream and
519
+ // openai-python both raise on a chunk carrying `error` — then
520
+ // `[DONE]` so the parser terminates rather than waiting.
521
+ return opened(`data: ${JSON.stringify({
522
+ error: {
523
+ message: failedResponseMessage(e.response),
524
+ type: 'server_error',
525
+ code: failedResponseCode(e.response),
526
+ },
527
+ })}\n\ndata: [DONE]\n\n`);
528
+ }
413
529
  return opened(`${frame({}, toolCalls.size > 0 ? 'tool_calls' : 'stop')}data: [DONE]\n\n`);
414
530
  }
415
531
  return null;
@@ -551,6 +667,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
551
667
  report(400, null, false, '');
552
668
  return true;
553
669
  }
670
+ if (!isAnthropic)
671
+ logUnsupportedChatFields(parsed, verbose);
554
672
  const clientWantsStream = parsed.stream === true;
555
673
  const model = String(parsed.model ?? '');
556
674
  // stream is forced: the backend is always streamed and collapsed here.
@@ -560,6 +678,31 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
560
678
  const scrubbed = toCodexSupportedBody(upstreamBody);
561
679
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
562
680
  const abort = new AbortController();
681
+ // Once the client is gone there is nobody left to write to, but the upstream
682
+ // Responses stream keeps generating — and the ChatGPT subscription keeps
683
+ // paying for output nobody will read. Aborting the fetch is what stops the
684
+ // meter; `clientGone` stops us writing to a socket we no longer have.
685
+ // `finished` tells our own teardown apart from a client that walked away,
686
+ // the same distinction proxy.ts makes with res.writableEnded.
687
+ let finished = false;
688
+ let clientGone = false;
689
+ const onClientClose = () => {
690
+ if (finished || clientGone)
691
+ return;
692
+ clientGone = true;
693
+ if (verbose)
694
+ console.log(`[dario] codex client disconnected (${creds.alias}) — aborting upstream`);
695
+ if (!abort.signal.aborted)
696
+ abort.abort();
697
+ };
698
+ res.on('close', onClientClose);
699
+ // Every streamed write goes through here: after a disconnect the socket is
700
+ // gone and writing to it is wasted at best, an EPIPE at worst.
701
+ const write = (chunk) => { if (!clientGone)
702
+ res.write(chunk); };
703
+ // Usage seen so far, so a stream the client abandoned still reports what the
704
+ // subscription already spent. Populated once the translators exist.
705
+ let usageSoFar = () => null;
563
706
  const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
564
707
  try {
565
708
  if (verbose)
@@ -574,6 +717,15 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
574
717
  const detail = await upstream.text().catch(() => '');
575
718
  if (verbose)
576
719
  console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
720
+ // The client can hang up while this buffered error body is being read:
721
+ // an already-arrived response resolves regardless of the abort, so the
722
+ // signal never rejects here. With nobody left to serve, a decline would
723
+ // hand the chain a request whose only remaining effect is billing the
724
+ // next provider for output no one reads.
725
+ if (clientGone) {
726
+ report(499, usageSoFar(), clientWantsStream, model);
727
+ return true;
728
+ }
577
729
  // "Not right now" — a rate limit or an upstream fault — is the caller's
578
730
  // cue to fail over, not something to hand the client. A 4xx that is our
579
731
  // own fault (a bad body, an unsupported parameter) is NOT: failing over
@@ -608,6 +760,11 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
608
760
  const antAssembler = isAnthropic ? createAnthropicMessageAssembler() : null;
609
761
  let terminalResponse = null;
610
762
  let anthropicFailed = false;
763
+ // Reported on the abandoned-client exit as well as the normal one, so a
764
+ // stream the client walked away from still shows what it already spent.
765
+ usageSoFar = isAnthropic
766
+ ? () => { const tr = terminalResponse; return tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null; }
767
+ : () => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; };
611
768
  const emitAnthropic = (events) => {
612
769
  for (const ev of events) {
613
770
  const t = ev.type ?? '';
@@ -623,7 +780,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
623
780
  antAssembler.push(produced);
624
781
  for (const out of produced) {
625
782
  if (clientWantsStream)
626
- res.write(formatResponsesAnthropicSSE(out));
783
+ write(formatResponsesAnthropicSSE(out));
627
784
  }
628
785
  }
629
786
  };
@@ -645,6 +802,10 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
645
802
  const { done, value } = await reader.read();
646
803
  if (done)
647
804
  break;
805
+ // The abort above normally makes this read reject; a stream that
806
+ // ignores its signal must not keep us looping for a client that left.
807
+ if (clientGone)
808
+ break;
648
809
  const decoded = decoder.decode(value, { stream: true });
649
810
  if (isAnthropic) {
650
811
  // The parser owns its own partial-frame buffering.
@@ -658,7 +819,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
658
819
  for (const line of lines) {
659
820
  const out = translator.chunk(line);
660
821
  if (out && clientWantsStream)
661
- res.write(out);
822
+ write(out);
662
823
  }
663
824
  }
664
825
  }
@@ -671,9 +832,17 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
671
832
  else if (buffered.length > 0) {
672
833
  const out = translator.chunk(buffered);
673
834
  if (out && clientWantsStream)
674
- res.write(out);
835
+ write(out);
675
836
  }
676
837
  }
838
+ // The client left mid-stream: upstream is aborted, the socket is gone and
839
+ // there is nothing left to collapse or send. 499 is nginx's "client closed
840
+ // request" — the request happened and cost tokens, so it is reported, but
841
+ // it is neither an upstream failure nor a decline.
842
+ if (clientGone) {
843
+ report(499, usageSoFar(), clientWantsStream, model);
844
+ return true;
845
+ }
677
846
  // A stream that ended in failure must not collapse into a 200 with empty
678
847
  // content: to a non-streaming client that is indistinguishable from a model
679
848
  // that chose to say nothing. Streaming clients already saw the terminal
@@ -686,6 +855,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
686
855
  if (verbose)
687
856
  console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
688
857
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
858
+ finished = true;
689
859
  res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
690
860
  report(502, null, clientWantsStream, model);
691
861
  return true;
@@ -693,7 +863,8 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
693
863
  if (isAnthropic) {
694
864
  if (clientWantsStream) {
695
865
  for (const out of antTranslator.end())
696
- res.write(formatResponsesAnthropicSSE(out));
866
+ write(formatResponsesAnthropicSSE(out));
867
+ finished = true;
697
868
  res.end();
698
869
  }
699
870
  else {
@@ -703,10 +874,12 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
703
874
  ...securityHeaders,
704
875
  });
705
876
  antAssembler.push(antTranslator.end());
877
+ finished = true;
706
878
  res.end(JSON.stringify(antAssembler.message(model)));
707
879
  }
708
880
  }
709
881
  else if (clientWantsStream) {
882
+ finished = true;
710
883
  res.end();
711
884
  }
712
885
  else {
@@ -715,18 +888,13 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
715
888
  'Access-Control-Allow-Origin': corsOrigin,
716
889
  ...securityHeaders,
717
890
  });
891
+ finished = true;
718
892
  res.end(JSON.stringify(translator.complete()));
719
893
  }
720
894
  // Token usage rides the terminal Responses event on either shape. A stream
721
895
  // that failed upstream still ended as 200 on the wire (the client saw the
722
896
  // failure event); analytics must count it as the 502 it was.
723
- {
724
- const tr = terminalResponse;
725
- const usage = isAnthropic
726
- ? (tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null)
727
- : (() => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; })();
728
- report(upstreamFailed ? 502 : 200, usage, clientWantsStream, model);
729
- }
897
+ report(upstreamFailed ? 502 : 200, usageSoFar(), clientWantsStream, model);
730
898
  return true;
731
899
  }
732
900
  catch (err) {
@@ -735,6 +903,16 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
735
903
  const detail = err instanceof Error ? err.message : String(err);
736
904
  if (verbose)
737
905
  console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
906
+ // A client that hung up aborted this fetch itself. That is not the
907
+ // subscription declining — failing over would re-bill a request nobody is
908
+ // reading — and there is no socket left to write a 502 to. This has to be
909
+ // checked BEFORE the decline branch: an abort that fires before the first
910
+ // byte still leaves headersSent false, which would otherwise look exactly
911
+ // like an unreachable backend and trigger a pointless failover.
912
+ if (clientGone) {
913
+ report(499, usageSoFar(), clientWantsStream, model);
914
+ return true;
915
+ }
738
916
  // A transport failure before any byte was written is the same "not right
739
917
  // now" as a 429: DNS, a refused connection, a reset socket or our own
740
918
  // upstream timeout all mean this subscription did not answer, and none of
@@ -756,9 +934,11 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
756
934
  }
757
935
  if (!res.headersSent) {
758
936
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
937
+ finished = true;
759
938
  res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
760
939
  }
761
940
  else {
941
+ finished = true;
762
942
  try {
763
943
  res.end();
764
944
  }
@@ -769,5 +949,6 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
769
949
  }
770
950
  finally {
771
951
  clearTimeout(timeout);
952
+ res.removeListener('close', onClientClose);
772
953
  }
773
954
  }
@@ -9,6 +9,21 @@ export interface CodexTokens {
9
9
  expiresAt: number;
10
10
  idToken?: string;
11
11
  }
12
+ /**
13
+ * A refresh the token endpoint REJECTED, carrying the two facts the caller
14
+ * needs to act on it: the HTTP status (a 400/401 `invalid_grant` is a dead
15
+ * refresh token and re-asking will never help; a 5xx or a transport failure
16
+ * may clear on its own) and a short snippet of the body for the operator.
17
+ *
18
+ * The message keeps its old shape so anything matching on the text still
19
+ * matches; `status`/`bodySnippet` exist so codex-accounts.ts can record the
20
+ * failure without re-parsing prose. `status` is 0 when no response arrived.
21
+ */
22
+ export declare class CodexRefreshError extends Error {
23
+ readonly status: number;
24
+ readonly bodySnippet: string;
25
+ constructor(status: number, bodySnippet: string);
26
+ }
12
27
  export declare function generateCodexPKCE(): {
13
28
  codeVerifier: string;
14
29
  codeChallenge: string;
@@ -33,6 +33,26 @@ export const CODEX_AUTHORIZE_URL = process.env.DARIO_CODEX_AUTHORIZE_URL || 'htt
33
33
  export const CODEX_TOKEN_URL = process.env.DARIO_CODEX_TOKEN_URL || 'https://auth.openai.com/oauth/token';
34
34
  export const CODEX_REDIRECT_URI = process.env.DARIO_CODEX_REDIRECT_URI || 'http://localhost:1455/auth/callback';
35
35
  export const CODEX_SCOPE = 'openid profile email offline_access';
36
+ /**
37
+ * A refresh the token endpoint REJECTED, carrying the two facts the caller
38
+ * needs to act on it: the HTTP status (a 400/401 `invalid_grant` is a dead
39
+ * refresh token and re-asking will never help; a 5xx or a transport failure
40
+ * may clear on its own) and a short snippet of the body for the operator.
41
+ *
42
+ * The message keeps its old shape so anything matching on the text still
43
+ * matches; `status`/`bodySnippet` exist so codex-accounts.ts can record the
44
+ * failure without re-parsing prose. `status` is 0 when no response arrived.
45
+ */
46
+ export class CodexRefreshError extends Error {
47
+ status;
48
+ bodySnippet;
49
+ constructor(status, bodySnippet) {
50
+ super(`Codex token refresh failed (${status}): ${bodySnippet}`);
51
+ this.name = 'CodexRefreshError';
52
+ this.status = status;
53
+ this.bodySnippet = bodySnippet;
54
+ }
55
+ }
36
56
  function base64url(buf) {
37
57
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
38
58
  }
@@ -108,7 +128,7 @@ export async function refreshCodexAccessToken(refreshToken) {
108
128
  });
109
129
  if (!res.ok) {
110
130
  const body = await res.text().catch(() => '');
111
- throw new Error(`Codex token refresh failed (${res.status}): ${body.slice(0, 200)}`);
131
+ throw new CodexRefreshError(res.status, body.slice(0, 200));
112
132
  }
113
133
  return parseTokenResponse(await res.json());
114
134
  }
package/dist/proxy.js CHANGED
@@ -22,7 +22,7 @@ import { createTokenBucket } from './rate-limit.js';
22
22
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
23
23
  import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
24
24
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
25
- import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
25
+ import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
26
26
  import { route as routeProvider } from './provider-adapter.js';
27
27
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
28
28
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
@@ -2190,6 +2190,11 @@ export async function startProxy(opts = {}) {
2190
2190
  needsRefresh: codexAccountNeedsRefresh(a),
2191
2191
  models: peekCodexModelSlugs(a.alias) ?? [],
2192
2192
  requestCount: codexRequestCounts.get(a.alias) ?? 0,
2193
+ // Why an account that LOOKS present is serving nothing: the last
2194
+ // token-endpoint rejection, remembered in-process for a minute
2195
+ // (DEV-179a412f). Read from memory only — a status read never spends
2196
+ // or exposes a credential, so there is no token in {at,status,message}.
2197
+ lastRefreshError: getCodexRefreshFailure(a.alias),
2193
2198
  }));
2194
2199
  res.writeHead(200, JSON_HEADERS);
2195
2200
  res.end(JSON.stringify({
@@ -2528,6 +2533,26 @@ export async function startProxy(opts = {}) {
2528
2533
  reason: ALL_PROVIDERS_RATE_LIMITED,
2529
2534
  }));
2530
2535
  };
2536
+ /**
2537
+ * The codex route was named — by a listed slug or a `codex:` prefix —
2538
+ * but that account's credentials could not be refreshed (DEV-179a412f).
2539
+ *
2540
+ * Before this the throw was swallowed by the JSON-peek `catch` around the
2541
+ * routing block, so the route silently disappeared and the request fell to
2542
+ * the Claude path, where an empty pool answered "No account configured …
2543
+ * run `dario login`" — pointing the operator at the wrong subscription
2544
+ * entirely. Answer about the CODEX account instead, in the client's own
2545
+ * wire shape.
2546
+ */
2547
+ const writeCodexCredentialsUnavailable = (err, shape) => {
2548
+ const message = `Codex account "${err.alias}" cannot refresh its token (${err.status || 'no response'} from the OpenAI token endpoint). `
2549
+ + `This is the ChatGPT subscription, not the Claude one — re-add it with \`dario codex add ${err.alias}\`.`;
2550
+ console.log(`[dario] #${requestCount} codex account ${err.alias} credentials unavailable — 503`);
2551
+ res.writeHead(503, { ...JSON_HEADERS, 'x-dario-upstream-rejection': 'credential_rejected' });
2552
+ res.end(JSON.stringify(shape === 'anthropic'
2553
+ ? { type: 'error', error: { type: 'authentication_error', message }, account: err.alias }
2554
+ : { error: message, account: err.alias }));
2555
+ };
2531
2556
  const selectPoolAccount = () => {
2532
2557
  if (upstreamApiKey) {
2533
2558
  // Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
@@ -2781,11 +2806,28 @@ export async function startProxy(opts = {}) {
2781
2806
  // an account added while the proxy runs routes on the very next
2782
2807
  // request; the absent answer is cached ~30s, so an idle proxy with
2783
2808
  // no codex account is not stat-ing the filesystem per request.
2809
+ // Credentials that exist on disk but could not be refreshed. Kept
2810
+ // separately from `codexCreds` so the decision below can still see
2811
+ // that this request was BOUND for codex and answer about that
2812
+ // account, rather than letting the throw escape into the JSON-peek
2813
+ // catch below and disappear (DEV-179a412f).
2814
+ let codexUnavailable = null;
2784
2815
  if (await hasAnyCodexAccount()) {
2785
2816
  const stored = await selectCodexAccount();
2786
2817
  if (stored) {
2787
- codexCreds = await getFreshCodexAccount(stored);
2788
- codexModels = await getCodexModelSlugs(codexCreds);
2818
+ try {
2819
+ codexCreds = await getFreshCodexAccount(stored);
2820
+ codexModels = await getCodexModelSlugs(codexCreds);
2821
+ }
2822
+ catch (err) {
2823
+ if (!(err instanceof CodexCredentialsUnavailableError))
2824
+ throw err;
2825
+ codexUnavailable = err;
2826
+ // Cached slugs only — never an upstream call with a credential
2827
+ // already known to be bad. Enough to answer the routing
2828
+ // question "was this request the subscription's to serve".
2829
+ codexModels = peekCodexModelSlugs(stored.alias) ?? [];
2830
+ }
2789
2831
  }
2790
2832
  }
2791
2833
  const decision = routeProvider({
@@ -2793,11 +2835,16 @@ export async function startProxy(opts = {}) {
2793
2835
  model: rawModel,
2794
2836
  forcedProvider,
2795
2837
  hasOpenAIBackend: openaiBackend !== null,
2796
- hasCodexAccount: codexCreds !== null,
2838
+ hasCodexAccount: codexCreds !== null || codexUnavailable !== null,
2797
2839
  codexModels,
2798
2840
  poolFallbackModel: requestPoolFallbackModel,
2799
2841
  poolSize: pool.size,
2800
2842
  });
2843
+ if (rawModel && codexUnavailable && decision.provider === 'codex') {
2844
+ requestCount++;
2845
+ writeCodexCredentialsUnavailable(codexUnavailable, isOpenAI ? 'openai' : 'anthropic');
2846
+ return;
2847
+ }
2801
2848
  if (rawModel && codexCreds && decision.provider === 'codex') {
2802
2849
  if (verbose) {
2803
2850
  console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → codex account ${codexCreds.alias}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.21",
3
+ "version": "6.0.23",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {