@askalf/dario 5.5.89 → 6.0.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 +61 -8
- package/dist/cli.js +67 -13
- package/dist/codex-backend.d.ts +44 -1
- package/dist/codex-backend.js +100 -16
- package/dist/compare.d.ts +110 -0
- package/dist/compare.js +210 -0
- package/dist/config-file.d.ts +6 -5
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.js +71 -0
- package/dist/provider-adapter.d.ts +31 -0
- package/dist/provider-adapter.js +36 -14
- package/dist/proxy.d.ts +15 -9
- package/dist/proxy.js +244 -42
- package/docs/commands.md +2 -2
- package/docs/multi-account-pool.md +30 -4
- package/package.json +1 -1
package/dist/compare.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow compare (v6.0.0) — run one prompt past a second model family and keep
|
|
3
|
+
* both answers, without the client ever knowing.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SHAPE. Once dario can serve either wire shape from either
|
|
6
|
+
* subscription, the obvious question stops being "can I reach GPT" and becomes
|
|
7
|
+
* "which of these two is actually better at MY work". Answering that from
|
|
8
|
+
* benchmarks is close to worthless; answering it from your own real traffic is
|
|
9
|
+
* not. So a compare is triggered per-request by a header, on prompts you were
|
|
10
|
+
* sending anyway.
|
|
11
|
+
*
|
|
12
|
+
* THE CLIENT IS NEVER AFFECTED. The primary answer streams through untouched
|
|
13
|
+
* and the comparison runs beside it — the request is not held open for it, and
|
|
14
|
+
* a compare that fails, times out, or has nowhere to go is dropped silently
|
|
15
|
+
* with the record still written. A diagnostic that can degrade the thing it is
|
|
16
|
+
* measuring is worse than no diagnostic.
|
|
17
|
+
*
|
|
18
|
+
* BOTH SIDES ARE RECORDED IN THE CLIENT'S OWN WIRE SHAPE. The comparison is
|
|
19
|
+
* dispatched through the same forwardToCodex translation the real path uses, so
|
|
20
|
+
* an Anthropic-shape request yields two Anthropic-shape answers. Comparing a
|
|
21
|
+
* Messages response against a raw Responses payload would mean eyeballing
|
|
22
|
+
* across two formats and calling the difference a model difference.
|
|
23
|
+
*
|
|
24
|
+
* Raw payloads are stored rather than extracted text: extraction is exactly
|
|
25
|
+
* where a subtle bug would quietly make two answers look more alike than they
|
|
26
|
+
* are, and this release was written off the back of five failures that read as
|
|
27
|
+
* successes.
|
|
28
|
+
*/
|
|
29
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { forwardToCodex, getCodexModelSlugs, isCodexModel } from './codex-backend.js';
|
|
33
|
+
import { hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
|
|
34
|
+
/** Request header that arms a comparison: `x-dario-compare: <model>`. */
|
|
35
|
+
export const COMPARE_HEADER = 'x-dario-compare';
|
|
36
|
+
/**
|
|
37
|
+
* Response header naming the model a comparison was REQUESTED against.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately not "compared-with". The header has to be set before the primary
|
|
40
|
+
* response starts streaming, which is strictly earlier than we can know whether
|
|
41
|
+
* the comparison ran — it may still be skipped for a missing account, an
|
|
42
|
+
* unlisted model, or an unparseable body. A header claiming a comparison
|
|
43
|
+
* happened would therefore sometimes be a lie, which is the exact failure this
|
|
44
|
+
* release exists to stamp out. The name now states only what is true at the
|
|
45
|
+
* moment it is written; whether it actually ran is in the record.
|
|
46
|
+
*/
|
|
47
|
+
export const COMPARE_RESULT_HEADER = 'x-dario-compare-requested';
|
|
48
|
+
export const COMPARE_DIR = join(homedir(), '.dario', 'compare');
|
|
49
|
+
/**
|
|
50
|
+
* Read the compare target from request headers. Returns null when unarmed, and
|
|
51
|
+
* for the empty value, so `-H 'x-dario-compare:'` reliably means "off" rather
|
|
52
|
+
* than "compare against a model named empty string".
|
|
53
|
+
*/
|
|
54
|
+
export function readCompareTarget(headers) {
|
|
55
|
+
const raw = headers[COMPARE_HEADER] ?? headers[COMPARE_HEADER.toUpperCase()];
|
|
56
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
57
|
+
if (typeof value !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
return value.trim() || null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Swap the model in a JSON request body. Returns null when the body is not
|
|
63
|
+
* JSON — the caller then skips the comparison rather than sending something it
|
|
64
|
+
* could not read.
|
|
65
|
+
*/
|
|
66
|
+
export function withModel(body, model) {
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(body.toString());
|
|
69
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
70
|
+
return null;
|
|
71
|
+
// A comparison is always non-streaming: nobody is watching it arrive, and a
|
|
72
|
+
// single JSON body is far easier to diff later than two SSE transcripts.
|
|
73
|
+
return Buffer.from(JSON.stringify({ ...parsed, model, stream: false }));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Tee everything written to a real response into a buffer, leaving delivery
|
|
81
|
+
* completely unchanged. `captured()` is meaningful once the response finishes.
|
|
82
|
+
*/
|
|
83
|
+
export function teeResponse(res) {
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
const chunks = [];
|
|
86
|
+
const origWrite = res.write.bind(res);
|
|
87
|
+
const origEnd = res.end.bind(res);
|
|
88
|
+
res.write = ((chunk, ...rest) => {
|
|
89
|
+
if (typeof chunk === 'string')
|
|
90
|
+
chunks.push(chunk);
|
|
91
|
+
else if (Buffer.isBuffer(chunk))
|
|
92
|
+
chunks.push(chunk.toString('utf-8'));
|
|
93
|
+
return origWrite(chunk, ...rest);
|
|
94
|
+
});
|
|
95
|
+
res.end = ((chunk, ...rest) => {
|
|
96
|
+
if (typeof chunk === 'string')
|
|
97
|
+
chunks.push(chunk);
|
|
98
|
+
else if (Buffer.isBuffer(chunk))
|
|
99
|
+
chunks.push(chunk.toString('utf-8'));
|
|
100
|
+
return origEnd(chunk, ...rest);
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
captured: () => ({
|
|
104
|
+
status: res.statusCode ?? null,
|
|
105
|
+
body: chunks.join(''),
|
|
106
|
+
ms: Date.now() - started,
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A ServerResponse stand-in that keeps what was written instead of sending it.
|
|
112
|
+
* The comparison has no socket of its own — it is answering nobody.
|
|
113
|
+
*/
|
|
114
|
+
function captureSink() {
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
const state = { status: null, chunks: [], headersSent: false };
|
|
117
|
+
const sink = {
|
|
118
|
+
statusCode: 0,
|
|
119
|
+
headersSent: false,
|
|
120
|
+
writeHead(code) { state.status = code; state.headersSent = true; this.headersSent = true; return this; },
|
|
121
|
+
setHeader() { return this; },
|
|
122
|
+
write(s) { if (s !== undefined && s !== null)
|
|
123
|
+
state.chunks.push(String(s)); return true; },
|
|
124
|
+
end(s) { if (s !== undefined && s !== null)
|
|
125
|
+
state.chunks.push(String(s)); return this; },
|
|
126
|
+
on() { return this; },
|
|
127
|
+
once() { return this; },
|
|
128
|
+
emit() { return false; },
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
sink: sink,
|
|
132
|
+
side: () => ({
|
|
133
|
+
status: state.status,
|
|
134
|
+
body: state.chunks.join(''),
|
|
135
|
+
ms: Date.now() - started,
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Run the comparison against the ChatGPT subscription.
|
|
141
|
+
*
|
|
142
|
+
* Resolves to a reason-for-skipping string when it declines, never throwing:
|
|
143
|
+
* the caller is on the success path of a request it has already answered, and
|
|
144
|
+
* a diagnostic must not be able to take that down.
|
|
145
|
+
*
|
|
146
|
+
* v6.0.0 compares against a Codex account only. Comparing against the Claude
|
|
147
|
+
* pool would mean occupying a seat for a request nobody is waiting on, which is
|
|
148
|
+
* a trade worth making deliberately rather than by default.
|
|
149
|
+
*/
|
|
150
|
+
export async function runCompare(opts) {
|
|
151
|
+
try {
|
|
152
|
+
if (!(await hasAnyCodexAccount()))
|
|
153
|
+
return { side: null, skipped: 'no Codex account configured' };
|
|
154
|
+
const stored = await selectCodexAccount();
|
|
155
|
+
if (!stored)
|
|
156
|
+
return { side: null, skipped: 'no Codex account configured' };
|
|
157
|
+
const creds = await getFreshCodexAccount(stored);
|
|
158
|
+
const slugs = await getCodexModelSlugs(creds).catch(() => []);
|
|
159
|
+
if (!isCodexModel(opts.targetModel, slugs)) {
|
|
160
|
+
return { side: null, skipped: `${opts.targetModel} is not served by Codex account ${creds.alias}` };
|
|
161
|
+
}
|
|
162
|
+
const body = withModel(opts.body, opts.targetModel);
|
|
163
|
+
if (!body)
|
|
164
|
+
return { side: null, skipped: 'request body is not JSON' };
|
|
165
|
+
const { sink, side } = captureSink();
|
|
166
|
+
await forwardToCodex({}, sink, body, creds, opts.corsOrigin, {}, opts.timeoutMs, opts.verbose, opts.shape);
|
|
167
|
+
return { side: side() };
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
return { side: null, skipped: `compare failed: ${err.message}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Persist one record. Returns the path written, or null on failure — a compare
|
|
175
|
+
* log that cannot be written is not worth failing a served request over.
|
|
176
|
+
*/
|
|
177
|
+
export function writeCompareRecord(record, dir = COMPARE_DIR) {
|
|
178
|
+
try {
|
|
179
|
+
mkdirSync(dir, { recursive: true });
|
|
180
|
+
const stamp = record.ts.replace(/[:.]/g, '-');
|
|
181
|
+
// Separators out, then dot-runs collapsed. Stripping separators alone
|
|
182
|
+
// already prevents traversal, but leaving `..` in a filename invites the
|
|
183
|
+
// next reader to assume it was never considered.
|
|
184
|
+
const safeModel = record.comparedModel.replace(/[^\w.-]/g, '_').replace(/\.{2,}/g, '.');
|
|
185
|
+
const base = join(dir, `${stamp}-${safeModel}`);
|
|
186
|
+
const payload = JSON.stringify(record, null, 2);
|
|
187
|
+
// `wx` fails when the file already exists, so two comparisons landing in the
|
|
188
|
+
// same millisecond get distinct files instead of one silently overwriting
|
|
189
|
+
// the other. Exclusive-create also holds ACROSS PROCESSES, which a
|
|
190
|
+
// read-then-write existence check would not: two dario instances sharing a
|
|
191
|
+
// compare directory is an ordinary setup, and losing half a calibration run
|
|
192
|
+
// to a race would be invisible — the log would simply have fewer records
|
|
193
|
+
// than requests, with nothing to indicate why.
|
|
194
|
+
for (let n = 0; n < 50; n++) {
|
|
195
|
+
const path = n === 0 ? `${base}.json` : `${base}-${n + 1}.json`;
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(path, payload, { encoding: 'utf-8', flag: 'wx' });
|
|
198
|
+
return path;
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
if (err.code !== 'EEXIST')
|
|
202
|
+
throw err;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
package/dist/config-file.d.ts
CHANGED
|
@@ -92,11 +92,12 @@ export interface DarioConfig {
|
|
|
92
92
|
effort?: string | null;
|
|
93
93
|
maxTokens?: number | 'client' | null;
|
|
94
94
|
/**
|
|
95
|
-
* Pool-exhausted fallback. When `model` is a non-empty string
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* (
|
|
99
|
-
* 429/503.
|
|
95
|
+
* Pool-exhausted fallback. When `model` is a non-empty string, a request
|
|
96
|
+
* the Claude pool can't serve is forwarded as `model` to whichever
|
|
97
|
+
* provider can serve it — a stored Codex/ChatGPT subscription that lists
|
|
98
|
+
* it (either wire shape), otherwise a configured openai-compat backend
|
|
99
|
+
* (OpenAI shape only) — instead of surfacing the 429/503. The response
|
|
100
|
+
* is marked `x-dario-pool-fallback`. Null/absent = off.
|
|
100
101
|
*/
|
|
101
102
|
poolFallback?: {
|
|
102
103
|
model?: string | null;
|
package/dist/doctor.d.ts
CHANGED
|
@@ -19,6 +19,31 @@ export interface Check {
|
|
|
19
19
|
/** Right-column detail — human readable, may include versions, paths, counts. */
|
|
20
20
|
detail: string;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Decide what `doctor` should say about pool-exhaustion failover, from
|
|
24
|
+
* configuration alone. Pure and exported so every branch is testable — the
|
|
25
|
+
* live box can only ever exercise the one that matches its own credentials,
|
|
26
|
+
* which is how the inert case went unnoticed in the first place.
|
|
27
|
+
*
|
|
28
|
+
* This check exists because of a specific outage. The box ran with
|
|
29
|
+
* --pool-fallback armed, no Codex account and no api-key backend, so failover
|
|
30
|
+
* was INERT: correctly configured by every check that existed, and incapable of
|
|
31
|
+
* doing anything. On 2026-08-29 the Claude pool filled twice and the fleet went
|
|
32
|
+
* dark beside an idle ChatGPT subscription. Nothing reported it, because
|
|
33
|
+
* nothing asked "armed" and "has somewhere to go" as a single question.
|
|
34
|
+
*
|
|
35
|
+
* It reports configuration, never reachability. Claiming a route WORKS needs a
|
|
36
|
+
* live request, and this release was built on the lesson that a green config is
|
|
37
|
+
* not a working path.
|
|
38
|
+
*/
|
|
39
|
+
export declare function failoverReadiness(input: {
|
|
40
|
+
chain: readonly string[];
|
|
41
|
+
codexAccounts: number;
|
|
42
|
+
backends: readonly string[];
|
|
43
|
+
}): {
|
|
44
|
+
status: CheckStatus;
|
|
45
|
+
detail: string;
|
|
46
|
+
};
|
|
22
47
|
/**
|
|
23
48
|
* Format a epoch timestamp reset time relative to the current time.
|
|
24
49
|
* Returns a human-friendly string like "1h 9m", "45m", "2d 3h".
|
package/dist/doctor.js
CHANGED
|
@@ -22,6 +22,57 @@ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
|
|
|
22
22
|
import { runAuthorizeProbe } from './cc-authorize-probe.js';
|
|
23
23
|
import { MIGRATED_LOGIN_ALIAS } from './accounts.js';
|
|
24
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
/**
|
|
26
|
+
* Decide what `doctor` should say about pool-exhaustion failover, from
|
|
27
|
+
* configuration alone. Pure and exported so every branch is testable — the
|
|
28
|
+
* live box can only ever exercise the one that matches its own credentials,
|
|
29
|
+
* which is how the inert case went unnoticed in the first place.
|
|
30
|
+
*
|
|
31
|
+
* This check exists because of a specific outage. The box ran with
|
|
32
|
+
* --pool-fallback armed, no Codex account and no api-key backend, so failover
|
|
33
|
+
* was INERT: correctly configured by every check that existed, and incapable of
|
|
34
|
+
* doing anything. On 2026-08-29 the Claude pool filled twice and the fleet went
|
|
35
|
+
* dark beside an idle ChatGPT subscription. Nothing reported it, because
|
|
36
|
+
* nothing asked "armed" and "has somewhere to go" as a single question.
|
|
37
|
+
*
|
|
38
|
+
* It reports configuration, never reachability. Claiming a route WORKS needs a
|
|
39
|
+
* live request, and this release was built on the lesson that a green config is
|
|
40
|
+
* not a working path.
|
|
41
|
+
*/
|
|
42
|
+
export function failoverReadiness(input) {
|
|
43
|
+
const { chain, codexAccounts, backends } = input;
|
|
44
|
+
const hasCodex = codexAccounts > 0;
|
|
45
|
+
const hasBackend = backends.length > 0;
|
|
46
|
+
if (chain.length === 0) {
|
|
47
|
+
return { status: 'info', detail: 'off — a drained Claude pool returns 429/503 (--pool-fallback to arm)' };
|
|
48
|
+
}
|
|
49
|
+
if (!hasCodex && !hasBackend) {
|
|
50
|
+
return {
|
|
51
|
+
status: 'warn',
|
|
52
|
+
detail: `armed (${chain.join(' → ')}) but INERT — no Codex account and no backend to fall back to. `
|
|
53
|
+
+ 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (hasCodex && chain.length > 1) {
|
|
57
|
+
return {
|
|
58
|
+
status: 'ok',
|
|
59
|
+
detail: `symmetric: ${chain.join(' → ')}, across ${codexAccounts} Codex account`
|
|
60
|
+
+ `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (hasCodex) {
|
|
64
|
+
return {
|
|
65
|
+
status: 'ok',
|
|
66
|
+
detail: `claude → codex as ${chain[0]} (both wire shapes). One-way — add a Claude model to `
|
|
67
|
+
+ 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
status: 'ok',
|
|
72
|
+
detail: `claude → ${backends[0]} as ${chain[0]} — OpenAI path only. A Codex account would `
|
|
73
|
+
+ 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
25
76
|
/**
|
|
26
77
|
* Format a epoch timestamp reset time relative to the current time.
|
|
27
78
|
* Returns a human-friendly string like "1h 9m", "45m", "2d 3h".
|
|
@@ -951,6 +1002,26 @@ export async function runChecks(opts = {}) {
|
|
|
951
1002
|
catch (err) {
|
|
952
1003
|
checks.push({ status: 'warn', label: 'Backends', detail: `check failed: ${err.message}` });
|
|
953
1004
|
}
|
|
1005
|
+
// ---- Failover readiness (v6.0.0) — see failoverReadiness() for the why.
|
|
1006
|
+
try {
|
|
1007
|
+
const { loadConfig } = await import('./config-file.js');
|
|
1008
|
+
const { listCodexAccountAliases } = await import('./codex-accounts.js');
|
|
1009
|
+
const { listBackends } = await import('./openai-backend.js');
|
|
1010
|
+
const cfg = loadConfig().config;
|
|
1011
|
+
const raw = (process.env.DARIO_POOL_FALLBACK ?? cfg.poolFallback?.model ?? '').trim();
|
|
1012
|
+
const chain = raw.split(',').map((m) => m.trim()).filter(Boolean);
|
|
1013
|
+
const codexAliases = await listCodexAccountAliases().catch(() => []);
|
|
1014
|
+
const backends = await listBackends().catch(() => []);
|
|
1015
|
+
const verdict = failoverReadiness({
|
|
1016
|
+
chain,
|
|
1017
|
+
codexAccounts: codexAliases.length,
|
|
1018
|
+
backends: backends.map((b) => b.name),
|
|
1019
|
+
});
|
|
1020
|
+
checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
checks.push({ status: 'warn', label: 'Failover', detail: `check failed: ${err.message}` });
|
|
1024
|
+
}
|
|
954
1025
|
// ---- CC sub-agent (v3.26, direction #2)
|
|
955
1026
|
try {
|
|
956
1027
|
const { loadSubagentStatus } = await import('./subagent.js');
|
|
@@ -99,4 +99,35 @@ export declare const DEFAULT_ADAPTERS: readonly ProviderAdapter[];
|
|
|
99
99
|
* because it's a cross-adapter relationship (a Claude-primary request that
|
|
100
100
|
* spills to openai on pool exhaustion), not a primary claim by either side.
|
|
101
101
|
*/
|
|
102
|
+
/**
|
|
103
|
+
* Who serves a request the Claude pool could not, and on which wire shapes.
|
|
104
|
+
*
|
|
105
|
+
* This exists because v6.0.0 shipped the failover dispatcher correctly and the
|
|
106
|
+
* GATE in front of it wrongly. `selectPoolAccount()` in proxy.ts still required
|
|
107
|
+
* an api-key backend AND the OpenAI path before it would defer, so a box with a
|
|
108
|
+
* Codex account and no api-key backend — the deployment this release is FOR —
|
|
109
|
+
* got a 503 before the dispatcher ran, and Anthropic-shape requests never
|
|
110
|
+
* reached it at all. Every routing test passed, because not one of them went
|
|
111
|
+
* through that selector.
|
|
112
|
+
*
|
|
113
|
+
* So the matrix lives here, in one place, stated once:
|
|
114
|
+
*
|
|
115
|
+
* codex account lists the model → 'codex' (both wire shapes)
|
|
116
|
+
* else api-key backend, OpenAI path → 'openai' (no Messages translation)
|
|
117
|
+
* else → 'unavailable' (honest 503)
|
|
118
|
+
*
|
|
119
|
+
* A caveat worth keeping in view: this is a SPECIFICATION test surface, not a
|
|
120
|
+
* wiring one. It cannot prove proxy.ts asks it the right question at the right
|
|
121
|
+
* moment — that is precisely what broke — and only a live request through the
|
|
122
|
+
* selector can. Treat green here as necessary, never sufficient.
|
|
123
|
+
*/
|
|
124
|
+
export type PoolFallbackOutcome = 'codex' | 'openai' | 'unavailable';
|
|
125
|
+
export declare function poolFallbackOutcome(input: {
|
|
126
|
+
fallbackModels: readonly string[];
|
|
127
|
+
poolSize: number;
|
|
128
|
+
/** A stored Codex account LISTS one of `fallbackModels`. */
|
|
129
|
+
codexServes: boolean;
|
|
130
|
+
hasOpenAIBackend: boolean;
|
|
131
|
+
isOpenAIPath: boolean;
|
|
132
|
+
}): PoolFallbackOutcome;
|
|
102
133
|
export declare function route(ctx: RouteContext, adapters?: readonly ProviderAdapter[]): RouteDecision;
|
package/dist/provider-adapter.js
CHANGED
|
@@ -87,25 +87,47 @@ export const claudeAdapter = {
|
|
|
87
87
|
},
|
|
88
88
|
};
|
|
89
89
|
export const DEFAULT_ADAPTERS = [codexAdapter, openaiAdapter, claudeAdapter];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
export function poolFallbackOutcome(input) {
|
|
91
|
+
const { fallbackModels, poolSize, codexServes, hasOpenAIBackend, isOpenAIPath } = input;
|
|
92
|
+
// Unarmed, or an empty pool, is not a failover situation at all: an empty
|
|
93
|
+
// pool is a setup error the operator must see, not traffic to re-bill.
|
|
94
|
+
if (fallbackModels.length === 0 || poolSize === 0)
|
|
95
|
+
return 'unavailable';
|
|
96
|
+
if (codexServes)
|
|
97
|
+
return 'codex';
|
|
98
|
+
if (hasOpenAIBackend && isOpenAIPath)
|
|
99
|
+
return 'openai';
|
|
100
|
+
return 'unavailable';
|
|
101
|
+
}
|
|
97
102
|
export function route(ctx, adapters = DEFAULT_ADAPTERS) {
|
|
98
103
|
const ordered = [...adapters].sort((a, b) => b.priority - a.priority);
|
|
99
104
|
const primary = ordered.find((a) => a.claimsPrimary(ctx)) ?? claudeAdapter;
|
|
100
105
|
let fallback = null;
|
|
101
106
|
let reason = `${primary.id} primary`;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// NOTE: this reports the CLAUDE-PRIMARY direction. The reverse — a codex
|
|
108
|
+
// primary declining a 429/5xx so the request lands on the Claude pool — is
|
|
109
|
+
// dispatched in proxy.ts, because it is a mid-flight decision that depends
|
|
110
|
+
// on the upstream's answer rather than on anything knowable when routing.
|
|
111
|
+
//
|
|
112
|
+
// Pool-exhaustion failover (v6.0.0). The target is whichever provider can
|
|
113
|
+
// actually serve `poolFallbackModel`, which makes a SECOND SUBSCRIPTION a
|
|
114
|
+
// first-class failover target rather than requiring an API key.
|
|
115
|
+
//
|
|
116
|
+
// codex is preferred when the nominated model is one its account lists: it
|
|
117
|
+
// is a plan you already pay for, so failover costs nothing per token, and
|
|
118
|
+
// since v5.5.87 it serves BOTH wire shapes — an Anthropic-shape client
|
|
119
|
+
// (Claude Code, any Anthropic SDK, a fleet agent) can fail over too. The
|
|
120
|
+
// api-key backend keeps its OpenAI-path guard: there is still no Messages
|
|
121
|
+
// translation on that route.
|
|
122
|
+
if (primary.id === 'claude' && ctx.poolFallbackModel !== null && ctx.poolSize > 0) {
|
|
123
|
+
if (ctx.hasCodexAccount && isCodexModel(ctx.poolFallbackModel, ctx.codexModels)) {
|
|
124
|
+
fallback = 'codex';
|
|
125
|
+
reason = 'claude primary, codex (subscription) fallback on pool-exhaustion';
|
|
126
|
+
}
|
|
127
|
+
else if (ctx.hasOpenAIBackend && ctx.isOpenAIPath) {
|
|
128
|
+
fallback = 'openai';
|
|
129
|
+
reason = 'claude primary, openai fallback on pool-exhaustion';
|
|
130
|
+
}
|
|
109
131
|
}
|
|
110
132
|
return { provider: primary.id, fallback, reason };
|
|
111
133
|
}
|
package/dist/proxy.d.ts
CHANGED
|
@@ -376,15 +376,21 @@ interface ProxyOptions {
|
|
|
376
376
|
*/
|
|
377
377
|
maxTokens?: number | 'client';
|
|
378
378
|
/**
|
|
379
|
-
* Pool-exhausted fallback
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
379
|
+
* Pool-exhausted fallback (strictly opt-in; off when unset/empty). May name
|
|
380
|
+
* a CHAIN — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each
|
|
381
|
+
* provider taking the first entry it can actually serve.
|
|
382
|
+
*
|
|
383
|
+
* When a provider can't serve — the pool finds every seat drained or
|
|
384
|
+
* cooling, a mid-flight 429 has no peer left, or a subscription answers
|
|
385
|
+
* 429/5xx — the request is served as the nominated model by whoever can,
|
|
386
|
+
* instead of surfacing the error. A Codex/ChatGPT subscription is preferred
|
|
387
|
+
* and works on BOTH wire shapes; an openai-compat backend is OpenAI-path
|
|
388
|
+
* only, having no Messages translation. A chain makes failover symmetric in
|
|
389
|
+
* both directions. Responses carry `x-dario-pool-fallback`.
|
|
390
|
+
*
|
|
391
|
+
* Inert with no Codex account and no backend — `dario doctor` says so.
|
|
392
|
+
* Sourced from `--pool-fallback` / `DARIO_POOL_FALLBACK` / config
|
|
393
|
+
* `poolFallback.model`.
|
|
388
394
|
*/
|
|
389
395
|
poolFallbackModel?: string;
|
|
390
396
|
/**
|