@askalf/dario 6.0.0 → 6.0.2
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 -2
- package/dist/analytics.d.ts +2 -0
- package/dist/analytics.js +9 -0
- package/dist/claude-model.d.ts +12 -0
- package/dist/claude-model.js +70 -0
- package/dist/codex-backend.d.ts +27 -2
- package/dist/codex-backend.js +66 -15
- package/dist/proxy.js +64 -5
- package/docs/multi-account-pool.md +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -177,7 +177,7 @@ That is a **chain**, read left to right, and each provider takes the first entry
|
|
|
177
177
|
|
|
178
178
|
A single-entry chain is one-way and means what it always meant, so an existing config is unaffected. Failover is entirely opt-in: without `--pool-fallback`, a drained pool still returns its honest 429/503.
|
|
179
179
|
|
|
180
|
-
The Claude entry
|
|
180
|
+
The Claude entry has to be a model the pool can actually serve. "Not a GPT model" is not the same thing, and swapping in a typo would trade a recoverable 429 for an unrecoverable 404 — so each entry is checked positively against the live model catalog and skipped if it fails, letting the real error surface. Canonical ids (`claude-sonnet-5`), long-context variants (`claude-sonnet-5[1m]`), catalog shorthands (`opus`, `sonnet1m`) and explicit provider prefixes (`claude:opus`, `anthropic:sonnet`) all qualify.
|
|
181
181
|
|
|
182
182
|
Only a **429 or 5xx** fails over. A 400 surfaces to you, because a bad request that fails over just reproduces itself on the other provider and buries the real cause.
|
|
183
183
|
|
|
@@ -303,7 +303,7 @@ The split isn't live, but it was announced once on short notice and could return
|
|
|
303
303
|
|
|
304
304
|
| Signal | Status |
|
|
305
305
|
|---|---|
|
|
306
|
-
| Source | **~29k** lines of TypeScript across **
|
|
306
|
+
| Source | **~29k** lines of TypeScript across **59** files — auditable in a weekend (v5 removed shim; the pool is the one code path) |
|
|
307
307
|
| Dependencies | **0 runtime.** Verify: `npm ls --production` |
|
|
308
308
|
| Provenance | Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions + Sigstore |
|
|
309
309
|
| Scanning | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) on every push and weekly |
|
package/dist/analytics.d.ts
CHANGED
|
@@ -63,6 +63,8 @@ export declare function billingBucketFromClaim(claim: string | null | undefined)
|
|
|
63
63
|
* non-subscription billing classification or the `unknown` sentinel below.
|
|
64
64
|
*/
|
|
65
65
|
export declare const SUBSCRIPTION_CLAIMS: ReadonlySet<string>;
|
|
66
|
+
/** The claim the proxy stamps on codex-engine requests (see above). */
|
|
67
|
+
export declare const CODEX_CLAIM = "chatgpt_subscription";
|
|
66
68
|
/**
|
|
67
69
|
* One-line per-request usage summary for verbose (-v / -vv) logs.
|
|
68
70
|
*
|
package/dist/analytics.js
CHANGED
|
@@ -35,6 +35,7 @@ export function billingBucketFromClaim(claim) {
|
|
|
35
35
|
// (30-min cooldown loops) exactly when the weekly window tightens.
|
|
36
36
|
case 'five_hour_overage_included':
|
|
37
37
|
case 'seven_day_overage_included':
|
|
38
|
+
case 'chatgpt_subscription':
|
|
38
39
|
return 'subscription';
|
|
39
40
|
case 'five_hour_fallback':
|
|
40
41
|
case 'seven_day_fallback':
|
|
@@ -61,7 +62,15 @@ export const SUBSCRIPTION_CLAIMS = new Set([
|
|
|
61
62
|
'seven_day_fallback',
|
|
62
63
|
'five_hour_overage_included',
|
|
63
64
|
'seven_day_overage_included',
|
|
65
|
+
// The codex engine: a request served from a ChatGPT-subscription account
|
|
66
|
+
// (dario#1009). There is no Anthropic claim header on that path; the proxy
|
|
67
|
+
// stamps this one. It is subscription billing — the user's ChatGPT plan —
|
|
68
|
+
// so it must be recognised here, or the overage guard reads it as
|
|
69
|
+
// pay-as-you-go and halts the proxy after the first GPT request.
|
|
70
|
+
'chatgpt_subscription',
|
|
64
71
|
]);
|
|
72
|
+
/** The claim the proxy stamps on codex-engine requests (see above). */
|
|
73
|
+
export const CODEX_CLAIM = 'chatgpt_subscription';
|
|
65
74
|
/**
|
|
66
75
|
* One-line per-request usage summary for verbose (-v / -vv) logs.
|
|
67
76
|
*
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Maps a bare name to a canonical id, or returns it unchanged. */
|
|
2
|
+
export type ModelResolver = (model: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* The canonical id the Claude pool would serve `model` as, or null when it
|
|
5
|
+
* cannot serve it. `bases` is the catalog base set — `getCachedBases()` at a
|
|
6
|
+
* call site, a fixture in a test. `resolve` is the alias pipeline; the default
|
|
7
|
+
* knows only catalog family shorthands, so pass the proxy's full resolver
|
|
8
|
+
* (operator aliases + pinned aliases) where one exists.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveClaudeServable(model: string, bases?: readonly string[], resolve?: ModelResolver): string | null;
|
|
11
|
+
/** Whether the Claude pool can serve `model`. See resolveClaudeServable. */
|
|
12
|
+
export declare function isClaudeServableModel(model: string, bases?: readonly string[], resolve?: ModelResolver): boolean;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Can the Claude pool actually serve this model name?" — the positive test
|
|
3
|
+
* the reverse half of failover needs, and WHAT it resolves to.
|
|
4
|
+
*
|
|
5
|
+
* Chain selection used to answer this by elimination: anything absent from the
|
|
6
|
+
* Codex slugs was handed to the Claude pool. That is not a capability test, it
|
|
7
|
+
* is a default, and it is wrong for every name that belongs to neither
|
|
8
|
+
* provider. `--pool-fallback=gpt-5.6-sol,gpt-4o` on a rate-limited
|
|
9
|
+
* subscription re-pointed the request at Anthropic as `gpt-4o` and turned a
|
|
10
|
+
* 429 into a 400 — and a typo'd slug (`claude-sonnet-6`, `gtp-5.6-sol`) did
|
|
11
|
+
* the same, silently, because a misspelling is also "absent from the slugs".
|
|
12
|
+
* A discovery outage makes it worse: `getCodexModelSlugs` degrades to an EMPTY
|
|
13
|
+
* set, at which point elimination calls EVERY chain entry Claude-servable.
|
|
14
|
+
*
|
|
15
|
+
* So the rule is positive, in two steps that mirror the request path:
|
|
16
|
+
*
|
|
17
|
+
* 1. RESOLVE the entry the way a request model is resolved — an explicit
|
|
18
|
+
* `claude:` / `anthropic:` prefix is stripped, then operator
|
|
19
|
+
* `--model-alias` values, pinned aliases (`opus48`) and catalog family
|
|
20
|
+
* shorthands (`opus`, `sonnet1m`) map to a canonical id. The caller
|
|
21
|
+
* supplies that resolver so classification and forwarding cannot
|
|
22
|
+
* disagree.
|
|
23
|
+
* 2. VALIDATE the resolved id against the catalog base set. A `claude-`
|
|
24
|
+
* prefix on its own proves nothing — `claude-sonnet-6` has the prefix and
|
|
25
|
+
* does not exist — so the id (sans `[1m]`) must be a base the pool can
|
|
26
|
+
* forward. An explicit provider prefix can force the route; it cannot
|
|
27
|
+
* make an unknown model servable.
|
|
28
|
+
*
|
|
29
|
+
* The RESOLVED id is what the caller swaps into the body. The swap happens
|
|
30
|
+
* after the proxy's own alias pass, so returning the entry as written would
|
|
31
|
+
* send an alias upstream raw and Anthropic would 400 it.
|
|
32
|
+
*
|
|
33
|
+
* Pure over the base set it is handed, so the whole selection stays testable
|
|
34
|
+
* without a socket. `bases` defaults to the baked catalog, never to empty: a
|
|
35
|
+
* cold catalog must still admit real ids, and must still refuse typos.
|
|
36
|
+
*/
|
|
37
|
+
import { BAKED_BASE_MODELS, resolveAliasAgainst } from './model-catalog.js';
|
|
38
|
+
/** Provider prefixes that force the Claude path (mirrors proxy.ts's PROVIDER_PREFIXES). */
|
|
39
|
+
const CLAUDE_PREFIXES = new Set(['claude', 'anthropic']);
|
|
40
|
+
/**
|
|
41
|
+
* The canonical id the Claude pool would serve `model` as, or null when it
|
|
42
|
+
* cannot serve it. `bases` is the catalog base set — `getCachedBases()` at a
|
|
43
|
+
* call site, a fixture in a test. `resolve` is the alias pipeline; the default
|
|
44
|
+
* knows only catalog family shorthands, so pass the proxy's full resolver
|
|
45
|
+
* (operator aliases + pinned aliases) where one exists.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveClaudeServable(model, bases = BAKED_BASE_MODELS, resolve) {
|
|
48
|
+
let m = model.trim().toLowerCase();
|
|
49
|
+
if (!m)
|
|
50
|
+
return null;
|
|
51
|
+
const idx = m.indexOf(':');
|
|
52
|
+
if (idx > 0) {
|
|
53
|
+
const prefix = m.slice(0, idx);
|
|
54
|
+
// A recognized non-Claude prefix is a definite NO, not a fall-through to
|
|
55
|
+
// the name test below — `openai:claude-sonnet-5` is the operator pointing
|
|
56
|
+
// somewhere else on purpose.
|
|
57
|
+
if (!CLAUDE_PREFIXES.has(prefix))
|
|
58
|
+
return null;
|
|
59
|
+
m = m.slice(idx + 1);
|
|
60
|
+
if (!m)
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const resolved = (resolve ? resolve(m) : (resolveAliasAgainst(m, bases) ?? m)).trim().toLowerCase();
|
|
64
|
+
const base = resolved.endsWith('[1m]') ? resolved.slice(0, -4) : resolved;
|
|
65
|
+
return bases.some((b) => b.toLowerCase() === base) ? resolved : null;
|
|
66
|
+
}
|
|
67
|
+
/** Whether the Claude pool can serve `model`. See resolveClaudeServable. */
|
|
68
|
+
export function isClaudeServableModel(model, bases = BAKED_BASE_MODELS, resolve) {
|
|
69
|
+
return resolveClaudeServable(model, bases, resolve) !== null;
|
|
70
|
+
}
|
package/dist/codex-backend.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
25
25
|
import type { CodexAccountCredentials } from './codex-accounts.js';
|
|
26
|
+
import { type ModelResolver } from './claude-model.js';
|
|
26
27
|
export declare const CODEX_BACKEND_BASE_URL: string;
|
|
27
28
|
/**
|
|
28
29
|
* Client version sent on the model-discovery call. The backend REQUIRES the
|
|
@@ -46,6 +47,22 @@ export declare function fetchCodexModels(creds: CodexAccountCredentials, fetchIm
|
|
|
46
47
|
* "route nothing here by name". An explicit `codex:`/`chatgpt:` prefix still
|
|
47
48
|
* routes, so discovery being down never makes the engine unusable.
|
|
48
49
|
*/
|
|
50
|
+
/** What the proxy learns from one forwarded codex request — enough for an
|
|
51
|
+
* analytics row and a log line. Reported once per request, on every exit
|
|
52
|
+
* that answered the client; a DECLINE (deferred to the Claude pool) reports
|
|
53
|
+
* nothing, since the Claude path records what it then serves. */
|
|
54
|
+
export interface CodexForwardOutcome {
|
|
55
|
+
status: number;
|
|
56
|
+
latencyMs: number;
|
|
57
|
+
inputTokens: number;
|
|
58
|
+
outputTokens: number;
|
|
59
|
+
stream: boolean;
|
|
60
|
+
model: string;
|
|
61
|
+
alias: string;
|
|
62
|
+
}
|
|
63
|
+
/** The cached slug list for an alias WITHOUT fetching. For the admin surface:
|
|
64
|
+
* a status read must never cost an upstream call or a token refresh. */
|
|
65
|
+
export declare function peekCodexModelSlugs(alias: string): readonly string[] | null;
|
|
49
66
|
export declare function getCodexModelSlugs(creds: CodexAccountCredentials, fetchImpl?: typeof fetch): Promise<readonly string[]>;
|
|
50
67
|
/**
|
|
51
68
|
* Whether a request naming `model` should be served from the subscription: the
|
|
@@ -69,7 +86,7 @@ export declare function isCodexModel(model: string, slugs: readonly string[]): b
|
|
|
69
86
|
* written before this release behave identically.
|
|
70
87
|
*/
|
|
71
88
|
export declare function pickCodexFallback(models: readonly string[], slugs: readonly string[]): string | null;
|
|
72
|
-
export declare function pickClaudeFallback(models: readonly string[], slugs: readonly string[]): string | null;
|
|
89
|
+
export declare function pickClaudeFallback(models: readonly string[], slugs: readonly string[], bases?: readonly string[], resolve?: ModelResolver): string | null;
|
|
73
90
|
/**
|
|
74
91
|
* Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
|
|
75
92
|
* claim. Payload only — this is reading our own token for a routing header, not
|
|
@@ -125,6 +142,14 @@ export declare function createResponsesTranslator(model: string): {
|
|
|
125
142
|
chunk(line: string): string | null;
|
|
126
143
|
/** True when the upstream stream terminated as a FAILURE. */
|
|
127
144
|
didFail(): boolean;
|
|
145
|
+
/** Token usage from the terminal event, or null if none arrived. Read by
|
|
146
|
+
* the proxy to record the request in analytics — before this, codex
|
|
147
|
+
* requests were invisible to /analytics and the request log entirely. */
|
|
148
|
+
usage(): {
|
|
149
|
+
prompt_tokens: number;
|
|
150
|
+
completion_tokens: number;
|
|
151
|
+
total_tokens: number;
|
|
152
|
+
} | null;
|
|
128
153
|
/** Everything seen so far, as one non-streaming chat.completion body. */
|
|
129
154
|
complete(): Record<string, unknown>;
|
|
130
155
|
};
|
|
@@ -179,4 +204,4 @@ export declare function buildCodexHeaders(creds: CodexAccountCredentials): Recor
|
|
|
179
204
|
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
180
205
|
* test/codex-oauth.mjs already uses.
|
|
181
206
|
*/
|
|
182
|
-
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, deferOnUnavailable?: boolean): Promise<boolean>;
|
|
207
|
+
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, deferOnUnavailable?: boolean, onDone?: (outcome: CodexForwardOutcome) => void): Promise<boolean>;
|
package/dist/codex-backend.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { anthropicToResponsesRequest, createResponsesSSEParser, formatResponsesAnthropicSSE, createAnthropicMessageAssembler, responsesStreamToAnthropicSSE, } from './anthropic-responses-translate.js';
|
|
2
|
+
import { resolveClaudeServable } from './claude-model.js';
|
|
3
|
+
import { BAKED_BASE_MODELS } from './model-catalog.js';
|
|
2
4
|
export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
|
|
3
5
|
/** Originator string the codex CLI identifies itself with. */
|
|
4
6
|
const CODEX_ORIGINATOR = 'codex_cli_rs';
|
|
@@ -59,12 +61,12 @@ export async function fetchCodexModels(creds, fetchImpl = fetch) {
|
|
|
59
61
|
}
|
|
60
62
|
return slugs;
|
|
61
63
|
}
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
/** The cached slug list for an alias WITHOUT fetching. For the admin surface:
|
|
65
|
+
* a status read must never cost an upstream call or a token refresh. */
|
|
66
|
+
export function peekCodexModelSlugs(alias) {
|
|
67
|
+
const hit = modelCache.get(alias);
|
|
68
|
+
return hit ? hit.slugs : null;
|
|
69
|
+
}
|
|
68
70
|
export async function getCodexModelSlugs(creds, fetchImpl = fetch) {
|
|
69
71
|
const hit = modelCache.get(creds.alias);
|
|
70
72
|
if (hit && Date.now() - hit.fetchedAt < hit.ttlMs)
|
|
@@ -109,21 +111,36 @@ export function isCodexModel(model, slugs) {
|
|
|
109
111
|
export function pickCodexFallback(models, slugs) {
|
|
110
112
|
return models.find(m => isCodexModel(m, slugs)) ?? null;
|
|
111
113
|
}
|
|
112
|
-
export function pickClaudeFallback(models, slugs) {
|
|
114
|
+
export function pickClaudeFallback(models, slugs, bases = BAKED_BASE_MODELS, resolve) {
|
|
113
115
|
// "Not a codex slug" is NOT the same as "the Claude pool can serve it". A
|
|
114
116
|
// typo, a retired model, or an entry meant for some third provider would all
|
|
115
117
|
// pass that test, and the request would be swapped to a model Anthropic 404s
|
|
116
118
|
// on — trading a recoverable 429 for an unrecoverable 404, which is strictly
|
|
117
119
|
// worse than not failing over at all.
|
|
118
120
|
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
121
|
+
// v6.0.0 shipped this as `/^claude/i`, which was the right DIRECTION (fail
|
|
122
|
+
// closed) and the wrong TEST. It rejected `anthropic:opus`, where the operator
|
|
123
|
+
// named the provider explicitly, and every catalog shorthand (`opus`,
|
|
124
|
+
// `sonnet1m`) — so a legitimate chain entry silently never failed over, which
|
|
125
|
+
// is the same class of quiet wrongness it was written to prevent. It also did
|
|
126
|
+
// nothing about the case where discovery degrades and `slugs` arrives EMPTY,
|
|
127
|
+
// at which point elimination calls every entry Claude-servable.
|
|
122
128
|
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
129
|
+
// `isClaudeServableModel` is the positive capability test instead, resolved
|
|
130
|
+
// against the live catalog, so aliases work and the limitation the old comment
|
|
131
|
+
// documented as accepted is simply gone.
|
|
132
|
+
//
|
|
133
|
+
// Returns the RESOLVED canonical id, not the entry as written: the caller
|
|
134
|
+
// swaps it into the body after the proxy's own alias pass has already run,
|
|
135
|
+
// so an alias returned raw would reach Anthropic unresolved and 400.
|
|
136
|
+
for (const m of models) {
|
|
137
|
+
if (isCodexModel(m, slugs))
|
|
138
|
+
continue;
|
|
139
|
+
const resolved = resolveClaudeServable(m, bases, resolve);
|
|
140
|
+
if (resolved)
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
127
144
|
}
|
|
128
145
|
/**
|
|
129
146
|
* Pull `chatgpt_account_id` out of the id_token's `https://api.openai.com/auth`
|
|
@@ -386,6 +403,12 @@ export function createResponsesTranslator(model) {
|
|
|
386
403
|
didFail() {
|
|
387
404
|
return failed;
|
|
388
405
|
},
|
|
406
|
+
/** Token usage from the terminal event, or null if none arrived. Read by
|
|
407
|
+
* the proxy to record the request in analytics — before this, codex
|
|
408
|
+
* requests were invisible to /analytics and the request log entirely. */
|
|
409
|
+
usage() {
|
|
410
|
+
return usage;
|
|
411
|
+
},
|
|
389
412
|
/** Everything seen so far, as one non-streaming chat.completion body. */
|
|
390
413
|
complete() {
|
|
391
414
|
const calls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
|
@@ -481,9 +504,23 @@ export function buildCodexHeaders(creds) {
|
|
|
481
504
|
* testable without network (test/codex-backend.mjs), matching the pattern
|
|
482
505
|
* test/codex-oauth.mjs already uses.
|
|
483
506
|
*/
|
|
484
|
-
export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false) {
|
|
507
|
+
export async function forwardToCodex(req, res, body, creds, corsOrigin, securityHeaders, upstreamTimeoutMs, verbose, shape = 'openai', fetchImpl = fetch, deferOnUnavailable = false, onDone) {
|
|
485
508
|
void req;
|
|
486
509
|
const isAnthropic = shape === 'anthropic';
|
|
510
|
+
// Reported exactly once, on every exit that answered the client. Without
|
|
511
|
+
// this the proxy had no idea a codex request happened: no analytics row, no
|
|
512
|
+
// log line, no per-account count.
|
|
513
|
+
const startedAt = Date.now();
|
|
514
|
+
let reported = false;
|
|
515
|
+
const report = (status, usage, stream, model) => {
|
|
516
|
+
if (reported || !onDone)
|
|
517
|
+
return;
|
|
518
|
+
reported = true;
|
|
519
|
+
try {
|
|
520
|
+
onDone({ status, latencyMs: Date.now() - startedAt, inputTokens: usage?.input ?? 0, outputTokens: usage?.output ?? 0, stream, model, alias: creds.alias });
|
|
521
|
+
}
|
|
522
|
+
catch { /* a reporting failure must never break a served request */ }
|
|
523
|
+
};
|
|
487
524
|
// An Anthropic-shape error body is {type,error{type,message}}; an OpenAI one
|
|
488
525
|
// is {error}. A client SDK reads its own shape, so errors follow the request.
|
|
489
526
|
const errBody = (message, extra = {}) => JSON.stringify(isAnthropic
|
|
@@ -496,6 +533,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
496
533
|
catch {
|
|
497
534
|
res.writeHead(400, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
498
535
|
res.end(errBody(`Codex backend requires a JSON ${isAnthropic ? 'messages' : 'chat/completions'} body`));
|
|
536
|
+
report(400, null, false, '');
|
|
499
537
|
return true;
|
|
500
538
|
}
|
|
501
539
|
const clientWantsStream = parsed.stream === true;
|
|
@@ -532,6 +570,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
532
570
|
}
|
|
533
571
|
res.writeHead(upstream.status, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
534
572
|
res.end(errBody('Upstream Codex backend error', { status: upstream.status, account: creds.alias }));
|
|
573
|
+
report(upstream.status, null, clientWantsStream, model);
|
|
535
574
|
return true;
|
|
536
575
|
}
|
|
537
576
|
// OpenAI shape: one stateful line-in/line-out translator (unchanged).
|
|
@@ -625,6 +664,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
625
664
|
console.error(`[dario] codex backend (${creds.alias}) response failed: ${detail}`);
|
|
626
665
|
res.writeHead(502, { 'Content-Type': 'application/json', ...securityHeaders });
|
|
627
666
|
res.end(errBody(`Codex backend response failed: ${detail}`, { account: creds.alias }));
|
|
667
|
+
report(502, null, clientWantsStream, model);
|
|
628
668
|
return true;
|
|
629
669
|
}
|
|
630
670
|
if (isAnthropic) {
|
|
@@ -654,6 +694,16 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
654
694
|
});
|
|
655
695
|
res.end(JSON.stringify(translator.complete()));
|
|
656
696
|
}
|
|
697
|
+
// Token usage rides the terminal Responses event on either shape. A stream
|
|
698
|
+
// that failed upstream still ended as 200 on the wire (the client saw the
|
|
699
|
+
// failure event); analytics must count it as the 502 it was.
|
|
700
|
+
{
|
|
701
|
+
const tr = terminalResponse;
|
|
702
|
+
const usage = isAnthropic
|
|
703
|
+
? (tr?.usage ? { input: Number(tr.usage.input_tokens ?? 0), output: Number(tr.usage.output_tokens ?? 0) } : null)
|
|
704
|
+
: (() => { const u = translator.usage(); return u ? { input: u.prompt_tokens, output: u.completion_tokens } : null; })();
|
|
705
|
+
report(upstreamFailed ? 502 : 200, usage, clientWantsStream, model);
|
|
706
|
+
}
|
|
657
707
|
return true;
|
|
658
708
|
}
|
|
659
709
|
catch (err) {
|
|
@@ -685,6 +735,7 @@ export async function forwardToCodex(req, res, body, creds, corsOrigin, security
|
|
|
685
735
|
}
|
|
686
736
|
catch { /* already closed */ }
|
|
687
737
|
}
|
|
738
|
+
report(502, null, clientWantsStream, model);
|
|
688
739
|
return true;
|
|
689
740
|
}
|
|
690
741
|
finally {
|
package/dist/proxy.js
CHANGED
|
@@ -13,16 +13,16 @@ import { buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuf
|
|
|
13
13
|
import { stampCch, hasCchSeed } from './cch.js';
|
|
14
14
|
import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
|
|
15
15
|
import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reconcilePoolAccounts, resolvePoolStrategy, utilFreshness } from './pool.js';
|
|
16
|
-
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS } from './analytics.js';
|
|
16
|
+
import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
|
|
17
17
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
18
|
import { notify as osNotify } from './notify.js';
|
|
19
19
|
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
|
|
20
20
|
import { handleAdminRequest } from './admin-api.js';
|
|
21
21
|
import { createTokenBucket } from './rate-limit.js';
|
|
22
22
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
23
|
-
import { forwardToCodex, getCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
23
|
+
import { forwardToCodex, getCodexModelSlugs, peekCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
|
|
24
24
|
import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
|
|
25
|
-
import { listCodexAccountAliases, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
|
|
25
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
|
|
26
26
|
import { route as routeProvider } from './provider-adapter.js';
|
|
27
27
|
import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
|
|
28
28
|
import { redactSecrets } from './redact.js';
|
|
@@ -1324,6 +1324,9 @@ export async function startProxy(opts = {}) {
|
|
|
1324
1324
|
// : null` — that gated the /analytics endpoint, but burn-rate /
|
|
1325
1325
|
// per-request visibility is useful for a pool of one too.
|
|
1326
1326
|
const analytics = new Analytics();
|
|
1327
|
+
// Per-alias request counts for GET /codex — the pool has requestCount per
|
|
1328
|
+
// account; the codex accounts had nothing until now.
|
|
1329
|
+
const codexRequestCounts = new Map();
|
|
1327
1330
|
// Overage-guard (v4.1, dario#288). Resolved from opts with built-in
|
|
1328
1331
|
// defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
|
|
1329
1332
|
// so an opts-less proxy still gets protection. The notifier is wired
|
|
@@ -2105,6 +2108,29 @@ export async function startProxy(opts = {}) {
|
|
|
2105
2108
|
}
|
|
2106
2109
|
// Analytics endpoint — rolling-window summary + burn-rate snapshot.
|
|
2107
2110
|
// Always-on as of v4 (pre-v4 this was gated to pool mode).
|
|
2111
|
+
// The codex engine, for the admin surface. Key-gated like /accounts. Reads
|
|
2112
|
+
// only what is already on disk and in the model cache: no upstream call,
|
|
2113
|
+
// no token refresh, and no token in the answer — a status read must never
|
|
2114
|
+
// spend or expose a credential.
|
|
2115
|
+
if (urlPath === '/codex' && req.method === 'GET') {
|
|
2116
|
+
const now = Date.now();
|
|
2117
|
+
const stored = await loadAllCodexAccounts();
|
|
2118
|
+
const accounts = stored.map((a) => ({
|
|
2119
|
+
alias: a.alias,
|
|
2120
|
+
expiresAt: a.expiresAt,
|
|
2121
|
+
expiresInMs: Math.max(0, a.expiresAt - now),
|
|
2122
|
+
needsRefresh: codexAccountNeedsRefresh(a),
|
|
2123
|
+
models: peekCodexModelSlugs(a.alias) ?? [],
|
|
2124
|
+
requestCount: codexRequestCounts.get(a.alias) ?? 0,
|
|
2125
|
+
}));
|
|
2126
|
+
res.writeHead(200, JSON_HEADERS);
|
|
2127
|
+
res.end(JSON.stringify({
|
|
2128
|
+
backend: CODEX_BACKEND_BASE_URL,
|
|
2129
|
+
requests: [...codexRequestCounts.values()].reduce((n, c) => n + c, 0),
|
|
2130
|
+
accounts,
|
|
2131
|
+
}));
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2108
2134
|
if (urlPath === '/analytics' && req.method === 'GET') {
|
|
2109
2135
|
res.writeHead(200, JSON_HEADERS);
|
|
2110
2136
|
// `queue` rides along the summary (dario#905): request-queue.ts always
|
|
@@ -2680,9 +2706,42 @@ export async function startProxy(opts = {}) {
|
|
|
2680
2706
|
// and pick the request back up on the Claude path below. Before
|
|
2681
2707
|
// this, a rate-limited ChatGPT plan was terminal for a gpt-bound
|
|
2682
2708
|
// request even with an idle Claude pool sitting right beside it.
|
|
2683
|
-
|
|
2709
|
+
// Resolve chain entries exactly as a request model is resolved —
|
|
2710
|
+
// operator --model-alias first, then pinned + catalog aliases — so
|
|
2711
|
+
// `backup` or `opus48` in the chain works the way it does on a
|
|
2712
|
+
// request, and the classifier validates the id that would actually
|
|
2713
|
+
// be forwarded.
|
|
2714
|
+
const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels, getCachedBases(), (m) => resolveClaudeAlias(applyModelAlias(m, modelAliases) ?? m));
|
|
2684
2715
|
const canDefer = claudeTarget !== null && pool.size > 0 && !upstreamApiKey;
|
|
2685
|
-
const
|
|
2716
|
+
const codexReq = requestCount;
|
|
2717
|
+
const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer,
|
|
2718
|
+
// Before this hook a codex request left no trace: nothing in
|
|
2719
|
+
// /analytics, nothing in the request log, no per-account count.
|
|
2720
|
+
// The dock (and anyone reading /analytics) saw a proxy that
|
|
2721
|
+
// served GPT all day and reported zero of it. A decline (the
|
|
2722
|
+
// request handed to the Claude pool) reports nothing here; the
|
|
2723
|
+
// Claude path records what it then serves.
|
|
2724
|
+
(o) => {
|
|
2725
|
+
codexRequestCounts.set(o.alias, (codexRequestCounts.get(o.alias) ?? 0) + 1);
|
|
2726
|
+
analytics.record({
|
|
2727
|
+
timestamp: Date.now(),
|
|
2728
|
+
account: o.alias,
|
|
2729
|
+
model: o.model || rawModel || 'codex',
|
|
2730
|
+
inputTokens: o.inputTokens, outputTokens: o.outputTokens,
|
|
2731
|
+
cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
2732
|
+
// No Anthropic rate-limit headers on this path; the claim
|
|
2733
|
+
// names the engine and is subscription billing, so the
|
|
2734
|
+
// overage guard (#288) leaves it alone.
|
|
2735
|
+
claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
|
|
2736
|
+
latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
|
|
2737
|
+
});
|
|
2738
|
+
writeLogLine(logFileStream, {
|
|
2739
|
+
ts: new Date().toISOString(), req: codexReq,
|
|
2740
|
+
method: req.method ?? '', path: urlPath, model: o.model || rawModel || undefined,
|
|
2741
|
+
status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
|
|
2742
|
+
claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, stream: o.stream,
|
|
2743
|
+
});
|
|
2744
|
+
});
|
|
2686
2745
|
if (served)
|
|
2687
2746
|
return;
|
|
2688
2747
|
const swapped = buildPoolFallbackBody(body, claudeTarget);
|
|
@@ -59,7 +59,7 @@ Neither subscription hitting its ceiling can take the deployment down on its own
|
|
|
59
59
|
Deliberate limits:
|
|
60
60
|
|
|
61
61
|
- **Only a 429 or 5xx fails over.** A 400 surfaces to the client. A bad request that fails over just reproduces itself on the other provider and buries the real cause.
|
|
62
|
-
- **The Claude entry
|
|
62
|
+
- **The Claude entry is validated positively, against the live catalog.** "Not a codex slug" would also match a typo, or a model meant for a third provider, and swapping that in trades a recoverable 429 for an unrecoverable 404. Worse, when model discovery degrades the codex slug list arrives EMPTY, at which point elimination would call *every* entry Claude-servable. So each entry is tested for what it is rather than what it isn't: canonical ids, `[1m]` variants, catalog shorthands (`opus`, `sonnet1m`) and explicit `claude:` / `anthropic:` prefixes all qualify; anything else is skipped and the real error surfaces.
|
|
63
63
|
- **The api-key backend is still OpenAI-shape only.** There is no Messages translation on that route. A Codex account has one, which is why it is preferred.
|
|
64
64
|
- **Never silent.** Every substituted response carries `x-dario-pool-fallback: <model>`. A quietly swapped model is exactly the surprise this project exists to avoid.
|
|
65
65
|
- **Empty pool still errors.** A pool with zero accounts is a setup mistake (`dario login` never ran); that returns the usual 503 rather than silently re-billing every request to another provider.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.2",
|
|
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": {
|