@askalf/dario 5.5.86 → 5.5.87

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.
@@ -76,6 +76,22 @@ export declare function extractChatGPTAccountId(idToken: string | undefined): st
76
76
  * the user would expect to find in their history
77
77
  */
78
78
  export declare function chatCompletionsToResponses(body: Record<string, unknown>): Record<string, unknown>;
79
+ /** Which wire shape the CLIENT spoke; selects both translation ends. */
80
+ export type CodexRequestShape = 'openai' | 'anthropic';
81
+ /**
82
+ * The Responses stream has THREE terminal events, not two. Missing
83
+ * `response.failed` is not inert in either direction: on the chat path the
84
+ * stream would end with no finish frame and no `[DONE]` (an SDK parser waits
85
+ * forever or throws), and on the Anthropic path the non-streaming collapse
86
+ * would find no terminal response and hand back a well-formed EMPTY success —
87
+ * a failure that reads as a normal, empty turn. Found by review on dario#1141;
88
+ * the chat-path half was the same bug, pre-existing.
89
+ */
90
+ export declare function isTerminalResponsesEvent(type: string): boolean;
91
+ /** True when a terminal Responses payload describes a FAILURE rather than a turn. */
92
+ export declare function isFailedResponse(resp: unknown): boolean;
93
+ /** The upstream message on a failed Responses payload, for the error body. */
94
+ export declare function failedResponseMessage(resp: unknown): string;
79
95
  /**
80
96
  * Stateful per-request translator: Responses SSE in, chat/completions out.
81
97
  *
@@ -93,15 +109,31 @@ export declare function chatCompletionsToResponses(body: Record<string, unknown>
93
109
  export declare function createResponsesTranslator(model: string): {
94
110
  /** Feed one raw SSE line. Returns the line to forward, or null. */
95
111
  chunk(line: string): string | null;
112
+ /** True when the upstream stream terminated as a FAILURE. */
113
+ didFail(): boolean;
96
114
  /** Everything seen so far, as one non-streaming chat.completion body. */
97
115
  complete(): Record<string, unknown>;
98
116
  };
99
117
  export declare function buildCodexHeaders(creds: CodexAccountCredentials): Record<string, string>;
100
118
  /**
101
- * Serve a /v1/chat/completions request from a stored Codex account.
119
+ * Serve a request from a stored Codex account, in either client wire shape.
120
+ *
121
+ * `shape` picks the two translation ends; everything between — headers, the
122
+ * timeout, the upstream POST, the read loop, the error paths — is shared,
123
+ * because only the translation differs between an OpenAI-shape and an
124
+ * Anthropic-shape client:
125
+ *
126
+ * openai chat/completions ─┐ ┌─ chat.completion(.chunk)
127
+ * ├→ Responses (codex) ┤
128
+ * anthropic messages ─────────┘ └─ Anthropic SSE / Message
129
+ *
130
+ * dario always asks the backend for a stream and collapses it here when the
131
+ * client didn't want one, so `stream: true` is forced on the way out for both
132
+ * shapes. For the Anthropic shape the non-streaming body is rebuilt from the
133
+ * `response` object carried by the terminal `response.completed` event.
102
134
  *
103
135
  * `fetchImpl` is injectable so the translation and header construction are
104
136
  * testable without network (test/codex-backend.mjs), matching the pattern
105
137
  * test/codex-oauth.mjs already uses.
106
138
  */
107
- export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch): Promise<void>;
139
+ export declare function forwardToCodex(req: IncomingMessage, res: ServerResponse, body: Buffer, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, shape?: CodexRequestShape, fetchImpl?: typeof fetch): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { anthropicToResponsesRequest, createResponsesSSEParser, formatResponsesAnthropicSSE, responsesStreamToAnthropicSSE, responsesToAnthropicResponse, } from './anthropic-responses-translate.js';
1
2
  export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
2
3
  /** Originator string the codex CLI identifies itself with. */
3
4
  const CODEX_ORIGINATOR = 'codex_cli_rs';
@@ -207,6 +208,32 @@ export function chatCompletionsToResponses(body) {
207
208
  out.reasoning = { effort: body.reasoning_effort };
208
209
  return out;
209
210
  }
211
+ /**
212
+ * The Responses stream has THREE terminal events, not two. Missing
213
+ * `response.failed` is not inert in either direction: on the chat path the
214
+ * stream would end with no finish frame and no `[DONE]` (an SDK parser waits
215
+ * forever or throws), and on the Anthropic path the non-streaming collapse
216
+ * would find no terminal response and hand back a well-formed EMPTY success —
217
+ * a failure that reads as a normal, empty turn. Found by review on dario#1141;
218
+ * the chat-path half was the same bug, pre-existing.
219
+ */
220
+ export function isTerminalResponsesEvent(type) {
221
+ return type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed';
222
+ }
223
+ /** True when a terminal Responses payload describes a FAILURE rather than a turn. */
224
+ export function isFailedResponse(resp) {
225
+ if (!resp || typeof resp !== 'object')
226
+ return false;
227
+ const r = resp;
228
+ return r.status === 'failed' || (r.error !== null && r.error !== undefined);
229
+ }
230
+ /** The upstream message on a failed Responses payload, for the error body. */
231
+ export function failedResponseMessage(resp) {
232
+ const e = resp?.error;
233
+ const msg = e && typeof e.message === 'string' ? e.message : '';
234
+ const code = e && typeof e.code === 'string' ? e.code : '';
235
+ return msg || code || 'the Codex backend ended the response as failed';
236
+ }
210
237
  /**
211
238
  * Stateful per-request translator: Responses SSE in, chat/completions out.
212
239
  *
@@ -229,6 +256,7 @@ export function createResponsesTranslator(model) {
229
256
  const toolCalls = new Map();
230
257
  let nextToolIndex = 0;
231
258
  let roleSent = false;
259
+ let failed = false;
232
260
  const frame = (delta, finish) => `data: ${JSON.stringify({
233
261
  id,
234
262
  object: 'chat.completion.chunk',
@@ -307,7 +335,9 @@ export function createResponsesTranslator(model) {
307
335
  acc.args += d;
308
336
  return opened(frame({ tool_calls: [{ index: acc.index, function: { arguments: d } }] }, null));
309
337
  }
310
- if (type === 'response.completed' || type === 'response.incomplete') {
338
+ if (isTerminalResponsesEvent(type)) {
339
+ if (type === 'response.failed' || isFailedResponse(e.response))
340
+ failed = true;
311
341
  const r = e.response;
312
342
  const u = r?.usage;
313
343
  if (u) {
@@ -321,6 +351,10 @@ export function createResponsesTranslator(model) {
321
351
  }
322
352
  return null;
323
353
  },
354
+ /** True when the upstream stream terminated as a FAILURE. */
355
+ didFail() {
356
+ return failed;
357
+ },
324
358
  /** Everything seen so far, as one non-streaming chat.completion body. */
325
359
  complete() {
326
360
  const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
@@ -357,26 +391,49 @@ export function buildCodexHeaders(creds) {
357
391
  return headers;
358
392
  }
359
393
  /**
360
- * Serve a /v1/chat/completions request from a stored Codex account.
394
+ * Serve a request from a stored Codex account, in either client wire shape.
395
+ *
396
+ * `shape` picks the two translation ends; everything between — headers, the
397
+ * timeout, the upstream POST, the read loop, the error paths — is shared,
398
+ * because only the translation differs between an OpenAI-shape and an
399
+ * Anthropic-shape client:
400
+ *
401
+ * openai chat/completions ─┐ ┌─ chat.completion(.chunk)
402
+ * ├→ Responses (codex) ┤
403
+ * anthropic messages ─────────┘ └─ Anthropic SSE / Message
404
+ *
405
+ * dario always asks the backend for a stream and collapses it here when the
406
+ * client didn't want one, so `stream: true` is forced on the way out for both
407
+ * shapes. For the Anthropic shape the non-streaming body is rebuilt from the
408
+ * `response` object carried by the terminal `response.completed` event.
361
409
  *
362
410
  * `fetchImpl` is injectable so the translation and header construction are
363
411
  * testable without network (test/codex-backend.mjs), matching the pattern
364
412
  * test/codex-oauth.mjs already uses.
365
413
  */
366
- export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch) {
414
+ export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch) {
367
415
  void req;
416
+ const isAnthropic = shape === 'anthropic';
417
+ // An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
418
+ // is {error}. A client SDK reads its own shape, so errors follow the request.
419
+ const errBody = (message, extra = {}) => JSON.stringify(isAnthropic
420
+ ? { type: 'error', error: { type: 'api_error', message }, ...extra }
421
+ : { error: message, ...extra });
368
422
  let parsed;
369
423
  try {
370
424
  parsed = JSON.parse(body.toString());
371
425
  }
372
426
  catch {
373
427
  res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
374
- res.end(JSON.stringify({ error: 'Codex backend requires a JSON chat/completions body' }));
428
+ res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
375
429
  return;
376
430
  }
377
431
  const clientWantsStream = parsed.stream === true;
378
432
  const model = String(parsed.model ?? '');
379
- const upstreamBody = chatCompletionsToResponses(parsed);
433
+ // stream is forced: the backend is always streamed and collapsed here.
434
+ const upstreamBody = isAnthropic
435
+ ? { ...anthropicToResponsesRequest(parsed, model), stream: true }
436
+ : chatCompletionsToResponses(parsed);
380
437
  const target = `${CODEX_BACKEND_BASE_URL.replace(/\/$/, '')}/responses`;
381
438
  const abort = new AbortController();
382
439
  const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
@@ -394,14 +451,33 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
394
451
  if (verbose)
395
452
  console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
396
453
  res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
397
- res.end(JSON.stringify({
398
- error: 'Upstream Codex backend error',
399
- status: upstream.status,
400
- account: creds.alias,
401
- }));
454
+ res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
402
455
  return;
403
456
  }
404
- const translator = createResponsesTranslator(model);
457
+ // OpenAI shape: one stateful line-in/line-out translator (unchanged).
458
+ // Anthropic shape: parse the typed Responses event stream, then map events
459
+ // to Anthropic SSE, keeping the terminal `response` for the collapse.
460
+ const translator = isAnthropic ? null : createResponsesTranslator(model);
461
+ const sseParser = isAnthropic ? createResponsesSSEParser() : null;
462
+ const antTranslator = isAnthropic ? responsesStreamToAnthropicSSE({ requestModel: model }) : null;
463
+ let terminalResponse = null;
464
+ let anthropicFailed = false;
465
+ const emitAnthropic = (events) => {
466
+ for (const ev of events) {
467
+ const t = ev.type ?? '';
468
+ if (isTerminalResponsesEvent(t)) {
469
+ const r = ev.response;
470
+ if (r)
471
+ terminalResponse = r;
472
+ if (t === 'response.failed')
473
+ anthropicFailed = true;
474
+ }
475
+ for (const out of antTranslator.push(ev)) {
476
+ if (clientWantsStream)
477
+ res.write(formatResponsesAnthropicSSE(out));
478
+ }
479
+ }
480
+ };
405
481
  if (clientWantsStream) {
406
482
  res.writeHead(200, {
407
483
  'Content-Type': 'text/event-stream',
@@ -420,7 +496,13 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
420
496
  const { done, value } = await reader.read();
421
497
  if (done)
422
498
  break;
423
- buffered += decoder.decode(value, { stream: true });
499
+ const decoded = decoder.decode(value, { stream: true });
500
+ if (isAnthropic) {
501
+ // The parser owns its own partial-frame buffering.
502
+ emitAnthropic(sseParser.push(decoded));
503
+ continue;
504
+ }
505
+ buffered += decoded;
424
506
  // SSE frames are newline-delimited; keep the trailing partial line.
425
507
  const lines = buffered.split('\n');
426
508
  buffered = lines.pop() ?? '';
@@ -434,13 +516,46 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
434
516
  finally {
435
517
  reader.releaseLock();
436
518
  }
437
- if (buffered.length > 0) {
519
+ if (isAnthropic) {
520
+ emitAnthropic(sseParser.flush());
521
+ }
522
+ else if (buffered.length > 0) {
438
523
  const out = translator.chunk(buffered);
439
524
  if (out && clientWantsStream)
440
525
  res.write(out);
441
526
  }
442
527
  }
443
- if (clientWantsStream) {
528
+ // A stream that ended in failure must not collapse into a 200 with empty
529
+ // content: to a non-streaming client that is indistinguishable from a model
530
+ // that chose to say nothing. Streaming clients already saw the terminal
531
+ // event, so only the collapse needs this.
532
+ const upstreamFailed = isAnthropic
533
+ ? (anthropicFailed || isFailedResponse(terminalResponse))
534
+ : translator.didFail();
535
+ if (!clientWantsStream && upstreamFailed) {
536
+ const detail = failedResponseMessage(terminalResponse);
537
+ if (verbose)
538
+ console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
539
+ res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
540
+ res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
541
+ return;
542
+ }
543
+ if (isAnthropic) {
544
+ if (clientWantsStream) {
545
+ for (const out of antTranslator.end())
546
+ res.write(formatResponsesAnthropicSSE(out));
547
+ res.end();
548
+ }
549
+ else {
550
+ res.writeHead(200, {
551
+ 'Content-Type': 'application/json',
552
+ 'Access-Control-Allow-Origin': corsOrigin,
553
+ ...securityHeaders,
554
+ });
555
+ res.end(JSON.stringify(responsesToAnthropicResponse(terminalResponse ?? {}, model)));
556
+ }
557
+ }
558
+ else if (clientWantsStream) {
444
559
  res.end();
445
560
  }
446
561
  else {
@@ -460,7 +575,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
460
575
  console.error(`[dario] codex backend (${creds.alias}) error: ${detail}`);
461
576
  if (!res.headersSent) {
462
577
  res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
463
- res.end(JSON.stringify({ error: 'Upstream Codex backend error', account: creds.alias }));
578
+ res.end(errBody('Upstream Codex backend error', { account: creds.alias }));
464
579
  }
465
580
  else {
466
581
  try {
@@ -64,10 +64,17 @@ export interface ProviderAdapter {
64
64
  * subscription even when an API-key backend is also configured; anything not
65
65
  * listed — `gpt-4o` and friends — is untouched and still lands on that backend.
66
66
  *
67
- * OpenAI-path only: the ChatGPT backend speaks Responses, and dario's
68
- * chat/completions⇄Responses translation (codex-backend.ts) is written against
69
- * the OpenAI request shape. An Anthropic-shape /v1/messages request would need
70
- * a second translation that doesn't exist, so it stays with Claude.
67
+ * Serves BOTH request shapes. The ChatGPT backend speaks Responses, and dario
68
+ * now owns a translation for each side of it: chat/completions⇄Responses in
69
+ * codex-backend.ts, and Messages⇄Responses in anthropic-responses-translate.ts
70
+ * (dario#1141). So an Anthropic-shape /v1/messages request naming a listed slug
71
+ * is served from the subscription too — which is what lets a Claude-shaped
72
+ * harness (Claude Code, or any Anthropic-SDK client) run on a ChatGPT plan.
73
+ *
74
+ * There is deliberately no path guard: the claim is driven by the MODEL, and a
75
+ * Claude model is never in `codexModels`, so Claude traffic on either path is
76
+ * untouched. The `openai` adapter below keeps its OpenAI-path guard — the
77
+ * API-key backend has no Messages translation.
71
78
  */
72
79
  export declare const codexAdapter: ProviderAdapter;
73
80
  /**
@@ -31,10 +31,17 @@ import { isCodexModel } from './codex-backend.js';
31
31
  * subscription even when an API-key backend is also configured; anything not
32
32
  * listed — `gpt-4o` and friends — is untouched and still lands on that backend.
33
33
  *
34
- * OpenAI-path only: the ChatGPT backend speaks Responses, and dario's
35
- * chat/completions⇄Responses translation (codex-backend.ts) is written against
36
- * the OpenAI request shape. An Anthropic-shape /v1/messages request would need
37
- * a second translation that doesn't exist, so it stays with Claude.
34
+ * Serves BOTH request shapes. The ChatGPT backend speaks Responses, and dario
35
+ * now owns a translation for each side of it: chat/completions⇄Responses in
36
+ * codex-backend.ts, and Messages⇄Responses in anthropic-responses-translate.ts
37
+ * (dario#1141). So an Anthropic-shape /v1/messages request naming a listed slug
38
+ * is served from the subscription too — which is what lets a Claude-shaped
39
+ * harness (Claude Code, or any Anthropic-SDK client) run on a ChatGPT plan.
40
+ *
41
+ * There is deliberately no path guard: the claim is driven by the MODEL, and a
42
+ * Claude model is never in `codexModels`, so Claude traffic on either path is
43
+ * untouched. The `openai` adapter below keeps its OpenAI-path guard — the
44
+ * API-key backend has no Messages translation.
38
45
  */
39
46
  export const codexAdapter = {
40
47
  id: 'codex',
@@ -42,8 +49,6 @@ export const codexAdapter = {
42
49
  claimsPrimary(ctx) {
43
50
  if (!ctx.hasCodexAccount)
44
51
  return false;
45
- if (!ctx.isOpenAIPath)
46
- return false;
47
52
  if (ctx.forcedProvider === 'claude' || ctx.forcedProvider === 'openai')
48
53
  return false;
49
54
  return ctx.forcedProvider === 'codex' || isCodexModel(ctx.model, ctx.codexModels);
package/dist/proxy.js CHANGED
@@ -2498,7 +2498,7 @@ export async function startProxy(opts = {}) {
2498
2498
  // an account added while the proxy runs routes on the very next
2499
2499
  // request; the absent answer is cached ~30s, so an idle proxy with
2500
2500
  // no codex account is not stat-ing the filesystem per request.
2501
- if (isOpenAI && await hasAnyCodexAccount()) {
2501
+ if (await hasAnyCodexAccount()) {
2502
2502
  const stored = await selectCodexAccount();
2503
2503
  if (stored) {
2504
2504
  codexCreds = await getFreshCodexAccount(stored);
@@ -2520,7 +2520,7 @@ export async function startProxy(opts = {}) {
2520
2520
  console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → codex account ${codexCreds.alias}`);
2521
2521
  }
2522
2522
  requestCount++;
2523
- await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose);
2523
+ await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic');
2524
2524
  return;
2525
2525
  }
2526
2526
  if (rawModel && openaiBackend && decision.provider === 'openai') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.5.86",
3
+ "version": "5.5.87",
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": {