@askalf/dario 6.0.27 → 6.0.29
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/dist/cli.js +70 -1
- package/dist/proxy.js +105 -4
- package/dist/seat-pin.d.ts +20 -0
- package/dist/seat-pin.js +44 -0
- package/docs/admin-api.md +14 -0
- package/docs/commands.md +1 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1049,6 +1049,75 @@ async function accounts() {
|
|
|
1049
1049
|
}
|
|
1050
1050
|
return;
|
|
1051
1051
|
}
|
|
1052
|
+
if (sub === 'check') {
|
|
1053
|
+
// Read-only, in-place seat probe: one tiny request per model, pinned to
|
|
1054
|
+
// the seat (x-dario-account, admin-token gated), through the RUNNING proxy.
|
|
1055
|
+
// Nothing is copied and nothing is restarted; the upstream status is the
|
|
1056
|
+
// verdict, because a pinned request never fails over.
|
|
1057
|
+
const alias = args[2];
|
|
1058
|
+
if (!alias) {
|
|
1059
|
+
console.error('');
|
|
1060
|
+
console.error(' Usage: dario accounts check <alias> [--models=a,b,c] [--port=N]');
|
|
1061
|
+
console.error('');
|
|
1062
|
+
process.exit(1);
|
|
1063
|
+
}
|
|
1064
|
+
const modelsArg = args.find(a => a.startsWith('--models='));
|
|
1065
|
+
const models = (modelsArg ? modelsArg.slice('--models='.length) : 'claude-haiku-4-5,claude-sonnet-5,claude-opus-5')
|
|
1066
|
+
.split(',').map(m => m.trim()).filter(Boolean);
|
|
1067
|
+
const { loadConfig } = await import('./config-file.js');
|
|
1068
|
+
const fileCfg = loadConfig().config;
|
|
1069
|
+
const portArg = args.find(a => a.startsWith('--port='));
|
|
1070
|
+
const port = (portArg ? parseInt(portArg.split('=')[1], 10) : undefined)
|
|
1071
|
+
?? (process.env['DARIO_PORT'] ? parseInt(process.env['DARIO_PORT'], 10) : undefined)
|
|
1072
|
+
?? fileCfg.port ?? 3456;
|
|
1073
|
+
const apiKey = process.env['DARIO_API_KEY'];
|
|
1074
|
+
const adminToken = process.env['DARIO_ADMIN_TOKEN'] || apiKey;
|
|
1075
|
+
if (!adminToken) {
|
|
1076
|
+
console.error('[dario] accounts check needs DARIO_ADMIN_TOKEN (or DARIO_API_KEY) in the environment, and the proxy running with DARIO_ADMIN=1.');
|
|
1077
|
+
process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
const headers = {
|
|
1080
|
+
'Content-Type': 'application/json',
|
|
1081
|
+
'x-dario-account': alias,
|
|
1082
|
+
'x-dario-admin-token': adminToken,
|
|
1083
|
+
};
|
|
1084
|
+
if (apiKey)
|
|
1085
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
1086
|
+
console.log('');
|
|
1087
|
+
console.log(` dario — seat check: ${alias} via http://127.0.0.1:${port} (pinned, no failover)`);
|
|
1088
|
+
console.log('');
|
|
1089
|
+
let failed = 0;
|
|
1090
|
+
for (const model of models) {
|
|
1091
|
+
let line;
|
|
1092
|
+
try {
|
|
1093
|
+
const resp = await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
|
1094
|
+
method: 'POST', headers,
|
|
1095
|
+
body: JSON.stringify({ model, max_tokens: 8, messages: [{ role: 'user', content: 'Reply with the single word PONG.' }] }),
|
|
1096
|
+
});
|
|
1097
|
+
const text = await resp.text();
|
|
1098
|
+
let detail = '';
|
|
1099
|
+
try {
|
|
1100
|
+
const j = JSON.parse(text);
|
|
1101
|
+
detail = resp.ok ? (j.model ?? '') : `${j.error?.type ?? ''} ${(j.error?.message ?? '').slice(0, 120)}`.trim();
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
detail = text.slice(0, 120);
|
|
1105
|
+
}
|
|
1106
|
+
if (!resp.ok)
|
|
1107
|
+
failed++;
|
|
1108
|
+
line = ` ${model.padEnd(24)} HTTP ${resp.status} ${detail}`;
|
|
1109
|
+
}
|
|
1110
|
+
catch (err) {
|
|
1111
|
+
failed++;
|
|
1112
|
+
line = ` ${model.padEnd(24)} FAIL ${err.message}`;
|
|
1113
|
+
}
|
|
1114
|
+
console.log(line);
|
|
1115
|
+
}
|
|
1116
|
+
console.log('');
|
|
1117
|
+
console.log(failed === 0 ? ` seat "${alias}" serves every model listed.` : ` ${failed}/${models.length} failed on seat "${alias}" — a 401 means its credential or identity, a 429 its window; neither was masked by a peer or the Codex leg.`);
|
|
1118
|
+
console.log('');
|
|
1119
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
1120
|
+
}
|
|
1052
1121
|
if (sub === 'remove' || sub === 'rm') {
|
|
1053
1122
|
const alias = args[2];
|
|
1054
1123
|
if (!alias) {
|
|
@@ -1068,7 +1137,7 @@ async function accounts() {
|
|
|
1068
1137
|
return;
|
|
1069
1138
|
}
|
|
1070
1139
|
console.error(`[dario] Unknown accounts subcommand: ${sub}`);
|
|
1071
|
-
console.error('Usage: dario accounts [list|add <alias>|remove <alias>]');
|
|
1140
|
+
console.error('Usage: dario accounts [list|add <alias>|check <alias>|remove <alias>]');
|
|
1072
1141
|
process.exit(1);
|
|
1073
1142
|
}
|
|
1074
1143
|
/**
|
package/dist/proxy.js
CHANGED
|
@@ -17,6 +17,7 @@ import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLA
|
|
|
17
17
|
import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
|
|
18
18
|
import { notify as osNotify } from './notify.js';
|
|
19
19
|
import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
|
|
20
|
+
import { resolveSeatPin, SEAT_PIN_HEADER, SEAT_PIN_TOKEN_HEADER } from './seat-pin.js';
|
|
20
21
|
import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
|
|
21
22
|
import { handleAdminRequest } from './admin-api.js';
|
|
22
23
|
import { createTokenBucket } from './rate-limit.js';
|
|
@@ -2606,6 +2607,41 @@ export async function startProxy(opts = {}) {
|
|
|
2606
2607
|
? { type: 'error', error: { type: 'authentication_error', message }, account: err.alias }
|
|
2607
2608
|
: { error: message, account: err.alias }));
|
|
2608
2609
|
};
|
|
2610
|
+
// Seat pin (seat-pin.ts): `x-dario-account: <alias>` + `x-dario-admin-token`
|
|
2611
|
+
// routes this one request to that seat with no headroom selection, no
|
|
2612
|
+
// sticky rebinding and no failover of any kind — the upstream answer is
|
|
2613
|
+
// the seat's answer. Refused (not ignored) without the admin API, so a
|
|
2614
|
+
// probe can never silently turn into a normal request.
|
|
2615
|
+
const seatPin = resolveSeatPin(req.headers, { adminEnabled, adminTokenBuf });
|
|
2616
|
+
if (seatPin.kind === 'disabled' || seatPin.kind === 'unauthorized') {
|
|
2617
|
+
res.writeHead(403, JSON_HEADERS);
|
|
2618
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'permission_error', message: seatPin.kind === 'disabled'
|
|
2619
|
+
? `${SEAT_PIN_HEADER} needs the admin API: DARIO_ADMIN=1 with DARIO_ADMIN_TOKEN set`
|
|
2620
|
+
: `${SEAT_PIN_HEADER} needs a valid ${SEAT_PIN_TOKEN_HEADER}` } }));
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
if (seatPin.kind === 'invalid-alias') {
|
|
2624
|
+
res.writeHead(400, JSON_HEADERS);
|
|
2625
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: `${SEAT_PIN_HEADER}: invalid alias` } }));
|
|
2626
|
+
return;
|
|
2627
|
+
}
|
|
2628
|
+
// Upstream API-key mode bypasses the pool entirely (x-api-key, no
|
|
2629
|
+
// bearer), so a pin cannot be honoured there — and "honoured by the
|
|
2630
|
+
// key" would be the silent-wrong-seat outcome this feature exists to
|
|
2631
|
+
// prevent. Refuse it, before the alias is even looked up.
|
|
2632
|
+
if (seatPin.kind === 'pinned' && upstreamApiKey) {
|
|
2633
|
+
res.writeHead(409, JSON_HEADERS);
|
|
2634
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: `${SEAT_PIN_HEADER} cannot be honoured: the proxy is in upstream API-key mode (ANTHROPIC_UPSTREAM_API_KEY), which bypasses the account pool` } }));
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const pinnedAccount = seatPin.kind === 'pinned' ? (pool.get(seatPin.alias) ?? null) : null;
|
|
2638
|
+
if (seatPin.kind === 'pinned' && !pinnedAccount) {
|
|
2639
|
+
res.writeHead(404, JSON_HEADERS);
|
|
2640
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'not_found_error', message: `${SEAT_PIN_HEADER}: no pool account "${seatPin.alias}"` } }));
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
if (pinnedAccount && verbose)
|
|
2644
|
+
console.log(`[dario] seat pin → ${pinnedAccount.alias} (no failover)`);
|
|
2609
2645
|
const selectPoolAccount = () => {
|
|
2610
2646
|
if (upstreamApiKey) {
|
|
2611
2647
|
// Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
|
|
@@ -2615,6 +2651,11 @@ export async function startProxy(opts = {}) {
|
|
|
2615
2651
|
}
|
|
2616
2652
|
// Pool is the one credential model (v5.0): a plain `dario login` is a
|
|
2617
2653
|
// pool of one, so every OAuth request selects from the pool.
|
|
2654
|
+
if (pinnedAccount) {
|
|
2655
|
+
poolAccount = pinnedAccount;
|
|
2656
|
+
accessToken = pinnedAccount.accessToken;
|
|
2657
|
+
return true;
|
|
2658
|
+
}
|
|
2618
2659
|
poolAccount = pool.select();
|
|
2619
2660
|
if (!poolAccount) {
|
|
2620
2661
|
// Pool-exhausted fallback: when armed, the pool HAS accounts (all
|
|
@@ -2674,6 +2715,49 @@ export async function startProxy(opts = {}) {
|
|
|
2674
2715
|
clearTimeout(bodyTimeout);
|
|
2675
2716
|
}
|
|
2676
2717
|
let body = Buffer.concat(chunks);
|
|
2718
|
+
// A body that is not a JSON object cannot be routed — every decision
|
|
2719
|
+
// below (alias, provider prefix, codex slug, template) peeks at `.model`
|
|
2720
|
+
// and each peek swallows its parse error and falls through. So `{` used
|
|
2721
|
+
// to reach the Claude pool as if it were a Claude request and, on a
|
|
2722
|
+
// --no-claude-auth proxy, came back as the pool's 503 "No account
|
|
2723
|
+
// configured" with `error` as a string — which the codex drift watcher's
|
|
2724
|
+
// wire-contract check flagged (run 34044346444): an OpenAI-shape client
|
|
2725
|
+
// reads `.error.message`. Answer here instead: 400, the endpoint's own
|
|
2726
|
+
// wire shape, and no upstream round-trip for a request nothing can serve.
|
|
2727
|
+
// Upstreams do the same (Anthropic: "The request body is not valid
|
|
2728
|
+
// JSON"; OpenAI: "We could not parse the JSON body of your request").
|
|
2729
|
+
{
|
|
2730
|
+
let invalid = null;
|
|
2731
|
+
if (body.length === 0)
|
|
2732
|
+
invalid = 'request body is empty';
|
|
2733
|
+
else {
|
|
2734
|
+
try {
|
|
2735
|
+
// Fatal decode: Buffer.toString() replaces malformed UTF-8 with
|
|
2736
|
+
// U+FFFD, so `{"x":"\xff"}` would parse here as a clean object
|
|
2737
|
+
// while the ORIGINAL bytes went on to be forwarded (review on
|
|
2738
|
+
// #1231). The bytes on the wire are what must be valid.
|
|
2739
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(body);
|
|
2740
|
+
const v = JSON.parse(text);
|
|
2741
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v))
|
|
2742
|
+
invalid = 'request body must be a JSON object';
|
|
2743
|
+
}
|
|
2744
|
+
catch (err) {
|
|
2745
|
+
invalid = `request body is not valid JSON: ${err instanceof Error ? err.message : String(err)}`;
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
if (invalid !== null) {
|
|
2749
|
+
requestCount++;
|
|
2750
|
+
writeLogLine(logFileStream, {
|
|
2751
|
+
ts: new Date().toISOString(), req: requestCount,
|
|
2752
|
+
method: req.method ?? '', path: urlPath, status: 400, reject: 'invalid-body',
|
|
2753
|
+
});
|
|
2754
|
+
res.writeHead(400, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
|
|
2755
|
+
res.end(JSON.stringify(isOpenAI
|
|
2756
|
+
? { error: { message: invalid, type: 'invalid_request_error', param: null, code: null } }
|
|
2757
|
+
: { type: 'error', error: { type: 'invalid_request_error', message: invalid } }));
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2677
2761
|
// Provider prefix (v3.10.0). If the body's model field is `<provider>:<model>`
|
|
2678
2762
|
// with a recognized prefix, strip the prefix and force routing regardless of
|
|
2679
2763
|
// regex. CLI-level `--model=<provider>:<name>` applies the same override
|
|
@@ -2893,6 +2977,18 @@ export async function startProxy(opts = {}) {
|
|
|
2893
2977
|
poolFallbackModel: requestPoolFallbackModel,
|
|
2894
2978
|
poolSize: pool.size,
|
|
2895
2979
|
});
|
|
2980
|
+
// A pin names a Claude POOL seat, so it is only meaningful for a request
|
|
2981
|
+
// this proxy would dispatch through that pool. Provider routing happens
|
|
2982
|
+
// here, BEFORE selectPoolAccount(), so a pinned request naming a Codex or
|
|
2983
|
+
// OpenAI-backend model would otherwise be answered by that leg and report
|
|
2984
|
+
// the wrong thing healthy — the same silent-wrong-leg outcome as the
|
|
2985
|
+
// api-key case above. Refuse it instead.
|
|
2986
|
+
if (pinnedAccount && decision.provider !== 'claude') {
|
|
2987
|
+
requestCount++;
|
|
2988
|
+
res.writeHead(409, JSON_HEADERS);
|
|
2989
|
+
res.end(JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: `${SEAT_PIN_HEADER} names a Claude pool seat, but this request routes to ${decision.provider}; drop the header or ask for a Claude model` } }));
|
|
2990
|
+
return;
|
|
2991
|
+
}
|
|
2896
2992
|
if (rawModel && codexUnavailable && decision.provider === 'codex') {
|
|
2897
2993
|
requestCount++;
|
|
2898
2994
|
writeCodexCredentialsUnavailable(codexUnavailable, isOpenAI ? 'openai' : 'anthropic');
|
|
@@ -3035,7 +3131,7 @@ export async function startProxy(opts = {}) {
|
|
|
3035
3131
|
if (!upstreamApiKey && !poolAccount) {
|
|
3036
3132
|
attemptedProviders.add('claude');
|
|
3037
3133
|
}
|
|
3038
|
-
if (!upstreamApiKey && !poolAccount && await tryCodexPoolFallback(req, res, body, selectPoolFallbackForBody(body), isOpenAI ? 'openai' : 'anthropic', 'pool exhausted', attemptedProviders)) {
|
|
3134
|
+
if (!upstreamApiKey && !poolAccount && !pinnedAccount && await tryCodexPoolFallback(req, res, body, selectPoolFallbackForBody(body), isOpenAI ? 'openai' : 'anthropic', 'pool exhausted', attemptedProviders)) {
|
|
3039
3135
|
return;
|
|
3040
3136
|
}
|
|
3041
3137
|
// `isOpenAI` is REQUIRED here and was not, before v6.0.0 — the selector's
|
|
@@ -3173,7 +3269,7 @@ export async function startProxy(opts = {}) {
|
|
|
3173
3269
|
// that already has the Anthropic prompt cache warmed for it.
|
|
3174
3270
|
// Rotating off mid-session costs cache-create on every turn.
|
|
3175
3271
|
stickyKey = computeStickyKey(userMsg);
|
|
3176
|
-
if (stickyKey) {
|
|
3272
|
+
if (stickyKey && !pinnedAccount) {
|
|
3177
3273
|
const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
|
|
3178
3274
|
if (preferred && preferred.alias !== poolAccount?.alias) {
|
|
3179
3275
|
poolAccount = preferred;
|
|
@@ -3540,6 +3636,11 @@ export async function startProxy(opts = {}) {
|
|
|
3540
3636
|
const triedAliases = new Set();
|
|
3541
3637
|
if (poolAccount)
|
|
3542
3638
|
triedAliases.add(poolAccount.alias);
|
|
3639
|
+
// A pinned request has no peers: every other seat counts as already
|
|
3640
|
+
// tried, so the 401/429 sites below find nobody to fail over to.
|
|
3641
|
+
if (pinnedAccount)
|
|
3642
|
+
for (const a of pool.all())
|
|
3643
|
+
triedAliases.add(a.alias);
|
|
3543
3644
|
let upstream;
|
|
3544
3645
|
let peekedBody = null;
|
|
3545
3646
|
// Inside-request 429 failover loop (v3.8.0). On a 429, pool mode tries
|
|
@@ -3869,7 +3970,7 @@ export async function startProxy(opts = {}) {
|
|
|
3869
3970
|
// sent one, so the following request does not re-walk the chain.
|
|
3870
3971
|
attemptedProviders.add('claude');
|
|
3871
3972
|
providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
|
|
3872
|
-
if (await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders)) {
|
|
3973
|
+
if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders)) {
|
|
3873
3974
|
return;
|
|
3874
3975
|
}
|
|
3875
3976
|
if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
|
|
@@ -3994,7 +4095,7 @@ export async function startProxy(opts = {}) {
|
|
|
3994
4095
|
// Same bookkeeping as the other mid-flight site — see there.
|
|
3995
4096
|
attemptedProviders.add('claude');
|
|
3996
4097
|
providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
|
|
3997
|
-
if (await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders)) {
|
|
4098
|
+
if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders)) {
|
|
3998
4099
|
return;
|
|
3999
4100
|
}
|
|
4000
4101
|
if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
2
|
+
export declare const SEAT_PIN_HEADER = "x-dario-account";
|
|
3
|
+
export declare const SEAT_PIN_TOKEN_HEADER = "x-dario-admin-token";
|
|
4
|
+
export type SeatPin = {
|
|
5
|
+
kind: 'none';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'disabled';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'unauthorized';
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'invalid-alias';
|
|
12
|
+
alias: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'pinned';
|
|
15
|
+
alias: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function resolveSeatPin(headers: IncomingHttpHeaders, opts: {
|
|
18
|
+
adminEnabled: boolean;
|
|
19
|
+
adminTokenBuf: Buffer | null;
|
|
20
|
+
}): SeatPin;
|
package/dist/seat-pin.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seat pin — route one request to one named pool account, with no failover.
|
|
3
|
+
*
|
|
4
|
+
* The read-only probe primitive behind `dario accounts check <alias>`: "does
|
|
5
|
+
* this seat serve this model right now?" cannot be answered by a normal
|
|
6
|
+
* request, because the pool picks the seat by headroom and fails over on
|
|
7
|
+
* 401/429 (to a peer, then to the Codex leg). A pinned request does none of
|
|
8
|
+
* that: it goes to the named seat and the upstream status comes back as-is.
|
|
9
|
+
*
|
|
10
|
+
* Gated on the admin API — `DARIO_ADMIN=1` plus a distinct `DARIO_ADMIN_TOKEN`
|
|
11
|
+
* sent in `x-dario-admin-token` — because choosing the seat is an admin act
|
|
12
|
+
* (it bypasses headroom routing and can burn one seat's window on purpose).
|
|
13
|
+
* The proxy's own API key still applies to the request as usual. With the
|
|
14
|
+
* admin API off the header is refused, not ignored: a probe that silently
|
|
15
|
+
* became a normal request would report the wrong seat as healthy.
|
|
16
|
+
*/
|
|
17
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
18
|
+
export const SEAT_PIN_HEADER = 'x-dario-account';
|
|
19
|
+
export const SEAT_PIN_TOKEN_HEADER = 'x-dario-admin-token';
|
|
20
|
+
/** Same charset as accounts.ts safeAliasPath — anything else is not a seat. */
|
|
21
|
+
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
|
|
22
|
+
function first(v) {
|
|
23
|
+
if (Array.isArray(v))
|
|
24
|
+
return v[0];
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
export function resolveSeatPin(headers, opts) {
|
|
28
|
+
const raw = first(headers[SEAT_PIN_HEADER]);
|
|
29
|
+
if (raw === undefined)
|
|
30
|
+
return { kind: 'none' };
|
|
31
|
+
const alias = raw.trim();
|
|
32
|
+
if (!opts.adminEnabled || !opts.adminTokenBuf)
|
|
33
|
+
return { kind: 'disabled' };
|
|
34
|
+
const provided = first(headers[SEAT_PIN_TOKEN_HEADER]);
|
|
35
|
+
if (!provided)
|
|
36
|
+
return { kind: 'unauthorized' };
|
|
37
|
+
const providedBuf = Buffer.from(provided);
|
|
38
|
+
if (providedBuf.length !== opts.adminTokenBuf.length || !timingSafeEqual(providedBuf, opts.adminTokenBuf)) {
|
|
39
|
+
return { kind: 'unauthorized' };
|
|
40
|
+
}
|
|
41
|
+
if (!ALIAS_RE.test(alias))
|
|
42
|
+
return { kind: 'invalid-alias', alias };
|
|
43
|
+
return { kind: 'pinned', alias };
|
|
44
|
+
}
|
package/docs/admin-api.md
CHANGED
|
@@ -102,6 +102,20 @@ representative `claim` (e.g. `five_hour`), routing `status`,
|
|
|
102
102
|
equivalent of the proxy-key-gated `GET /accounts` pool view; a headless
|
|
103
103
|
operator needs only the admin token to watch headroom.
|
|
104
104
|
|
|
105
|
+
## Pinning a request to one seat
|
|
106
|
+
|
|
107
|
+
`x-dario-account: <alias>` on `POST /v1/messages` (or `/v1/chat/completions`) routes that one request to the named pool account with **no failover**: no headroom selection, no sticky rebinding, no peer retry on 401/429, no Codex leg. The upstream status comes back as-is. It is the primitive behind `dario accounts check <alias>` — the answer to "does this seat serve Sonnet right now?" without copying a credential anywhere or restarting anything.
|
|
108
|
+
|
|
109
|
+
The header is gated on the admin API: the request must also carry `x-dario-admin-token: <DARIO_ADMIN_TOKEN>` (the proxy API key still applies as usual). With the admin API off, or the token missing or wrong, the request is refused with 403 rather than served unpinned — a probe that silently became a normal request would call the wrong seat healthy. An unknown alias is 404; a malformed one is 400. In upstream API-key mode (`ANTHROPIC_UPSTREAM_API_KEY`) the pool is bypassed entirely, so a pin is refused with 409 rather than quietly served by the key. A request that routes to another provider (a Codex-named model, or an OpenAI backend) is refused with 409 for the same reason: the pin names a Claude pool seat, and answering from another leg would report the wrong thing healthy.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl -s http://localhost:3456/v1/messages \
|
|
113
|
+
-H "authorization: Bearer $DARIO_API_KEY" \
|
|
114
|
+
-H "x-dario-account: spare" -H "x-dario-admin-token: $DARIO_ADMIN_TOKEN" \
|
|
115
|
+
-H 'content-type: application/json' \
|
|
116
|
+
-d '{"model":"claude-sonnet-5","max_tokens":8,"messages":[{"role":"user","content":"PONG"}]}'
|
|
117
|
+
```
|
|
118
|
+
|
|
105
119
|
## Bulk re-auth, in one round-trip
|
|
106
120
|
|
|
107
121
|
For a pool with several accounts, the round-trip of "notice one's broken,
|
package/docs/commands.md
CHANGED
|
@@ -15,6 +15,7 @@ This page is the per-flag reference. For environment variables grouped by task
|
|
|
15
15
|
| `dario status` | Show Claude backend OAuth token health and expiry |
|
|
16
16
|
| `dario refresh` | Force an immediate Claude token refresh |
|
|
17
17
|
| `dario logout` | Delete stored Claude credentials |
|
|
18
|
+
| `dario accounts check <alias> [--models=a,b]` | Read-only, in-place seat probe: one tiny request per model, pinned to that seat through the running proxy (`x-dario-account` + `x-dario-admin-token`, needs `DARIO_ADMIN=1`). A pinned request never fails over, so the upstream status is the seat's own answer. |
|
|
18
19
|
| `dario accounts list` / `add <alias>` / `remove <alias>` | Multi-account pool management. `add <alias>` on a fresh pool auto back-fills your existing `dario login` credentials as `login`, so your first `add` trips the 2+ pool threshold on its own — see [Multi-account pool mode](./multi-account-pool.md). |
|
|
19
20
|
| `dario backend list` / `add <name> --key=<key> [--base-url=<url>]` / `remove <name>` | OpenAI-compat backend management |
|
|
20
21
|
| `dario subagent install` / `remove` / `status` | CC sub-agent lifecycle. See [sub-agent hook](./sub-agent.md). |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.29",
|
|
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": {
|