@askalf/dario 6.3.0 → 6.5.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/README.md +2 -0
- package/dist/anthropic-responses-translate.d.ts +86 -2
- package/dist/anthropic-responses-translate.js +112 -6
- package/dist/codex-accounts.d.ts +80 -6
- package/dist/codex-accounts.js +177 -2
- package/dist/codex-backend.d.ts +13 -1
- package/dist/codex-backend.js +44 -6
- package/dist/proxy.js +254 -59
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -269,6 +269,8 @@ The tool doesn't know. The backend doesn't know. dario is the seam.
|
|
|
269
269
|
|
|
270
270
|
A ChatGPT Plus or Pro plan is served on **all three** of dario's endpoints: any client that speaks `/v1/chat/completions` or `/v1/responses` can use it (Codex CLI, the OpenAI SDKs, the Agents SDK, your scripts), and so can any client that speaks `/v1/messages` (Claude Code, the Anthropic SDKs, agent runtimes). The harness never needs to know which subscription is behind it — and the symmetry holds: Codex CLI runs on a Claude plan the same way.
|
|
271
271
|
|
|
272
|
+
An Anthropic-shape client that declares Anthropic's hosted `web_search_20260209` tool gets **real web search on the ChatGPT plan** (since 6.4): the plan's own search runs, and the client sees Anthropic's own blocks — `server_tool_use` with the query, `web_search_tool_result` listing the pages searched, the answer with `web_search_result_location` citations. `allowed_domains` and `user_location` carry over, and so does forcing the search with `tool_choice`; `blocked_domains` and `max_uses` do not.
|
|
273
|
+
|
|
272
274
|
```bash
|
|
273
275
|
dario add altman # prints an authorize URL; paste the redirect URL back
|
|
274
276
|
dario codex list
|
|
@@ -216,9 +216,33 @@ export interface ResponsesFunctionTool {
|
|
|
216
216
|
parameters: Record<string, unknown>;
|
|
217
217
|
strict?: boolean | null;
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The Responses hosted web-search tool (v6.4). `filters.allowed_domains` and
|
|
221
|
+
* `user_location` are the two Anthropic web-search options it can carry;
|
|
222
|
+
* `blocked_domains` and `max_uses` have no equivalent and are dropped.
|
|
223
|
+
*/
|
|
224
|
+
export interface ResponsesWebSearchTool {
|
|
225
|
+
type: 'web_search';
|
|
226
|
+
filters?: {
|
|
227
|
+
allowed_domains?: string[];
|
|
228
|
+
};
|
|
229
|
+
user_location?: {
|
|
230
|
+
type: 'approximate';
|
|
231
|
+
city?: string;
|
|
232
|
+
region?: string;
|
|
233
|
+
country?: string;
|
|
234
|
+
timezone?: string;
|
|
235
|
+
};
|
|
236
|
+
search_context_size?: 'low' | 'medium' | 'high';
|
|
237
|
+
}
|
|
238
|
+
export type ResponsesTool = ResponsesFunctionTool | ResponsesWebSearchTool;
|
|
219
239
|
export type ResponsesToolChoice = 'auto' | 'none' | 'required' | {
|
|
220
240
|
type: 'function';
|
|
221
241
|
name: string;
|
|
242
|
+
}
|
|
243
|
+
/** Force the hosted web search (the backend accepts `web_search` here, not a function name). */
|
|
244
|
+
| {
|
|
245
|
+
type: 'web_search';
|
|
222
246
|
};
|
|
223
247
|
export interface ResponsesReasoningConfig {
|
|
224
248
|
/**
|
|
@@ -235,7 +259,9 @@ export interface ResponsesRequest {
|
|
|
235
259
|
model: string;
|
|
236
260
|
input: ResponsesInputItem[];
|
|
237
261
|
instructions?: string;
|
|
238
|
-
tools?:
|
|
262
|
+
tools?: ResponsesTool[];
|
|
263
|
+
/** `web_search_call.action.sources` — the searched URLs, which the Anthropic result block needs. */
|
|
264
|
+
include?: string[];
|
|
239
265
|
tool_choice?: ResponsesToolChoice;
|
|
240
266
|
parallel_tool_calls?: boolean;
|
|
241
267
|
reasoning?: ResponsesReasoningConfig;
|
|
@@ -292,7 +318,29 @@ export interface ResponsesReasoningItem {
|
|
|
292
318
|
encrypted_content?: string | null;
|
|
293
319
|
status?: string;
|
|
294
320
|
}
|
|
295
|
-
|
|
321
|
+
/**
|
|
322
|
+
* A hosted web-search step as the backend reports it. `action.type` is
|
|
323
|
+
* `search` (with `query` and, when `include: web_search_call.action.sources`
|
|
324
|
+
* was asked for, `sources`), `open_page` (`url`) or `find_in_page`
|
|
325
|
+
* (`url`, `pattern`) — probed 2026-09-12 on the ChatGPT backend.
|
|
326
|
+
*/
|
|
327
|
+
export interface ResponsesWebSearchCallItem {
|
|
328
|
+
type: 'web_search_call';
|
|
329
|
+
id?: string;
|
|
330
|
+
status?: string;
|
|
331
|
+
action?: {
|
|
332
|
+
type?: string;
|
|
333
|
+
query?: string;
|
|
334
|
+
queries?: string[];
|
|
335
|
+
url?: string;
|
|
336
|
+
pattern?: string;
|
|
337
|
+
sources?: Array<{
|
|
338
|
+
type?: string;
|
|
339
|
+
url?: string;
|
|
340
|
+
}>;
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
export type ResponsesOutputItem = ResponsesMessageItem | ResponsesResponseFunctionCall | ResponsesReasoningItem | ResponsesWebSearchCallItem | {
|
|
296
344
|
type: string;
|
|
297
345
|
[key: string]: unknown;
|
|
298
346
|
};
|
|
@@ -429,6 +477,15 @@ export type ResponsesAnthropicStreamEvent = {
|
|
|
429
477
|
id: string;
|
|
430
478
|
name: string;
|
|
431
479
|
input: Record<string, unknown>;
|
|
480
|
+
} | {
|
|
481
|
+
type: 'server_tool_use';
|
|
482
|
+
id: string;
|
|
483
|
+
name: 'web_search';
|
|
484
|
+
input: Record<string, unknown>;
|
|
485
|
+
} | {
|
|
486
|
+
type: 'web_search_tool_result';
|
|
487
|
+
tool_use_id: string;
|
|
488
|
+
content: AnthropicWebSearchResult[];
|
|
432
489
|
};
|
|
433
490
|
} | {
|
|
434
491
|
type: 'content_block_delta';
|
|
@@ -442,6 +499,9 @@ export type ResponsesAnthropicStreamEvent = {
|
|
|
442
499
|
} | {
|
|
443
500
|
type: 'input_json_delta';
|
|
444
501
|
partial_json: string;
|
|
502
|
+
} | {
|
|
503
|
+
type: 'citations_delta';
|
|
504
|
+
citation: AnthropicWebSearchCitation;
|
|
445
505
|
};
|
|
446
506
|
} | {
|
|
447
507
|
type: 'content_block_stop';
|
|
@@ -461,6 +521,22 @@ export type ResponsesAnthropicStreamEvent = {
|
|
|
461
521
|
} | {
|
|
462
522
|
type: 'message_stop';
|
|
463
523
|
};
|
|
524
|
+
/** One searched page, as Anthropic's `web_search_tool_result` lists them. */
|
|
525
|
+
export interface AnthropicWebSearchResult {
|
|
526
|
+
type: 'web_search_result';
|
|
527
|
+
url: string;
|
|
528
|
+
title: string;
|
|
529
|
+
encrypted_content: string;
|
|
530
|
+
page_age: string | null;
|
|
531
|
+
}
|
|
532
|
+
/** A citation on a text block, as Anthropic's `citations_delta` carries it. */
|
|
533
|
+
export interface AnthropicWebSearchCitation {
|
|
534
|
+
type: 'web_search_result_location';
|
|
535
|
+
url: string;
|
|
536
|
+
title: string;
|
|
537
|
+
cited_text: string;
|
|
538
|
+
encrypted_index: string;
|
|
539
|
+
}
|
|
464
540
|
/**
|
|
465
541
|
* One parsed Responses SSE event — a typed superset of the fields this
|
|
466
542
|
* translator consumes. Every event carries a `type`; the rest are present
|
|
@@ -480,6 +556,14 @@ export interface ResponsesStreamEvent {
|
|
|
480
556
|
summary_index?: number;
|
|
481
557
|
/** Text / argument / reasoning fragment on `*.delta` events. */
|
|
482
558
|
delta?: string;
|
|
559
|
+
/** `output_text.annotation.added`: a url_citation over the item's text. */
|
|
560
|
+
annotation?: {
|
|
561
|
+
type?: string;
|
|
562
|
+
url?: string;
|
|
563
|
+
title?: string;
|
|
564
|
+
start_index?: number;
|
|
565
|
+
end_index?: number;
|
|
566
|
+
};
|
|
483
567
|
/** error event fields. */
|
|
484
568
|
code?: string | null;
|
|
485
569
|
message?: string;
|
|
@@ -288,7 +288,7 @@ function translateAssistantBlocks(blocks) {
|
|
|
288
288
|
out.push(call);
|
|
289
289
|
return out;
|
|
290
290
|
}
|
|
291
|
-
function translateToolChoice(choice) {
|
|
291
|
+
function translateToolChoice(choice, webSearchName) {
|
|
292
292
|
if (!choice || typeof choice !== 'object')
|
|
293
293
|
return undefined;
|
|
294
294
|
switch (choice.type) {
|
|
@@ -299,7 +299,12 @@ function translateToolChoice(choice) {
|
|
|
299
299
|
case 'any':
|
|
300
300
|
return 'required';
|
|
301
301
|
case 'tool':
|
|
302
|
-
// Responses forced form is FLATTENED: {type:'function', name}.
|
|
302
|
+
// Responses forced form is FLATTENED: {type:'function', name}. Forcing
|
|
303
|
+
// the hosted web search is its own shape — `{type:'function', name:
|
|
304
|
+
// 'web_search'}` names a function the request never declared and the
|
|
305
|
+
// backend 400s ("Tool choice 'function' not found in 'tools'").
|
|
306
|
+
if (typeof choice.name === 'string' && choice.name === webSearchName)
|
|
307
|
+
return { type: 'web_search' };
|
|
303
308
|
return typeof choice.name === 'string'
|
|
304
309
|
? { type: 'function', name: choice.name }
|
|
305
310
|
: 'required';
|
|
@@ -361,11 +366,37 @@ export function anthropicToResponsesRequest(body, targetModel, options = {}) {
|
|
|
361
366
|
const instructions = flattenSystem(body.system);
|
|
362
367
|
if (instructions.length > 0)
|
|
363
368
|
out.instructions = instructions;
|
|
369
|
+
// The client's name for the hosted web search, once one is declared, so a
|
|
370
|
+
// forced tool_choice on it translates to the hosted-tool choice.
|
|
371
|
+
let webSearchName;
|
|
364
372
|
if (Array.isArray(body.tools)) {
|
|
365
373
|
const tools = [];
|
|
374
|
+
let webSearch = false;
|
|
366
375
|
for (const tool of body.tools) {
|
|
367
376
|
if (!tool || typeof tool.name !== 'string')
|
|
368
377
|
continue;
|
|
378
|
+
// Anthropic's hosted web search → the backend's hosted web search. The
|
|
379
|
+
// two options both sides speak travel; the rest is dropped. Asking for
|
|
380
|
+
// `action.sources` is what lets the result block name the pages it
|
|
381
|
+
// searched rather than come back empty.
|
|
382
|
+
const t = tool;
|
|
383
|
+
if (typeof t.type === 'string' && t.type.startsWith('web_search') && !webSearch) {
|
|
384
|
+
webSearch = true;
|
|
385
|
+
webSearchName = tool.name;
|
|
386
|
+
const ws = { type: 'web_search' };
|
|
387
|
+
if (Array.isArray(t.allowed_domains) && t.allowed_domains.length > 0)
|
|
388
|
+
ws.filters = { allowed_domains: t.allowed_domains.filter((d) => typeof d === 'string') };
|
|
389
|
+
const loc = t.user_location;
|
|
390
|
+
if (loc && typeof loc === 'object') {
|
|
391
|
+
const l = { type: 'approximate' };
|
|
392
|
+
for (const k of ['city', 'region', 'country', 'timezone'])
|
|
393
|
+
if (typeof loc[k] === 'string')
|
|
394
|
+
l[k] = loc[k];
|
|
395
|
+
ws.user_location = l;
|
|
396
|
+
}
|
|
397
|
+
tools.push(ws);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
369
400
|
if (!tool.input_schema || typeof tool.input_schema !== 'object')
|
|
370
401
|
continue;
|
|
371
402
|
const fn = {
|
|
@@ -379,8 +410,10 @@ export function anthropicToResponsesRequest(body, targetModel, options = {}) {
|
|
|
379
410
|
}
|
|
380
411
|
if (tools.length > 0)
|
|
381
412
|
out.tools = tools;
|
|
413
|
+
if (webSearch)
|
|
414
|
+
out.include = ['web_search_call.action.sources'];
|
|
382
415
|
}
|
|
383
|
-
const toolChoice = translateToolChoice(body.tool_choice);
|
|
416
|
+
const toolChoice = translateToolChoice(body.tool_choice, webSearchName);
|
|
384
417
|
if (toolChoice !== undefined && out.tools)
|
|
385
418
|
out.tool_choice = toolChoice;
|
|
386
419
|
if (body.tool_choice?.disable_parallel_tool_use === true && out.tools) {
|
|
@@ -579,6 +612,10 @@ export function responsesStreamToAnthropicSSE(options = {}) {
|
|
|
579
612
|
const blockByOutputIndex = new Map();
|
|
580
613
|
let sawToolCall = false;
|
|
581
614
|
let syntheticToolSeq = 0;
|
|
615
|
+
/** Text streamed per message item, so a url_citation's indices can be turned into cited_text. */
|
|
616
|
+
const textByOutputIndex = new Map();
|
|
617
|
+
/** server_tool_use ids per web_search_call output index, for the result block. */
|
|
618
|
+
const searchIdByOutputIndex = new Map();
|
|
582
619
|
function ensureStarted(event, events) {
|
|
583
620
|
if (started)
|
|
584
621
|
return;
|
|
@@ -620,6 +657,46 @@ export function responsesStreamToAnthropicSSE(options = {}) {
|
|
|
620
657
|
});
|
|
621
658
|
return index;
|
|
622
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* A hosted web-search step: a `server_tool_use` block opened at
|
|
662
|
+
* output_item.added, its input (query or url) written at output_item.done
|
|
663
|
+
* when the backend reveals the action, then a `web_search_tool_result`
|
|
664
|
+
* block listing the pages the step touched — the searched sources when the
|
|
665
|
+
* request asked for them, the opened page otherwise.
|
|
666
|
+
*/
|
|
667
|
+
function openSearchBlock(item, outputIndex, events) {
|
|
668
|
+
const index = nextIndex++;
|
|
669
|
+
open = { kind: 'search', index, outputIndex };
|
|
670
|
+
blockByOutputIndex.set(outputIndex, { kind: 'search', index });
|
|
671
|
+
const id = strOr(item?.id) || `srvtoolu_responses_${syntheticToolSeq++}`;
|
|
672
|
+
searchIdByOutputIndex.set(outputIndex, id);
|
|
673
|
+
events.push({ type: 'content_block_start', index, content_block: { type: 'server_tool_use', id, name: 'web_search', input: {} } });
|
|
674
|
+
return index;
|
|
675
|
+
}
|
|
676
|
+
function finishSearchBlock(item, outputIndex, events) {
|
|
677
|
+
const ws = (item ?? {});
|
|
678
|
+
const action = ws.action ?? {};
|
|
679
|
+
const input = action.type === 'search' || action.query
|
|
680
|
+
? { query: strOr(action.query) || (Array.isArray(action.queries) ? strOr(action.queries[0]) : '') }
|
|
681
|
+
: action.url ? { url: strOr(action.url), ...(action.pattern ? { pattern: strOr(action.pattern) } : {}) } : {};
|
|
682
|
+
const blk = blockByOutputIndex.get(outputIndex);
|
|
683
|
+
if (blk && blk.kind === 'search') {
|
|
684
|
+
events.push({ type: 'content_block_delta', index: blk.index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(input) } });
|
|
685
|
+
}
|
|
686
|
+
closeOpenBlock(events);
|
|
687
|
+
const results = [];
|
|
688
|
+
const seen = new Set();
|
|
689
|
+
const urls = Array.isArray(action.sources) ? action.sources.map((s) => strOr(s?.url)).filter(Boolean) : action.url ? [strOr(action.url)] : [];
|
|
690
|
+
for (const url of urls) {
|
|
691
|
+
if (seen.has(url))
|
|
692
|
+
continue;
|
|
693
|
+
seen.add(url);
|
|
694
|
+
results.push({ type: 'web_search_result', url, title: '', encrypted_content: '', page_age: null });
|
|
695
|
+
}
|
|
696
|
+
const index = nextIndex++;
|
|
697
|
+
events.push({ type: 'content_block_start', index, content_block: { type: 'web_search_tool_result', tool_use_id: searchIdByOutputIndex.get(outputIndex) ?? '', content: results } });
|
|
698
|
+
events.push({ type: 'content_block_stop', index });
|
|
699
|
+
}
|
|
623
700
|
function openThinkingBlock(outputIndex, events) {
|
|
624
701
|
const index = nextIndex++;
|
|
625
702
|
open = { kind: 'thinking', index, outputIndex };
|
|
@@ -719,12 +796,16 @@ export function responsesStreamToAnthropicSSE(options = {}) {
|
|
|
719
796
|
openToolBlock(event.item, outputIndex, events);
|
|
720
797
|
else if (itemType === 'reasoning')
|
|
721
798
|
openThinkingBlock(outputIndex, events);
|
|
799
|
+
else if (itemType === 'web_search_call')
|
|
800
|
+
openSearchBlock(event.item, outputIndex, events);
|
|
722
801
|
// message → nothing; the text block opens on the first delta.
|
|
723
802
|
break;
|
|
724
803
|
}
|
|
725
804
|
case 'response.output_text.delta': {
|
|
726
805
|
if (typeof event.delta === 'string' && event.delta.length > 0) {
|
|
727
|
-
const
|
|
806
|
+
const oi = numOr(event.output_index, 0);
|
|
807
|
+
const index = ensureTextBlock(oi, events);
|
|
808
|
+
textByOutputIndex.set(oi, (textByOutputIndex.get(oi) ?? '') + event.delta);
|
|
728
809
|
events.push({
|
|
729
810
|
type: 'content_block_delta',
|
|
730
811
|
index,
|
|
@@ -733,6 +814,24 @@ export function responsesStreamToAnthropicSSE(options = {}) {
|
|
|
733
814
|
}
|
|
734
815
|
break;
|
|
735
816
|
}
|
|
817
|
+
case 'response.output_text.annotation.added': {
|
|
818
|
+
// A url_citation over the message text → a citation on the text
|
|
819
|
+
// block, with the cited span cut from what has streamed so far.
|
|
820
|
+
const a = event.annotation;
|
|
821
|
+
if (a && a.type === 'url_citation' && typeof a.url === 'string') {
|
|
822
|
+
const oi = numOr(event.output_index, 0);
|
|
823
|
+
const index = ensureTextBlock(oi, events);
|
|
824
|
+
const text = textByOutputIndex.get(oi) ?? '';
|
|
825
|
+
const from = numOr(a.start_index, 0);
|
|
826
|
+
const to = numOr(a.end_index, from);
|
|
827
|
+
events.push({
|
|
828
|
+
type: 'content_block_delta',
|
|
829
|
+
index,
|
|
830
|
+
delta: { type: 'citations_delta', citation: { type: 'web_search_result_location', url: a.url, title: strOr(a.title), cited_text: text.slice(Math.max(0, from), Math.max(from, to)), encrypted_index: '' } },
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
736
835
|
case 'response.function_call_arguments.delta': {
|
|
737
836
|
if (typeof event.delta === 'string' && event.delta.length > 0) {
|
|
738
837
|
const index = resolveBlock(numOr(event.output_index, 0), 'tool', events);
|
|
@@ -758,6 +857,11 @@ export function responsesStreamToAnthropicSSE(options = {}) {
|
|
|
758
857
|
}
|
|
759
858
|
case 'response.output_item.done': {
|
|
760
859
|
const outputIndex = numOr(event.output_index, 0);
|
|
860
|
+
const itemType = event.item && typeof event.item === 'object' ? event.item.type : undefined;
|
|
861
|
+
if (itemType === 'web_search_call') {
|
|
862
|
+
finishSearchBlock(event.item, outputIndex, events);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
761
865
|
if (open && open.outputIndex === outputIndex)
|
|
762
866
|
closeOpenBlock(events);
|
|
763
867
|
break;
|
|
@@ -861,7 +965,7 @@ export function createAnthropicMessageAssembler() {
|
|
|
861
965
|
}
|
|
862
966
|
else if (e.type === 'content_block_start') {
|
|
863
967
|
blocks[e.index] = { ...e.content_block };
|
|
864
|
-
if (e.content_block.type === 'tool_use')
|
|
968
|
+
if (e.content_block.type === 'tool_use' || e.content_block.type === 'server_tool_use')
|
|
865
969
|
partialJson.set(e.index, '');
|
|
866
970
|
}
|
|
867
971
|
else if (e.type === 'content_block_delta') {
|
|
@@ -873,6 +977,8 @@ export function createAnthropicMessageAssembler() {
|
|
|
873
977
|
b.thinking = String(b.thinking ?? '') + d.thinking;
|
|
874
978
|
else if (d.type === 'input_json_delta')
|
|
875
979
|
partialJson.set(e.index, (partialJson.get(e.index) ?? '') + d.partial_json);
|
|
980
|
+
else if (d.type === 'citations_delta')
|
|
981
|
+
(b.citations = (Array.isArray(b.citations) ? b.citations : [])).push(d.citation);
|
|
876
982
|
}
|
|
877
983
|
else if (e.type === 'message_delta') {
|
|
878
984
|
if (msg) {
|
|
@@ -894,7 +1000,7 @@ export function createAnthropicMessageAssembler() {
|
|
|
894
1000
|
message(fallbackModel) {
|
|
895
1001
|
for (const [i, raw] of partialJson) {
|
|
896
1002
|
const b = blocks[i];
|
|
897
|
-
if (!b || b.type !== 'tool_use')
|
|
1003
|
+
if (!b || (b.type !== 'tool_use' && b.type !== 'server_tool_use'))
|
|
898
1004
|
continue;
|
|
899
1005
|
b.input = safeParseArguments(raw);
|
|
900
1006
|
}
|
package/dist/codex-accounts.d.ts
CHANGED
|
@@ -61,14 +61,88 @@ export declare function _resetCodexRefreshFailuresForTest(): void;
|
|
|
61
61
|
* a misleading "run `dario login`" answer to the client.
|
|
62
62
|
*/
|
|
63
63
|
export declare function getFreshCodexAccount(creds: CodexAccountCredentials): Promise<CodexAccountCredentials>;
|
|
64
|
+
/** Record that `alias` declined, for as long as the upstream asked. */
|
|
65
|
+
export declare function noteCodexDecline(alias: string, retryAfterMs?: number | null): number;
|
|
66
|
+
/** A seat that just served is not rate-limited — clear it. */
|
|
67
|
+
export declare function clearCodexDecline(alias: string): void;
|
|
68
|
+
/** Ms until `alias` is askable again; 0 when it is askable now. */
|
|
69
|
+
export declare function codexCooldownRemainingMs(alias: string): number;
|
|
70
|
+
/** Test seam — forget every cool-down and binding. */
|
|
71
|
+
export declare function _resetCodexPoolForTest(): void;
|
|
72
|
+
/** The alias currently bound to a conversation, or null. */
|
|
73
|
+
export declare function codexStickyAliasFor(key: string | null | undefined): string | null;
|
|
64
74
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
75
|
+
* Move a conversation onto `alias`, the codex mirror of pool.rebindSticky.
|
|
76
|
+
*
|
|
77
|
+
* Selection binds a conversation to the seat it picked; mid-request failover
|
|
78
|
+
* then moves it, and without this the binding still names the seat that just
|
|
79
|
+
* declined — the next turn would read a stale binding, find it cooling, and
|
|
80
|
+
* re-pick from scratch. A null key is accepted so the caller does not have to
|
|
81
|
+
* guard: a request with no hashable first user message has no conversation to
|
|
82
|
+
* bind.
|
|
83
|
+
*/
|
|
84
|
+
export declare function rebindCodexSticky(key: string | null | undefined, alias: string): void;
|
|
85
|
+
/**
|
|
86
|
+
* Choose a ChatGPT seat for this request.
|
|
87
|
+
*
|
|
88
|
+
* Order, most specific first:
|
|
89
|
+
* 1. an explicitly named alias (`x-dario-account`, DARIO_CODEX_ACCOUNT) — a
|
|
90
|
+
* pin is an instruction, so it is honoured even while cooling; the caller
|
|
91
|
+
* asked for that seat and gets its answer, 429 included.
|
|
92
|
+
* 2. the seat this conversation is already bound to, unless it is cooling.
|
|
93
|
+
* 3. the first seat alphabetically that is not cooling — deterministic, so a
|
|
94
|
+
* given conversation lands on the same seat across a restart and keeps its
|
|
95
|
+
* prompt cache.
|
|
96
|
+
* 4. null when every seat is cooling. The caller answers from that rather
|
|
97
|
+
* than spending a request that can only 429 again.
|
|
98
|
+
*/
|
|
99
|
+
export declare function selectCodexAccount(preferredAlias?: string, opts?: {
|
|
100
|
+
stickyKey?: string | null;
|
|
101
|
+
}): Promise<CodexAccountCredentials | null>;
|
|
102
|
+
/**
|
|
103
|
+
* The next askable seat that this request has NOT already tried.
|
|
104
|
+
*
|
|
105
|
+
* Mid-flight failover: a seat that 429s during a request hands the SAME
|
|
106
|
+
* request to a peer rather than failing it. Without this the pool only helps
|
|
107
|
+
* the request AFTER the one that discovered the limit — the discovering
|
|
108
|
+
* request still failed, every time a window rolled over.
|
|
109
|
+
*
|
|
110
|
+
* `tried` is per-request, so a seat already attempted here is never revisited
|
|
111
|
+
* inside the same request even if its cool-down has not landed yet. That is
|
|
112
|
+
* the codex mirror of the Claude pool's selectExcluding, and it is what makes
|
|
113
|
+
* the loop terminate: every pass adds a seat, so it is bounded by pool size.
|
|
114
|
+
*
|
|
115
|
+
* Stickiness is deliberately NOT consulted. The bound seat is the one that
|
|
116
|
+
* just declined; re-offering it would loop, and a conversation whose seat has
|
|
117
|
+
* gone away is better served elsewhere than not at all.
|
|
118
|
+
*/
|
|
119
|
+
export declare function selectCodexAccountExcluding(tried: ReadonlySet<string>): Promise<CodexAccountCredentials | null>;
|
|
120
|
+
/** Every seat is cooling — the fail-fast condition, for the caller's message. */
|
|
121
|
+
/**
|
|
122
|
+
* Are ALL of these aliases cooling, right now?
|
|
123
|
+
*
|
|
124
|
+
* Synchronous on purpose. The provider-wide cool-down is written from this
|
|
125
|
+
* answer, and an `await` between deciding and writing is a window another
|
|
126
|
+
* in-flight request can use: the last-limited-seat request observes every seat
|
|
127
|
+
* cooling, a peer then succeeds on a just-recovered seat and calls
|
|
128
|
+
* clearCodexDecline, and the delayed continuation re-cools the whole provider
|
|
129
|
+
* against a pool that is healthy again. canAttempt('codex') then short-circuits
|
|
130
|
+
* and the healthy seat is skipped until the stale window expires — the exact
|
|
131
|
+
* single-seat outage this pool exists to prevent, reintroduced by the
|
|
132
|
+
* bookkeeping meant to prevent it.
|
|
133
|
+
*
|
|
134
|
+
* Re-checking inside the continuation narrows that window; it does not close
|
|
135
|
+
* it, because the re-check is itself another await. Taking the alias list first
|
|
136
|
+
* and then deciding-and-writing with no suspension point between them closes it
|
|
137
|
+
* outright: JS runs that callback as one unit, so nothing can interleave.
|
|
138
|
+
*
|
|
139
|
+
* The alias list may be a tick stale, which is harmless — a seat added in that
|
|
140
|
+
* window is not cooling, so "all cooled" is false on the next decline anyway.
|
|
70
141
|
*/
|
|
71
|
-
export declare function
|
|
142
|
+
export declare function allAliasesCooled(aliases: readonly string[]): boolean;
|
|
143
|
+
export declare function allCodexAccountsCooled(): Promise<boolean>;
|
|
144
|
+
/** Longest remaining cool-down across every seat, for a `retry-after`. */
|
|
145
|
+
export declare function codexPoolRetryAfterMs(): Promise<number>;
|
|
72
146
|
/**
|
|
73
147
|
* Parse whatever the user pastes back after authorizing.
|
|
74
148
|
*
|
package/dist/codex-accounts.js
CHANGED
|
@@ -15,6 +15,7 @@ import { homedir } from 'node:os';
|
|
|
15
15
|
import { randomBytes } from 'node:crypto';
|
|
16
16
|
import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, CodexRefreshError, } from './codex-oauth.js';
|
|
17
17
|
import { durableWriteFile } from './durable-write.js';
|
|
18
|
+
import { ProviderCooldowns } from './provider-cooldown.js';
|
|
18
19
|
const DARIO_DIR = join(homedir(), '.dario');
|
|
19
20
|
const CODEX_ACCOUNTS_DIR = join(DARIO_DIR, 'codex-accounts');
|
|
20
21
|
/** Same alias charset/traversal guard as accounts.ts's safeAliasPath. */
|
|
@@ -298,7 +299,105 @@ export async function getFreshCodexAccount(creds) {
|
|
|
298
299
|
* balancing — a subscription is per-seat, so spreading load across seats is the
|
|
299
300
|
* user's decision to make explicitly, not something to do implicitly.
|
|
300
301
|
*/
|
|
301
|
-
|
|
302
|
+
/**
|
|
303
|
+
* Per-seat cool-downs and conversation stickiness for the ChatGPT pool
|
|
304
|
+
* (dario#1244 follow-up).
|
|
305
|
+
*
|
|
306
|
+
* Until now selectCodexAccount returned `sort()[0]` — the alphabetically FIRST
|
|
307
|
+
* account, every time. `dario add altman` will happily store a dozen seats and
|
|
308
|
+
* dario would use exactly one of them. That is why the account-wide 429 on
|
|
309
|
+
* 2026-09-07 took the whole GPT lane down: a second seat sat there, healthy and
|
|
310
|
+
* unreachable, while every request failed over to Claude.
|
|
311
|
+
*
|
|
312
|
+
* Reuses ProviderCooldowns keyed by ALIAS rather than by provider name. It is
|
|
313
|
+
* already the right shape — arbitrary string key, injectable clock, entries
|
|
314
|
+
* dropped on read — so the pool needs no second cool-down implementation.
|
|
315
|
+
*
|
|
316
|
+
* ROTATION IS PER-CONVERSATION, NOT PER-REQUEST, and that is the whole design.
|
|
317
|
+
* The Codex prompt cache is scoped to the serving account: a conversation that
|
|
318
|
+
* builds a prefix on seat A reads nothing from it on seat B, and measured cache
|
|
319
|
+
* share on this lane is 59% in production against a 73% controlled ceiling.
|
|
320
|
+
* Rotating per request would trade a rate-limit problem for a cache problem and
|
|
321
|
+
* come out behind. So a conversation binds to a seat and stays there until that
|
|
322
|
+
* seat actually declines.
|
|
323
|
+
*
|
|
324
|
+
* Deliberately NOT headroom routing like the Claude pool. Claude responds with
|
|
325
|
+
* `anthropic-ratelimit-*` headers on every response, so that pool can read
|
|
326
|
+
* utilisation before it picks. The Codex backend states nothing until it 429s —
|
|
327
|
+
* the only signal is the decline itself plus its `retry-after` — so this is
|
|
328
|
+
* fill-first with cool-down eviction, which is what the available signal
|
|
329
|
+
* supports. If the backend ever starts reporting utilisation, this is where
|
|
330
|
+
* headroom would go.
|
|
331
|
+
*/
|
|
332
|
+
let codexCooldowns = new ProviderCooldowns();
|
|
333
|
+
/** conversation sticky key -> alias. Bounded; swept when it exceeds the cap. */
|
|
334
|
+
const codexSticky = new Map();
|
|
335
|
+
const CODEX_STICKY_MAX = 500;
|
|
336
|
+
/** Record that `alias` declined, for as long as the upstream asked. */
|
|
337
|
+
export function noteCodexDecline(alias, retryAfterMs) {
|
|
338
|
+
return codexCooldowns.note(alias, retryAfterMs);
|
|
339
|
+
}
|
|
340
|
+
/** A seat that just served is not rate-limited — clear it. */
|
|
341
|
+
export function clearCodexDecline(alias) {
|
|
342
|
+
codexCooldowns.clear(alias);
|
|
343
|
+
}
|
|
344
|
+
/** Ms until `alias` is askable again; 0 when it is askable now. */
|
|
345
|
+
export function codexCooldownRemainingMs(alias) {
|
|
346
|
+
return codexCooldowns.remainingMs(alias);
|
|
347
|
+
}
|
|
348
|
+
/** Test seam — forget every cool-down and binding. */
|
|
349
|
+
export function _resetCodexPoolForTest() {
|
|
350
|
+
codexSticky.clear();
|
|
351
|
+
// A fresh instance rather than clearing per alias: the previous version
|
|
352
|
+
// emptied the sticky map first and then iterated it, so it cleared nothing
|
|
353
|
+
// and cool-downs leaked between test cases.
|
|
354
|
+
codexCooldowns = new ProviderCooldowns();
|
|
355
|
+
}
|
|
356
|
+
/** The alias currently bound to a conversation, or null. */
|
|
357
|
+
export function codexStickyAliasFor(key) {
|
|
358
|
+
return key ? codexSticky.get(key) ?? null : null;
|
|
359
|
+
}
|
|
360
|
+
function bindCodexSticky(key, alias) {
|
|
361
|
+
if (codexSticky.size >= CODEX_STICKY_MAX && !codexSticky.has(key)) {
|
|
362
|
+
// Oldest-first eviction: Map preserves insertion order, so the first key is
|
|
363
|
+
// the least recently bound. Losing a binding costs one cache miss, never
|
|
364
|
+
// correctness, so a cheap sweep beats an LRU.
|
|
365
|
+
const oldest = codexSticky.keys().next().value;
|
|
366
|
+
if (oldest !== undefined)
|
|
367
|
+
codexSticky.delete(oldest);
|
|
368
|
+
}
|
|
369
|
+
codexSticky.set(key, alias);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Move a conversation onto `alias`, the codex mirror of pool.rebindSticky.
|
|
373
|
+
*
|
|
374
|
+
* Selection binds a conversation to the seat it picked; mid-request failover
|
|
375
|
+
* then moves it, and without this the binding still names the seat that just
|
|
376
|
+
* declined — the next turn would read a stale binding, find it cooling, and
|
|
377
|
+
* re-pick from scratch. A null key is accepted so the caller does not have to
|
|
378
|
+
* guard: a request with no hashable first user message has no conversation to
|
|
379
|
+
* bind.
|
|
380
|
+
*/
|
|
381
|
+
export function rebindCodexSticky(key, alias) {
|
|
382
|
+
if (!key)
|
|
383
|
+
return;
|
|
384
|
+
bindCodexSticky(key, alias);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Choose a ChatGPT seat for this request.
|
|
388
|
+
*
|
|
389
|
+
* Order, most specific first:
|
|
390
|
+
* 1. an explicitly named alias (`x-dario-account`, DARIO_CODEX_ACCOUNT) — a
|
|
391
|
+
* pin is an instruction, so it is honoured even while cooling; the caller
|
|
392
|
+
* asked for that seat and gets its answer, 429 included.
|
|
393
|
+
* 2. the seat this conversation is already bound to, unless it is cooling.
|
|
394
|
+
* 3. the first seat alphabetically that is not cooling — deterministic, so a
|
|
395
|
+
* given conversation lands on the same seat across a restart and keeps its
|
|
396
|
+
* prompt cache.
|
|
397
|
+
* 4. null when every seat is cooling. The caller answers from that rather
|
|
398
|
+
* than spending a request that can only 429 again.
|
|
399
|
+
*/
|
|
400
|
+
export async function selectCodexAccount(preferredAlias, opts) {
|
|
302
401
|
const alias = preferredAlias || process.env.DARIO_CODEX_ACCOUNT;
|
|
303
402
|
if (alias) {
|
|
304
403
|
const one = await loadCodexAccount(alias);
|
|
@@ -308,7 +407,83 @@ export async function selectCodexAccount(preferredAlias) {
|
|
|
308
407
|
const all = await loadAllCodexAccounts();
|
|
309
408
|
if (all.length === 0)
|
|
310
409
|
return null;
|
|
311
|
-
|
|
410
|
+
const byAlias = [...all].sort((a, b) => a.alias.localeCompare(b.alias));
|
|
411
|
+
const key = opts?.stickyKey ?? null;
|
|
412
|
+
if (key) {
|
|
413
|
+
const bound = codexSticky.get(key);
|
|
414
|
+
if (bound && !codexCooldowns.isCooled(bound)) {
|
|
415
|
+
const hit = byAlias.find((c) => c.alias === bound);
|
|
416
|
+
// A binding to a seat that has since been removed falls through to a
|
|
417
|
+
// fresh pick rather than failing the request.
|
|
418
|
+
if (hit)
|
|
419
|
+
return hit;
|
|
420
|
+
codexSticky.delete(key);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const free = byAlias.find((c) => !codexCooldowns.isCooled(c.alias));
|
|
424
|
+
if (!free)
|
|
425
|
+
return null;
|
|
426
|
+
if (key)
|
|
427
|
+
bindCodexSticky(key, free.alias);
|
|
428
|
+
return free;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The next askable seat that this request has NOT already tried.
|
|
432
|
+
*
|
|
433
|
+
* Mid-flight failover: a seat that 429s during a request hands the SAME
|
|
434
|
+
* request to a peer rather than failing it. Without this the pool only helps
|
|
435
|
+
* the request AFTER the one that discovered the limit — the discovering
|
|
436
|
+
* request still failed, every time a window rolled over.
|
|
437
|
+
*
|
|
438
|
+
* `tried` is per-request, so a seat already attempted here is never revisited
|
|
439
|
+
* inside the same request even if its cool-down has not landed yet. That is
|
|
440
|
+
* the codex mirror of the Claude pool's selectExcluding, and it is what makes
|
|
441
|
+
* the loop terminate: every pass adds a seat, so it is bounded by pool size.
|
|
442
|
+
*
|
|
443
|
+
* Stickiness is deliberately NOT consulted. The bound seat is the one that
|
|
444
|
+
* just declined; re-offering it would loop, and a conversation whose seat has
|
|
445
|
+
* gone away is better served elsewhere than not at all.
|
|
446
|
+
*/
|
|
447
|
+
export async function selectCodexAccountExcluding(tried) {
|
|
448
|
+
const all = await loadAllCodexAccounts();
|
|
449
|
+
if (all.length === 0)
|
|
450
|
+
return null;
|
|
451
|
+
return [...all]
|
|
452
|
+
.sort((a, b) => a.alias.localeCompare(b.alias))
|
|
453
|
+
.find((c) => !tried.has(c.alias) && !codexCooldowns.isCooled(c.alias)) ?? null;
|
|
454
|
+
}
|
|
455
|
+
/** Every seat is cooling — the fail-fast condition, for the caller's message. */
|
|
456
|
+
/**
|
|
457
|
+
* Are ALL of these aliases cooling, right now?
|
|
458
|
+
*
|
|
459
|
+
* Synchronous on purpose. The provider-wide cool-down is written from this
|
|
460
|
+
* answer, and an `await` between deciding and writing is a window another
|
|
461
|
+
* in-flight request can use: the last-limited-seat request observes every seat
|
|
462
|
+
* cooling, a peer then succeeds on a just-recovered seat and calls
|
|
463
|
+
* clearCodexDecline, and the delayed continuation re-cools the whole provider
|
|
464
|
+
* against a pool that is healthy again. canAttempt('codex') then short-circuits
|
|
465
|
+
* and the healthy seat is skipped until the stale window expires — the exact
|
|
466
|
+
* single-seat outage this pool exists to prevent, reintroduced by the
|
|
467
|
+
* bookkeeping meant to prevent it.
|
|
468
|
+
*
|
|
469
|
+
* Re-checking inside the continuation narrows that window; it does not close
|
|
470
|
+
* it, because the re-check is itself another await. Taking the alias list first
|
|
471
|
+
* and then deciding-and-writing with no suspension point between them closes it
|
|
472
|
+
* outright: JS runs that callback as one unit, so nothing can interleave.
|
|
473
|
+
*
|
|
474
|
+
* The alias list may be a tick stale, which is harmless — a seat added in that
|
|
475
|
+
* window is not cooling, so "all cooled" is false on the next decline anyway.
|
|
476
|
+
*/
|
|
477
|
+
export function allAliasesCooled(aliases) {
|
|
478
|
+
return aliases.length > 0 && aliases.every((a) => codexCooldowns.isCooled(a));
|
|
479
|
+
}
|
|
480
|
+
export async function allCodexAccountsCooled() {
|
|
481
|
+
return allAliasesCooled(await listCodexAccountAliases());
|
|
482
|
+
}
|
|
483
|
+
/** Longest remaining cool-down across every seat, for a `retry-after`. */
|
|
484
|
+
export async function codexPoolRetryAfterMs() {
|
|
485
|
+
const aliases = await listCodexAccountAliases();
|
|
486
|
+
return aliases.reduce((max, a) => Math.max(max, codexCooldowns.remainingMs(a)), 0);
|
|
312
487
|
}
|
|
313
488
|
/**
|
|
314
489
|
* Parse whatever the user pastes back after authorizing.
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -48,6 +48,9 @@ export interface CodexForwardOutcome {
|
|
|
48
48
|
export interface CodexDecline {
|
|
49
49
|
status: number;
|
|
50
50
|
retryAfterMs: number | null;
|
|
51
|
+
/** The seat that declined. Without it a caller can cool the provider but
|
|
52
|
+
* not the account, which is the whole point of a pool. */
|
|
53
|
+
alias: string;
|
|
51
54
|
}
|
|
52
55
|
/** The cached slug list for an alias WITHOUT fetching. For the admin surface:
|
|
53
56
|
* a status read must never cost an upstream call or a token refresh. */
|
|
@@ -275,7 +278,16 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
|
|
|
275
278
|
* into a buffered response object is not built yet. A non-streaming client
|
|
276
279
|
* gets a 400 saying so.
|
|
277
280
|
*/
|
|
278
|
-
export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void
|
|
281
|
+
export declare function forwardResponsesToCodex(res: ServerResponse, body: Record<string, unknown>, creds: CodexAccountCredentials, corsOrigin: string, securityHeaders: Record<string, string>, upstreamTimeoutMs: number, verbose: boolean, fetchImpl?: typeof fetch, onDone?: (outcome: CodexForwardOutcome) => void,
|
|
282
|
+
/** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
|
|
283
|
+
* caller needs to know which seat and for how long — without it the pool
|
|
284
|
+
* cannot cool a limited seat on this path, so selection hands the same
|
|
285
|
+
* rate-limited account back on every following request. */
|
|
286
|
+
onDecline?: (info: CodexDecline) => void,
|
|
287
|
+
/** When true a decline returns false WITHOUT writing, so the caller can
|
|
288
|
+
* retry the request on a healthy peer. False keeps the old behaviour: the
|
|
289
|
+
* upstream error is written through as the backend sent it. */
|
|
290
|
+
deferOnUnavailable?: boolean): Promise<boolean>;
|
|
279
291
|
/**
|
|
280
292
|
* Serve a request from a stored Codex account, in either client wire shape.
|
|
281
293
|
*
|
package/dist/codex-backend.js
CHANGED
|
@@ -717,6 +717,9 @@ export function createResponsesTranslator(model) {
|
|
|
717
717
|
export const CODEX_SUPPORTED_FIELDS = [
|
|
718
718
|
'model', 'input', 'stream', 'store', 'instructions',
|
|
719
719
|
'tools', 'tool_choice', 'parallel_tool_calls', 'reasoning',
|
|
720
|
+
// `web_search_call.action.sources` (v6.4): accepted by the backend, probed
|
|
721
|
+
// 2026-09-12 — the searched URLs the Anthropic result block lists.
|
|
722
|
+
'include',
|
|
720
723
|
// Sent by the Codex CLI on every request (codex-rs ResponsesApiRequest), so
|
|
721
724
|
// accepted by construction; see codexPromptCacheKey.
|
|
722
725
|
'prompt_cache_key',
|
|
@@ -756,7 +759,16 @@ export function buildCodexHeaders(creds) {
|
|
|
756
759
|
* into a buffered response object is not built yet. A non-streaming client
|
|
757
760
|
* gets a 400 saying so.
|
|
758
761
|
*/
|
|
759
|
-
export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone
|
|
762
|
+
export async function forwardResponsesToCodex(res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, fetchImpl = fetch, onDone,
|
|
763
|
+
/** Mirrors forwardToCodex. A 429 or 5xx is the SEAT saying no, and the
|
|
764
|
+
* caller needs to know which seat and for how long — without it the pool
|
|
765
|
+
* cannot cool a limited seat on this path, so selection hands the same
|
|
766
|
+
* rate-limited account back on every following request. */
|
|
767
|
+
onDecline,
|
|
768
|
+
/** When true a decline returns false WITHOUT writing, so the caller can
|
|
769
|
+
* retry the request on a healthy peer. False keeps the old behaviour: the
|
|
770
|
+
* upstream error is written through as the backend sent it. */
|
|
771
|
+
deferOnUnavailable = false) {
|
|
760
772
|
const startedAt = Date.now();
|
|
761
773
|
const model = String(body.model ?? '');
|
|
762
774
|
let reported = false;
|
|
@@ -801,6 +813,23 @@ export async function forwardResponsesToCodex(res, body, creds, corsOrigin, secu
|
|
|
801
813
|
const detail = await upstream.text().catch(() => '');
|
|
802
814
|
if (verbose)
|
|
803
815
|
console.error(`[dario] codex backend ${upstream.status}: ${detail.slice(0, 300)}`);
|
|
816
|
+
// Same rule as the Messages path: a 429 or a 5xx is the seat declining,
|
|
817
|
+
// and that is true whether or not anything is waiting to take over.
|
|
818
|
+
const unavailable = upstream.status === 429 || upstream.status >= 500;
|
|
819
|
+
if (unavailable) {
|
|
820
|
+
try {
|
|
821
|
+
onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
|
|
822
|
+
}
|
|
823
|
+
catch { /* a reporting failure must never break a request */ }
|
|
824
|
+
}
|
|
825
|
+
if (deferOnUnavailable && unavailable) {
|
|
826
|
+
if (verbose)
|
|
827
|
+
console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring`);
|
|
828
|
+
// Nothing written, so the caller is free to retry this same request
|
|
829
|
+
// on a peer. Reporting nothing here matches forwardToCodex: a
|
|
830
|
+
// declined attempt is not a served request.
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
804
833
|
if (!clientGone) {
|
|
805
834
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
806
835
|
// The backend's own error body, already in the client's shape.
|
|
@@ -1045,16 +1074,25 @@ midstream) {
|
|
|
1045
1074
|
// own fault (a bad body, an unsupported parameter) is NOT: failing over
|
|
1046
1075
|
// would just reproduce it somewhere else and hide the real error.
|
|
1047
1076
|
const unavailable = upstream.status === 429 || upstream.status >= 500;
|
|
1077
|
+
// The seat said no, and that is true whether or not a fallback exists
|
|
1078
|
+
// to defer to. Recording it outside the defer branch is what lets the
|
|
1079
|
+
// POOL rotate on a deployment with no --pool-fallback configured: with
|
|
1080
|
+
// the notice inside the branch, a 429 went straight to the client and
|
|
1081
|
+
// the seat was never cooled, so selection returned the same limited
|
|
1082
|
+
// account forever (found writing the proxy-level test for #1288).
|
|
1083
|
+
if (unavailable) {
|
|
1084
|
+
try {
|
|
1085
|
+
onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')), alias: creds.alias });
|
|
1086
|
+
}
|
|
1087
|
+
catch { /* a reporting failure must never break a request */ }
|
|
1088
|
+
}
|
|
1048
1089
|
if (deferOnUnavailable && unavailable) {
|
|
1049
1090
|
console.log(`[dario] codex account ${creds.alias} unavailable (${upstream.status}) — deferring to the next provider`);
|
|
1050
1091
|
// A decline is the only exit that tells the caller nothing was served,
|
|
1051
1092
|
// and until now it carried no WHY: a 429 and a 503 were the same false.
|
|
1052
1093
|
// The chain needs the status (to cool a rate limit but not an outage)
|
|
1053
1094
|
// and the upstream's own `retry-after` (to cool it for the right long).
|
|
1054
|
-
|
|
1055
|
-
onDecline?.({ status: upstream.status, retryAfterMs: parseRetryAfterMs(upstream.headers.get('retry-after')) });
|
|
1056
|
-
}
|
|
1057
|
-
catch { /* a reporting failure must never break a declined request */ }
|
|
1095
|
+
// (the decline was already recorded above, for both exits)
|
|
1058
1096
|
return false;
|
|
1059
1097
|
}
|
|
1060
1098
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
@@ -1248,7 +1286,7 @@ midstream) {
|
|
|
1248
1286
|
// status 0: no HTTP status ever arrived. Reported so the caller can tell
|
|
1249
1287
|
// an outage from a rate limit — an unreachable backend is not quota.
|
|
1250
1288
|
try {
|
|
1251
|
-
onDecline?.({ status: 0, retryAfterMs: null });
|
|
1289
|
+
onDecline?.({ status: 0, retryAfterMs: null, alias: creds.alias });
|
|
1252
1290
|
}
|
|
1253
1291
|
catch { /* as above */ }
|
|
1254
1292
|
return false;
|
package/dist/proxy.js
CHANGED
|
@@ -31,7 +31,7 @@ import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequest
|
|
|
31
31
|
import { isClaudeServableModel } from './claude-model.js';
|
|
32
32
|
import { MODEL_UNROUTABLE } from './upstream-rejection.js';
|
|
33
33
|
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
34
|
-
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
|
|
34
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, getCodexRefreshFailure, CodexCredentialsUnavailableError } from './codex-accounts.js';
|
|
35
35
|
import { route as routeProvider } from './provider-adapter.js';
|
|
36
36
|
import { selectPoolFallbackModels } from './pool-fallback-tier.js';
|
|
37
37
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
@@ -163,6 +163,30 @@ function extractFirstUserMessage(body) {
|
|
|
163
163
|
}
|
|
164
164
|
return '';
|
|
165
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* The conversation key for Codex seat stickiness, from raw request bytes.
|
|
168
|
+
*
|
|
169
|
+
* The same hash the Claude pool binds on (computeStickyKey over the first user
|
|
170
|
+
* message), so a conversation stays on one ChatGPT seat across turns and keeps
|
|
171
|
+
* the prompt-cache prefix it built there — rotating per request would trade a
|
|
172
|
+
* rate-limit problem for a cache problem.
|
|
173
|
+
*
|
|
174
|
+
* Null for a body that is not a JSON object or carries no user message; those
|
|
175
|
+
* requests bypass stickiness rather than sharing one bucket. Used by the two
|
|
176
|
+
* codex entries that hold no parsed body of their own (the pool-exhausted
|
|
177
|
+
* fallback and the mid-stream continuation target).
|
|
178
|
+
*/
|
|
179
|
+
function codexStickyKeyForBody(body) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(body.toString('utf-8'));
|
|
182
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
183
|
+
return null;
|
|
184
|
+
return computeStickyKey(extractFirstUserMessage(parsed));
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
166
190
|
// Session ID behavior:
|
|
167
191
|
// v3.18 rotated per request — which was itself a fingerprint. Real CC
|
|
168
192
|
// rotates roughly once per conversation, not per call. A user who has
|
|
@@ -1999,6 +2023,47 @@ export async function startProxy(opts = {}) {
|
|
|
1999
2023
|
function checkAuth(req) {
|
|
2000
2024
|
return authenticateRequest(req.headers, apiKeyBuf);
|
|
2001
2025
|
}
|
|
2026
|
+
/**
|
|
2027
|
+
* A ChatGPT seat declined. Cool the SEAT, and the provider only once every
|
|
2028
|
+
* seat is cooling.
|
|
2029
|
+
*
|
|
2030
|
+
* One handler for every codex forward — both wire shapes and the
|
|
2031
|
+
* Claude-to-Codex fallback. It was two hand-copied copies, and that is
|
|
2032
|
+
* precisely how the native Responses path ended up cooling nothing while
|
|
2033
|
+
* the translated path cooled correctly: a third call site inherits this by
|
|
2034
|
+
* construction rather than by someone remembering to copy it.
|
|
2035
|
+
*
|
|
2036
|
+
* Closes over nothing per-request, which is what makes one copy possible.
|
|
2037
|
+
*/
|
|
2038
|
+
const codexOnDecline = (d) => {
|
|
2039
|
+
// Cool the PROVIDER (is the codex lane usable at all) and the SEAT
|
|
2040
|
+
// that actually declined (which ChatGPT account said no, and for how
|
|
2041
|
+
// long). Before the seat half existed, selectCodexAccount returned the
|
|
2042
|
+
// alphabetically-first account every time, so one 429'd seat took the
|
|
2043
|
+
// whole lane down while its healthy peers sat unreachable.
|
|
2044
|
+
if (d.status !== 429)
|
|
2045
|
+
return;
|
|
2046
|
+
// A 429 is a SEAT-level condition, so cool the seat unconditionally.
|
|
2047
|
+
// The provider is only cooled once EVERY seat is cooling.
|
|
2048
|
+
//
|
|
2049
|
+
// Cooling the provider on any single 429 defeats the pool: the routing
|
|
2050
|
+
// gate short-circuits on canAttempt('codex'), so the next request never
|
|
2051
|
+
// reaches selectCodexAccount to find the healthy peer — the exact
|
|
2052
|
+
// single-seat outage this change exists to remove (caught in review of
|
|
2053
|
+
// #1288). Dropping provider cooling altogether is equally wrong the other
|
|
2054
|
+
// way: on a single-seat deployment nothing would fail fast, and every
|
|
2055
|
+
// request would re-hammer a seat already known to be limited instead of
|
|
2056
|
+
// falling through to Claude. All-seats-cooled is the condition that means
|
|
2057
|
+
// what the provider cool-down was always trying to say.
|
|
2058
|
+
noteCodexDecline(d.alias, d.retryAfterMs);
|
|
2059
|
+
void listCodexAccountAliases().then((aliases) => {
|
|
2060
|
+
// Decide and write in the SAME tick — see allAliasesCooled. An await
|
|
2061
|
+
// between the two lets a concurrent success clear a seat in the gap,
|
|
2062
|
+
// and the late write then cools a pool that has recovered.
|
|
2063
|
+
if (allAliasesCooled(aliases))
|
|
2064
|
+
providerCooldowns.note('codex', d.retryAfterMs);
|
|
2065
|
+
}).catch(() => { });
|
|
2066
|
+
};
|
|
2002
2067
|
/**
|
|
2003
2068
|
* Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
|
|
2004
2069
|
*
|
|
@@ -2025,50 +2090,101 @@ export async function startProxy(opts = {}) {
|
|
|
2025
2090
|
return false;
|
|
2026
2091
|
if (!(await hasAnyCodexAccount().catch(() => false)))
|
|
2027
2092
|
return false;
|
|
2028
|
-
|
|
2093
|
+
// Sticky on the CONVERSATION, not on this fallback hop: a conversation
|
|
2094
|
+
// that reaches the subscription twice lands on the same seat both times,
|
|
2095
|
+
// so the second turn reads the prefix the first one paid to create.
|
|
2096
|
+
const stickyKey = codexStickyKeyForBody(body);
|
|
2097
|
+
const stored = await selectCodexAccount(undefined, { stickyKey }).catch(() => null);
|
|
2029
2098
|
if (!stored)
|
|
2030
2099
|
return false;
|
|
2031
|
-
let
|
|
2100
|
+
let seat;
|
|
2032
2101
|
try {
|
|
2033
|
-
|
|
2102
|
+
seat = await getFreshCodexAccount(stored);
|
|
2034
2103
|
}
|
|
2035
2104
|
catch {
|
|
2036
2105
|
return false;
|
|
2037
2106
|
}
|
|
2038
|
-
const slugs = await getCodexModelSlugs(creds).catch(() => []);
|
|
2039
|
-
const fallbackPick = pickCodexFallback(fallbackModels, slugs);
|
|
2040
|
-
if (!fallbackPick)
|
|
2041
|
-
return false;
|
|
2042
|
-
const fallbackModel = fallbackPick.model;
|
|
2043
|
-
const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
|
|
2044
|
-
if (!fallbackBody)
|
|
2045
|
-
return false;
|
|
2046
|
-
console.log(`[dario] #${requestCount} ${why} → codex account ${creds.alias} as ${fallbackModel}`);
|
|
2047
|
-
requestCount++;
|
|
2048
|
-
attempted.add('codex');
|
|
2049
2107
|
// If an api-key backend could ALSO serve this request, let the subscription
|
|
2050
2108
|
// decline a 429/5xx rather than answer with it, and report not-served so the
|
|
2051
|
-
// caller falls through to that backend.
|
|
2052
|
-
// said it declines so the caller can continue; it just never exercised the
|
|
2053
|
-
// mechanism it was built on, so a rate-limited subscription ended the chain
|
|
2054
|
-
// with a healthy backend sitting unused beside it.
|
|
2109
|
+
// caller falls through to that backend.
|
|
2055
2110
|
//
|
|
2056
|
-
// With NO next option, do not defer: the real upstream error is
|
|
2057
|
-
// to the client than replacing it with a generic 503.
|
|
2111
|
+
// With NO next option and no peer, do not defer: the real upstream error is
|
|
2112
|
+
// more useful to the client than replacing it with a generic 503.
|
|
2058
2113
|
const hasNextOption = openaiBackend !== null && shape === 'openai';
|
|
2059
|
-
|
|
2060
|
-
//
|
|
2061
|
-
//
|
|
2062
|
-
//
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
//
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2114
|
+
// The next seat that could serve one of these fallback models, excluding
|
|
2115
|
+
// everything already tried. `peek` is the cached read, so the scan costs no
|
|
2116
|
+
// upstream call; a seat whose model list is unknown is still worth a try.
|
|
2117
|
+
//
|
|
2118
|
+
// Scans rather than testing one candidate: with mixed model availability
|
|
2119
|
+
// across seats, the alphabetically-next peer may be the one that lists none
|
|
2120
|
+
// of the fallback models while a later one lists one.
|
|
2121
|
+
const nextFallbackPeer = async (tried) => {
|
|
2122
|
+
const skipped = new Set(tried);
|
|
2123
|
+
for (;;) {
|
|
2124
|
+
const candidate = await selectCodexAccountExcluding(skipped).catch(() => null);
|
|
2125
|
+
if (!candidate)
|
|
2126
|
+
return null;
|
|
2127
|
+
const peerSlugs = peekCodexModelSlugs(candidate.alias);
|
|
2128
|
+
if (!peerSlugs || pickCodexFallback(fallbackModels, peerSlugs))
|
|
2129
|
+
return candidate;
|
|
2130
|
+
skipped.add(candidate.alias);
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
// Mid-flight seat failover on the CLAUDE-TO-CODEX route, the same as the
|
|
2134
|
+
// primary Codex route has. Without it this route selected one seat and
|
|
2135
|
+
// stopped: a 429 from that seat was written to the client while a healthy
|
|
2136
|
+
// peer sat unused, so the pool helped every route except this one (caught
|
|
2137
|
+
// in review of #1288). The fallback model is re-picked per seat because
|
|
2138
|
+
// pickCodexFallback reads that SEAT's slugs — peers need not list the same
|
|
2139
|
+
// model, and the one that answers may answer as a different one.
|
|
2140
|
+
//
|
|
2141
|
+
// Terminates by construction: every pass adds a seat to `tried`, and
|
|
2142
|
+
// nextFallbackPeer never returns one already in it.
|
|
2143
|
+
const tried = new Set();
|
|
2144
|
+
let served = false;
|
|
2145
|
+
while (seat) {
|
|
2146
|
+
tried.add(seat.alias);
|
|
2147
|
+
const slugs = await getCodexModelSlugs(seat).catch(() => []);
|
|
2148
|
+
const fallbackPick = pickCodexFallback(fallbackModels, slugs);
|
|
2149
|
+
// Resolved BEFORE the attempt: it decides whether this attempt may defer,
|
|
2150
|
+
// and becomes the seat to retry on if it declines.
|
|
2151
|
+
const peer = await nextFallbackPeer(tried);
|
|
2152
|
+
if (!fallbackPick) {
|
|
2153
|
+
// This seat lists none of the fallback models. That used to end the
|
|
2154
|
+
// attempt outright; a peer may still list one.
|
|
2155
|
+
if (!peer)
|
|
2156
|
+
return false;
|
|
2157
|
+
seat = await getFreshCodexAccount(peer).catch(() => peer);
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
const fallbackModel = fallbackPick.model;
|
|
2161
|
+
const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
|
|
2162
|
+
if (!fallbackBody)
|
|
2163
|
+
return false;
|
|
2164
|
+
console.log(`[dario] #${requestCount} ${why} → codex account ${seat.alias} as ${fallbackModel}`);
|
|
2165
|
+
requestCount++;
|
|
2166
|
+
// Marked only once an attempt is actually being made. Marking it before
|
|
2167
|
+
// the guards above would tell the rest of the request that codex had been
|
|
2168
|
+
// tried when it had not, suppressing a later legitimate attempt.
|
|
2169
|
+
attempted.add('codex');
|
|
2170
|
+
served = await forwardToCodex(req, res, fallbackBody, seat, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption || peer !== null, undefined, codexOnDecline,
|
|
2171
|
+
// The mirror of the Claude side (dario#1161): an operator who writes
|
|
2172
|
+
// `--pool-fallback=gpt-5.6-terra:high` is choosing the effort the
|
|
2173
|
+
// failover runs at, so the entry's own suffix reaches the request rather
|
|
2174
|
+
// than the failover quietly running at the backend default.
|
|
2175
|
+
effortForCodex(fallbackPick.effort));
|
|
2176
|
+
if (served || !peer)
|
|
2177
|
+
break;
|
|
2178
|
+
console.log(`[dario] codex seat ${seat.alias} declined — retrying this fallback on ${peer.alias}`);
|
|
2179
|
+
// The conversation follows the request. Its binding still names the seat
|
|
2180
|
+
// that just declined; leaving it there would send the next turn back to a
|
|
2181
|
+
// cooling seat and re-pick from scratch.
|
|
2182
|
+
rebindCodexSticky(stickyKey, peer.alias);
|
|
2183
|
+
seat = await getFreshCodexAccount(peer).catch(() => peer);
|
|
2184
|
+
}
|
|
2185
|
+
if (served) {
|
|
2071
2186
|
providerCooldowns.clear('codex');
|
|
2187
|
+
}
|
|
2072
2188
|
return served;
|
|
2073
2189
|
};
|
|
2074
2190
|
/**
|
|
@@ -3027,7 +3143,10 @@ export async function startProxy(opts = {}) {
|
|
|
3027
3143
|
return null;
|
|
3028
3144
|
if (!(await hasAnyCodexAccount().catch(() => false)))
|
|
3029
3145
|
return null;
|
|
3030
|
-
|
|
3146
|
+
// The CLIENT's bytes rather than the rewritten `body`: a resume is the
|
|
3147
|
+
// same conversation as the request that died mid-stream, so it must
|
|
3148
|
+
// hash to the same key and land on the seat that conversation holds.
|
|
3149
|
+
const stored = await selectCodexAccount(undefined, { stickyKey: codexStickyKeyForBody(clientBodyBytes) }).catch(() => null);
|
|
3031
3150
|
if (!stored)
|
|
3032
3151
|
return null;
|
|
3033
3152
|
let creds;
|
|
@@ -3328,8 +3447,17 @@ export async function startProxy(opts = {}) {
|
|
|
3328
3447
|
// account, rather than letting the throw escape into the JSON-peek
|
|
3329
3448
|
// catch below and disappear (DEV-179a412f).
|
|
3330
3449
|
let codexUnavailable = null;
|
|
3450
|
+
// Conversation -> seat binding for the codex lane, the mirror of the
|
|
3451
|
+
// Claude pool's stickyKey below. It belongs HERE because this is
|
|
3452
|
+
// where the seat is CHOSEN: without a key every turn independently
|
|
3453
|
+
// re-picks "the first seat not cooling", so a lower-alias seat that
|
|
3454
|
+
// frees up mid-conversation silently moves the conversation off the
|
|
3455
|
+
// seat holding its prompt-cache prefix (caught in review of #1288).
|
|
3456
|
+
// `parsedBody` is the object the invalid-body guard already parsed,
|
|
3457
|
+
// so this costs no second JSON.parse.
|
|
3458
|
+
const codexStickyKey = parsedBody ? computeStickyKey(extractFirstUserMessage(parsedBody)) : null;
|
|
3331
3459
|
if (await hasAnyCodexAccount()) {
|
|
3332
|
-
const stored = await selectCodexAccount();
|
|
3460
|
+
const stored = await selectCodexAccount(undefined, { stickyKey: codexStickyKey });
|
|
3333
3461
|
if (stored) {
|
|
3334
3462
|
try {
|
|
3335
3463
|
codexCreds = await getFreshCodexAccount(stored);
|
|
@@ -3466,14 +3594,22 @@ export async function startProxy(opts = {}) {
|
|
|
3466
3594
|
},
|
|
3467
3595
|
})
|
|
3468
3596
|
: null;
|
|
3469
|
-
//
|
|
3470
|
-
//
|
|
3471
|
-
//
|
|
3472
|
-
//
|
|
3473
|
-
//
|
|
3474
|
-
//
|
|
3597
|
+
// Reporting is one function for BOTH codex shapes below: the Responses
|
|
3598
|
+
// passthrough and the translated Messages path record the same row, so a
|
|
3599
|
+
// GPT request looks the same in /analytics whichever shape asked for it.
|
|
3600
|
+
//
|
|
3601
|
+
// Before this hook a codex request left no trace: nothing in /analytics,
|
|
3602
|
+
// nothing in the request log, no per-account count. The dock (and anyone
|
|
3603
|
+
// reading /analytics) saw a proxy that served GPT all day and reported
|
|
3604
|
+
// zero of it. A decline (the request handed to the Claude pool) reports
|
|
3605
|
+
// nothing here; the Claude path records what it then serves.
|
|
3475
3606
|
const codexOnDone = (o) => {
|
|
3476
3607
|
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
3608
|
+
// A seat that actually SERVED is not rate-limited. Keyed on a 2xx,
|
|
3609
|
+
// never on forwardToCodex returning true — that means "I wrote a
|
|
3610
|
+
// response", which is equally true when it wrote the upstream 429.
|
|
3611
|
+
if (o.status >= 200 && o.status < 300)
|
|
3612
|
+
clearCodexDecline(o.alias);
|
|
3477
3613
|
analytics.record({
|
|
3478
3614
|
timestamp: Date.now(),
|
|
3479
3615
|
consumer,
|
|
@@ -3502,24 +3638,83 @@ export async function startProxy(opts = {}) {
|
|
|
3502
3638
|
cacheReadTokens: o.cacheReadTokens, cacheCreateTokens: o.cacheCreateTokens,
|
|
3503
3639
|
}, consumer));
|
|
3504
3640
|
};
|
|
3505
|
-
// A
|
|
3506
|
-
//
|
|
3507
|
-
//
|
|
3508
|
-
//
|
|
3509
|
-
//
|
|
3510
|
-
//
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3641
|
+
// Mid-flight seat failover. A 429 lands BEFORE any body is written —
|
|
3642
|
+
// forwardToCodex only returns false on the decline path — so the same
|
|
3643
|
+
// request can be handed to a healthy peer instead of failing. Without
|
|
3644
|
+
// this the pool only helps the request AFTER the one that discovered the
|
|
3645
|
+
// limit; the discovering request still failed, every window rollover.
|
|
3646
|
+
//
|
|
3647
|
+
// `deferOnUnavailable` is widened to `canDefer || a peer exists`: without
|
|
3648
|
+
// that, a decline with no Claude fallback configured writes the 429 to the
|
|
3649
|
+
// client and returns true, and there is nothing left to retry onto.
|
|
3650
|
+
//
|
|
3651
|
+
// Terminates by construction: every pass adds a seat to `codexTried`, and
|
|
3652
|
+
// selectCodexAccountExcluding never returns a seat already in it.
|
|
3653
|
+
let served = false;
|
|
3654
|
+
if (codexAvailable) {
|
|
3655
|
+
const codexTried = new Set();
|
|
3656
|
+
let codexSeat = codexCreds;
|
|
3657
|
+
while (codexSeat) {
|
|
3658
|
+
codexTried.add(codexSeat.alias);
|
|
3659
|
+
// Resolved BEFORE the attempt: it decides whether this attempt may
|
|
3660
|
+
// defer, and becomes the seat to retry on if it declines.
|
|
3661
|
+
// A peer that demonstrably does not list this model cannot serve it;
|
|
3662
|
+
// trying it would trade a 429 for a 400. peek is the cached read, so
|
|
3663
|
+
// this never costs an upstream call — an unknown list still gets a try.
|
|
3664
|
+
//
|
|
3665
|
+
// Scanning rather than testing one candidate: with mixed model
|
|
3666
|
+
// availability across seats, the alphabetically-next peer may be the
|
|
3667
|
+
// one that cannot serve this model while a later one can. Stopping at
|
|
3668
|
+
// the first incompatible candidate left `codexPeer` null and abandoned
|
|
3669
|
+
// a usable seat — with no Claude fallback the declining seat's 429 went
|
|
3670
|
+
// straight to the client (caught in review of #1288). `peerTried` is
|
|
3671
|
+
// seeded from `codexTried` and grows every pass, so this terminates.
|
|
3672
|
+
let codexPeer = null;
|
|
3673
|
+
const peerTried = new Set(codexTried);
|
|
3674
|
+
for (;;) {
|
|
3675
|
+
const candidate = await selectCodexAccountExcluding(peerTried).catch(() => null);
|
|
3676
|
+
if (!candidate)
|
|
3677
|
+
break;
|
|
3678
|
+
const peerSlugs = rawModel ? peekCodexModelSlugs(candidate.alias) : null;
|
|
3679
|
+
if (!peerSlugs || isCodexModel(rawModel, peerSlugs)) {
|
|
3680
|
+
codexPeer = candidate;
|
|
3681
|
+
break;
|
|
3682
|
+
}
|
|
3683
|
+
peerTried.add(candidate.alias);
|
|
3684
|
+
}
|
|
3685
|
+
// A Responses client on a ChatGPT-subscription model: the backend speaks
|
|
3686
|
+
// that shape natively, so the body goes through as written (model
|
|
3687
|
+
// resolved) and the SSE comes back untouched — no round trip through the
|
|
3688
|
+
// Messages shape, which cannot carry the newest Codex CLI request
|
|
3689
|
+
// features. Answers on the raw response: these bytes are already in the
|
|
3690
|
+
// client's shape.
|
|
3691
|
+
//
|
|
3692
|
+
// It sits INSIDE the retry loop, on the same seat sequence and the same
|
|
3693
|
+
// defer condition as the translated path. Outside it, a 429 on this shape
|
|
3694
|
+
// cooled nothing: selection handed the same limited seat back on every
|
|
3695
|
+
// following request and a healthy peer was never reached — the single-seat
|
|
3696
|
+
// outage this change exists to remove, surviving on the one shape Codex
|
|
3697
|
+
// CLI actually speaks (caught in review of #1288).
|
|
3698
|
+
if (isResponses && responsesBodyRaw) {
|
|
3699
|
+
served = await forwardResponsesToCodex(rawRes, { ...responsesBodyRaw, model: rawModel }, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, codexFetch, codexOnDone, codexOnDecline, canDefer || codexPeer !== null);
|
|
3700
|
+
}
|
|
3701
|
+
else {
|
|
3702
|
+
served = await forwardToCodex(req, res, body, codexSeat, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', codexFetch, canDefer || codexPeer !== null, codexOnDone, codexOnDecline,
|
|
3703
|
+
// dario#1260 — the effort named by the model-name suffix stripped
|
|
3704
|
+
// above. Undefined for every request that did not name one, which
|
|
3705
|
+
// leaves the outbound body exactly as it was.
|
|
3706
|
+
effortForCodex(requestEffort), codexGuard);
|
|
3707
|
+
}
|
|
3708
|
+
if (served || !codexPeer)
|
|
3709
|
+
break;
|
|
3710
|
+
console.log(`[dario] codex seat ${codexSeat.alias} declined — retrying this request on ${codexPeer.alias}`);
|
|
3711
|
+
// The conversation follows the request. Its binding still names
|
|
3712
|
+
// the seat that just declined; leaving it there would send the
|
|
3713
|
+
// next turn back to a cooling seat and re-pick from scratch.
|
|
3714
|
+
rebindCodexSticky(codexStickyKey, codexPeer.alias);
|
|
3715
|
+
codexSeat = await getFreshCodexAccount(codexPeer).catch(() => codexPeer);
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3523
3718
|
if (served) {
|
|
3524
3719
|
// A provider that just served is not rate-limited.
|
|
3525
3720
|
providerCooldowns.clear('codex');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.0",
|
|
4
4
|
"description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|