@askalf/dario 6.21.0 → 6.22.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 +1 -1
- package/dist/account-sync.d.ts +50 -18
- package/dist/account-sync.js +36 -18
- package/dist/admin-api.js +3 -3
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +47 -27
- package/dist/codex-account-sync.d.ts +28 -0
- package/dist/codex-account-sync.js +54 -0
- package/dist/codex-accounts.d.ts +32 -0
- package/dist/codex-accounts.js +140 -17
- package/dist/proxy.js +29 -5
- package/docs/admin-api.md +5 -5
- package/docs/multi-instance.md +4 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -213,7 +213,7 @@ dario is the routing layer of **[Own Your Stack](https://github.com/askalf)**, o
|
|
|
213
213
|
|
|
214
214
|
dario is part of **Own Your Stack**, the open toolkit behind **[Sprayberry Labs](https://sprayberrylabs.com)**, the software studio with one human on staff, run by [askalf](https://askalf.org), the AI operation these tools are part of.
|
|
215
215
|
|
|
216
|
-
Built in the open, scars included. Follow the build: **[@ask_alf](https://x.com/ask_alf)**
|
|
216
|
+
Built in the open, scars included. Follow the build: **[@ask_alf](https://x.com/ask_alf)**
|
|
217
217
|
|
|
218
218
|
### Sponsors
|
|
219
219
|
|
package/dist/account-sync.d.ts
CHANGED
|
@@ -28,17 +28,30 @@
|
|
|
28
28
|
* every seat's full credentials, refresh tokens included, with no expiry, so
|
|
29
29
|
* DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
|
|
30
30
|
* separate from `--pool-shared-state`.
|
|
31
|
+
*
|
|
32
|
+
* ChatGPT seats ride the same switch and the same rules in a registry of their
|
|
33
|
+
* own (`/codex-accounts/...`, codex-account-sync.ts), since aliases are a
|
|
34
|
+
* namespace per engine: a second AccountSync, with an AccountRegistry that
|
|
35
|
+
* says where its records live and how to read them.
|
|
31
36
|
*/
|
|
32
37
|
import { type AccountCredentials } from './accounts.js';
|
|
38
|
+
/** What the sync needs of a record, whichever engine it belongs to. */
|
|
39
|
+
export interface SyncedCredentials {
|
|
40
|
+
alias: string;
|
|
41
|
+
accessToken: string;
|
|
42
|
+
refreshToken: string;
|
|
43
|
+
expiresAt: number;
|
|
44
|
+
grantedAt?: number;
|
|
45
|
+
}
|
|
33
46
|
/** Where the sync reads and writes this instance's accounts. accounts.ts in production; a test passes its own. */
|
|
34
|
-
export interface AccountSyncStore {
|
|
35
|
-
loadAll(): Promise<
|
|
36
|
-
save(creds:
|
|
47
|
+
export interface AccountSyncStore<R extends SyncedCredentials = AccountCredentials> {
|
|
48
|
+
loadAll(): Promise<R[]>;
|
|
49
|
+
save(creds: R, opts: {
|
|
37
50
|
fetchedAt: number;
|
|
38
51
|
removedAt?: number;
|
|
39
52
|
}): Promise<{
|
|
40
53
|
saved: boolean;
|
|
41
|
-
replaced?:
|
|
54
|
+
replaced?: R;
|
|
42
55
|
}>;
|
|
43
56
|
remove(alias: string, removedAt: number): Promise<boolean>;
|
|
44
57
|
/** Removals this process made, alias to epoch ms. */
|
|
@@ -78,9 +91,9 @@ export interface SharedPendingLogins {
|
|
|
78
91
|
aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
|
|
79
92
|
}
|
|
80
93
|
/** What one sync changed on this instance, handed to the running proxy. */
|
|
81
|
-
export interface AccountSyncChange {
|
|
94
|
+
export interface AccountSyncChange<R extends SyncedCredentials = AccountCredentials> {
|
|
82
95
|
/** Records the pool lacks or holds older credentials for. */
|
|
83
|
-
records:
|
|
96
|
+
records: R[];
|
|
84
97
|
/** Seats the pool still holds whose account was removed. */
|
|
85
98
|
removed: Array<{
|
|
86
99
|
alias: string;
|
|
@@ -108,19 +121,35 @@ export interface AccountSyncStatus {
|
|
|
108
121
|
errors: number;
|
|
109
122
|
lastError: string | null;
|
|
110
123
|
}
|
|
111
|
-
|
|
124
|
+
/** One engine's registry on the service: where its records live and how to read them. */
|
|
125
|
+
export interface AccountRegistry<R extends SyncedCredentials> {
|
|
126
|
+
/** The service path it answers under: `<path>/list`, `<path>/<alias>/put`, `<path>/<alias>/remove`. */
|
|
127
|
+
path: string;
|
|
128
|
+
/** What the log calls one record. */
|
|
129
|
+
noun: string;
|
|
130
|
+
store: AccountSyncStore<R>;
|
|
131
|
+
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
132
|
+
isRecord(alias: string, rec: unknown): rec is R;
|
|
133
|
+
/** Names a record in a log line, never with its credentials. */
|
|
134
|
+
describe(creds: R): string;
|
|
135
|
+
/** Whether two records under one alias are different subscriptions. */
|
|
136
|
+
differ(a: R, b: R): boolean;
|
|
137
|
+
}
|
|
138
|
+
export interface AccountSyncOptions<R extends SyncedCredentials = AccountCredentials> {
|
|
112
139
|
baseUrl: string;
|
|
113
140
|
token: string;
|
|
114
141
|
/** Sync interval, ms. Default 15000. */
|
|
115
142
|
intervalMs?: number;
|
|
116
|
-
store?: AccountSyncStore
|
|
143
|
+
store?: AccountSyncStore<R>;
|
|
117
144
|
log?: (line: string) => void;
|
|
145
|
+
/** Default: the Claude accounts. */
|
|
146
|
+
registry?: AccountRegistry<R>;
|
|
118
147
|
}
|
|
119
148
|
export declare const DEFAULT_ACCOUNT_SYNC_INTERVAL_MS = 15000;
|
|
120
|
-
export interface AccountSyncPlan {
|
|
149
|
+
export interface AccountSyncPlan<R extends SyncedCredentials = AccountCredentials> {
|
|
121
150
|
/** Registry records to write here, each with the removal time it must outlive. */
|
|
122
151
|
save: Array<{
|
|
123
|
-
creds:
|
|
152
|
+
creds: R;
|
|
124
153
|
removedAt?: number;
|
|
125
154
|
}>;
|
|
126
155
|
/** Local files a removal covers. */
|
|
@@ -129,7 +158,7 @@ export interface AccountSyncPlan {
|
|
|
129
158
|
removedAt: number;
|
|
130
159
|
}>;
|
|
131
160
|
/** Local records newer than the registry's. */
|
|
132
|
-
push:
|
|
161
|
+
push: R[];
|
|
133
162
|
}
|
|
134
163
|
/** The latest removal of `alias` either side knows of, or undefined. */
|
|
135
164
|
export declare function removalTime(alias: string, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): number | undefined;
|
|
@@ -140,7 +169,7 @@ export declare function removalTime(alias: string, markers: ReadonlyMap<string,
|
|
|
140
169
|
* newer wins. The registry's goes to disk, ours goes to the registry, and a
|
|
141
170
|
* local file no record outlives is removed.
|
|
142
171
|
*/
|
|
143
|
-
export declare function planAccountSync(local: readonly
|
|
172
|
+
export declare function planAccountSync<R extends SyncedCredentials>(local: readonly R[], registry: ReadonlyMap<string, R>, markers: ReadonlyMap<string, number>, localRemovals: ReadonlyMap<string, number>): AccountSyncPlan<R>;
|
|
144
173
|
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
145
174
|
export declare function isRegistryRecord(alias: string, rec: unknown): rec is AccountCredentials;
|
|
146
175
|
/** How `dario accounts remove` ended with the shared account registry on. */
|
|
@@ -160,16 +189,19 @@ export type SharedRemovalResult =
|
|
|
160
189
|
* goes only once the registry took the removal, and only when it holds no
|
|
161
190
|
* login granted after it. Throws when the service does not answer.
|
|
162
191
|
*/
|
|
163
|
-
export declare function removeAccountEverywhere(baseUrl: string, token: string, alias: string, opts?: {
|
|
164
|
-
store?: AccountSyncStore
|
|
192
|
+
export declare function removeAccountEverywhere<R extends SyncedCredentials = AccountCredentials>(baseUrl: string, token: string, alias: string, opts?: {
|
|
193
|
+
store?: AccountSyncStore<R>;
|
|
165
194
|
removedAt?: number;
|
|
195
|
+
registry?: AccountRegistry<R>;
|
|
166
196
|
}): Promise<SharedRemovalResult>;
|
|
167
|
-
export declare class AccountSync implements SharedPendingLogins {
|
|
197
|
+
export declare class AccountSync<R extends SyncedCredentials = AccountCredentials> implements SharedPendingLogins {
|
|
168
198
|
readonly intervalMs: number;
|
|
169
199
|
private readonly baseUrl;
|
|
170
200
|
private readonly token;
|
|
201
|
+
private readonly registry;
|
|
171
202
|
private readonly store;
|
|
172
203
|
private readonly log;
|
|
204
|
+
private readonly tag;
|
|
173
205
|
private onChange;
|
|
174
206
|
private live;
|
|
175
207
|
private timer;
|
|
@@ -179,14 +211,14 @@ export declare class AccountSync implements SharedPendingLogins {
|
|
|
179
211
|
private markers;
|
|
180
212
|
private down;
|
|
181
213
|
private stats;
|
|
182
|
-
constructor(opts: AccountSyncOptions);
|
|
214
|
+
constructor(opts: AccountSyncOptions<R>);
|
|
183
215
|
/**
|
|
184
216
|
* Connect a running pool: `live` reads a seat's credentials, `onChange`
|
|
185
217
|
* gets what each later sync changed. Until then a sync updates the
|
|
186
218
|
* accounts directory only, which is all the startup sync needs, since the
|
|
187
219
|
* pool loads from that directory after it.
|
|
188
220
|
*/
|
|
189
|
-
attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange) => Promise<void> | void): void;
|
|
221
|
+
attach(live: (alias: string) => LiveSeat | undefined, onChange: (change: AccountSyncChange<R>) => Promise<void> | void): void;
|
|
190
222
|
/** Sync on the interval. The timer never keeps the process alive. */
|
|
191
223
|
start(): void;
|
|
192
224
|
stop(): void;
|
|
@@ -205,7 +237,7 @@ export declare class AccountSync implements SharedPendingLogins {
|
|
|
205
237
|
remove(engine: LoginEngine, alias: string, state: string): Promise<boolean | 'failed'>;
|
|
206
238
|
aliases(engine: LoginEngine): Promise<string[] | 'failed'>;
|
|
207
239
|
/** One sync. Null when the registry could not be read; never throws. */
|
|
208
|
-
syncOnce(): Promise<AccountSyncChange | null>;
|
|
240
|
+
syncOnce(): Promise<AccountSyncChange<R> | null>;
|
|
209
241
|
private run;
|
|
210
242
|
/**
|
|
211
243
|
* One POST to the service. Null on any failure: the caller carries on with
|
package/dist/account-sync.js
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
* every seat's full credentials, refresh tokens included, with no expiry, so
|
|
29
29
|
* DARIO_REFRESH_LOCK_TOKEN reads every seat; that is why this is an opt-in
|
|
30
30
|
* separate from `--pool-shared-state`.
|
|
31
|
+
*
|
|
32
|
+
* ChatGPT seats ride the same switch and the same rules in a registry of their
|
|
33
|
+
* own (`/codex-accounts/...`, codex-account-sync.ts), since aliases are a
|
|
34
|
+
* namespace per engine: a second AccountSync, with an AccountRegistry that
|
|
35
|
+
* says where its records live and how to read them.
|
|
31
36
|
*/
|
|
32
37
|
import { loadAllAccounts, saveSharedAccount, removeSharedAccount, localAccountRemovals, credentialsAreNewer, grantedAfter, isValidAccountAlias, } from './accounts.js';
|
|
33
38
|
import { maskEmail } from './pool.js';
|
|
@@ -99,12 +104,12 @@ export function isRegistryRecord(alias, rec) {
|
|
|
99
104
|
&& typeof r.expiresAt === 'number' && Number.isFinite(r.expiresAt)
|
|
100
105
|
&& Array.isArray(r.scopes) && typeof r.deviceId === 'string' && typeof r.accountUuid === 'string';
|
|
101
106
|
}
|
|
102
|
-
function parseRegistry(raw) {
|
|
107
|
+
function parseRegistry(raw, isRecord) {
|
|
103
108
|
const out = new Map();
|
|
104
109
|
if (!raw || typeof raw !== 'object')
|
|
105
110
|
return out;
|
|
106
111
|
for (const [alias, rec] of Object.entries(raw))
|
|
107
|
-
if (
|
|
112
|
+
if (isRecord(alias, rec))
|
|
108
113
|
out.set(alias, rec);
|
|
109
114
|
return out;
|
|
110
115
|
}
|
|
@@ -137,9 +142,10 @@ async function registryCall(baseUrl, token, path, body) {
|
|
|
137
142
|
* login granted after it. Throws when the service does not answer.
|
|
138
143
|
*/
|
|
139
144
|
export async function removeAccountEverywhere(baseUrl, token, alias, opts = {}) {
|
|
140
|
-
const
|
|
145
|
+
const registry = opts.registry ?? CLAUDE_REGISTRY;
|
|
146
|
+
const store = opts.store ?? registry.store;
|
|
141
147
|
const removedAt = opts.removedAt ?? Date.now();
|
|
142
|
-
const res = await registryCall(baseUrl, token,
|
|
148
|
+
const res = await registryCall(baseUrl, token, `${registry.path}/${encodeURIComponent(alias)}/remove`, { removedAt });
|
|
143
149
|
if (res.removed !== true)
|
|
144
150
|
return 'registry-newer';
|
|
145
151
|
if (await store.remove(alias, removedAt))
|
|
@@ -155,12 +161,22 @@ export async function removeAccountEverywhere(baseUrl, token, alias, opts = {})
|
|
|
155
161
|
function describeAccount(c) {
|
|
156
162
|
return maskEmail(c.accountEmail) ?? c.accountId ?? 'unknown account';
|
|
157
163
|
}
|
|
164
|
+
const CLAUDE_REGISTRY = {
|
|
165
|
+
path: '/accounts',
|
|
166
|
+
noun: 'account',
|
|
167
|
+
store: accountFileStore,
|
|
168
|
+
isRecord: isRegistryRecord,
|
|
169
|
+
describe: describeAccount,
|
|
170
|
+
differ: (a, b) => !!a.accountId && !!b.accountId && a.accountId !== b.accountId,
|
|
171
|
+
};
|
|
158
172
|
export class AccountSync {
|
|
159
173
|
intervalMs;
|
|
160
174
|
baseUrl;
|
|
161
175
|
token;
|
|
176
|
+
registry;
|
|
162
177
|
store;
|
|
163
178
|
log;
|
|
179
|
+
tag;
|
|
164
180
|
onChange = null;
|
|
165
181
|
live = null;
|
|
166
182
|
timer = null;
|
|
@@ -182,8 +198,10 @@ export class AccountSync {
|
|
|
182
198
|
this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
|
|
183
199
|
this.token = opts.token;
|
|
184
200
|
this.intervalMs = clampTimerMs(Math.max(1_000, opts.intervalMs ?? DEFAULT_ACCOUNT_SYNC_INTERVAL_MS));
|
|
185
|
-
this.
|
|
201
|
+
this.registry = opts.registry ?? CLAUDE_REGISTRY;
|
|
202
|
+
this.store = opts.store ?? this.registry.store;
|
|
186
203
|
this.log = opts.log ?? ((line) => console.error(line));
|
|
204
|
+
this.tag = `[dario] shared ${this.registry.noun}s:`;
|
|
187
205
|
}
|
|
188
206
|
/**
|
|
189
207
|
* Connect a running pool: `live` reads a seat's credentials, `onChange`
|
|
@@ -224,7 +242,7 @@ export class AccountSync {
|
|
|
224
242
|
}
|
|
225
243
|
// Never logs the login: the line carries the alias and the transport error only.
|
|
226
244
|
loginFailed(what, alias) {
|
|
227
|
-
this.log(
|
|
245
|
+
this.log(`${this.tag} could not ${what} the pending login for "${alias}" on the service (${this.stats.lastError ?? 'no answer'}); using this instance's copy`);
|
|
228
246
|
return 'failed';
|
|
229
247
|
}
|
|
230
248
|
async put(engine, alias, login, ttlMs, create = false) {
|
|
@@ -264,7 +282,7 @@ export class AccountSync {
|
|
|
264
282
|
return this.run().catch((err) => {
|
|
265
283
|
this.stats.errors++;
|
|
266
284
|
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
|
267
|
-
this.log(
|
|
285
|
+
this.log(`${this.tag} sync failed (${this.stats.lastError}); retrying on the next sync`);
|
|
268
286
|
return null;
|
|
269
287
|
});
|
|
270
288
|
});
|
|
@@ -279,17 +297,17 @@ export class AccountSync {
|
|
|
279
297
|
for (const [alias, at] of this.store.localRemovals()) {
|
|
280
298
|
if ((this.published.get(alias) ?? -Infinity) >= at)
|
|
281
299
|
continue;
|
|
282
|
-
const res = await this.call(
|
|
300
|
+
const res = await this.call(`${this.registry.path}/${encodeURIComponent(alias)}/remove`, { removedAt: at });
|
|
283
301
|
if (!res)
|
|
284
302
|
break;
|
|
285
303
|
this.published.set(alias, at);
|
|
286
304
|
this.stats.removalsPublished++;
|
|
287
305
|
}
|
|
288
306
|
const fetchedAt = Date.now();
|
|
289
|
-
const res = await this.call(
|
|
307
|
+
const res = await this.call(`${this.registry.path}/list`, {});
|
|
290
308
|
if (!res)
|
|
291
309
|
return null;
|
|
292
|
-
const registry = parseRegistry(res.accounts);
|
|
310
|
+
const registry = parseRegistry(res.accounts, this.registry.isRecord);
|
|
293
311
|
const markers = parseMarkers(res.removed);
|
|
294
312
|
this.markers = markers;
|
|
295
313
|
this.stats.registryAccounts = registry.size;
|
|
@@ -312,7 +330,7 @@ export class AccountSync {
|
|
|
312
330
|
kept.delete(alias);
|
|
313
331
|
removed.push({ alias, removedAt });
|
|
314
332
|
this.stats.removed++;
|
|
315
|
-
this.log(
|
|
333
|
+
this.log(`${this.tag} removed ${this.registry.noun} "${alias}"; it was removed on another instance`);
|
|
316
334
|
}
|
|
317
335
|
for (const { creds, removedAt } of plan.save) {
|
|
318
336
|
const out = await this.store.save(creds, removedAt === undefined ? { fetchedAt } : { fetchedAt, removedAt });
|
|
@@ -322,13 +340,13 @@ export class AccountSync {
|
|
|
322
340
|
this.stats.saved++;
|
|
323
341
|
const was = out.replaced;
|
|
324
342
|
if (!was) {
|
|
325
|
-
this.log(
|
|
343
|
+
this.log(`${this.tag} added ${this.registry.noun} "${creds.alias}" (${this.registry.describe(creds)}) from the registry`);
|
|
326
344
|
}
|
|
327
|
-
else if (
|
|
345
|
+
else if (this.registry.differ(was, creds)) {
|
|
328
346
|
// Aliases are one namespace across every instance. Two instances
|
|
329
347
|
// that each logged a different subscription in under one alias
|
|
330
348
|
// (`login`, most often) keep only the newer grant.
|
|
331
|
-
this.log(
|
|
349
|
+
this.log(`${this.tag} ${this.registry.noun} "${creds.alias}" here was ${this.registry.describe(was)} and is now ${this.registry.describe(creds)}, a newer login under the same alias on another instance; aliases are shared across instances, so give each subscription its own alias`);
|
|
332
350
|
}
|
|
333
351
|
}
|
|
334
352
|
for (const creds of plan.push) {
|
|
@@ -337,14 +355,14 @@ export class AccountSync {
|
|
|
337
355
|
const removedSince = this.store.localRemovals().get(creds.alias);
|
|
338
356
|
if (!grantedAfter(creds, removedSince))
|
|
339
357
|
continue;
|
|
340
|
-
const out = await this.call(
|
|
358
|
+
const out = await this.call(`${this.registry.path}/${encodeURIComponent(creds.alias)}/put`, { credentials: creds });
|
|
341
359
|
if (!out)
|
|
342
360
|
break;
|
|
343
361
|
if (!out.stored)
|
|
344
362
|
continue;
|
|
345
363
|
this.stats.pushed++;
|
|
346
364
|
if (!registry.has(creds.alias))
|
|
347
|
-
this.log(
|
|
365
|
+
this.log(`${this.tag} published ${this.registry.noun} "${creds.alias}" to the registry`);
|
|
348
366
|
}
|
|
349
367
|
const change = { records: [], removed };
|
|
350
368
|
if (this.live) {
|
|
@@ -386,7 +404,7 @@ export class AccountSync {
|
|
|
386
404
|
this.stats.lastOkAt = Date.now();
|
|
387
405
|
if (this.down) {
|
|
388
406
|
this.down = false;
|
|
389
|
-
this.log(
|
|
407
|
+
this.log(`${this.tag} service reachable again (${this.baseUrl})`);
|
|
390
408
|
}
|
|
391
409
|
return out;
|
|
392
410
|
}
|
|
@@ -395,7 +413,7 @@ export class AccountSync {
|
|
|
395
413
|
this.stats.lastError = err instanceof Error ? err.message : String(err);
|
|
396
414
|
if (!this.down) {
|
|
397
415
|
this.down = true;
|
|
398
|
-
this.log(
|
|
416
|
+
this.log(`${this.tag} ${this.baseUrl} unreachable (${this.stats.lastError}); serving the accounts on this instance's disk until it answers again`);
|
|
399
417
|
}
|
|
400
418
|
return null;
|
|
401
419
|
}
|
package/dist/admin-api.js
CHANGED
|
@@ -290,9 +290,9 @@ async function doCompleteCodexLogin(alias, rawCode, now, deps, remote) {
|
|
|
290
290
|
if (pastedState !== null && pastedState !== p.state) {
|
|
291
291
|
return { ok: false, status: 400, error: 'state mismatch — the redirect is from a different login attempt' };
|
|
292
292
|
}
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
// on an instance without the seat.
|
|
293
|
+
// The login may have been started on an instance that lacks this alias, and without the shared
|
|
294
|
+
// registry a seat is on one instance only. Checked here, before the claim, so nothing is consumed
|
|
295
|
+
// and the login still completes on an instance without the seat.
|
|
296
296
|
if ((await listCodexAccountAliases()).includes(alias)) {
|
|
297
297
|
return { ok: false, status: 409, error: `codex account "${alias}" already exists on this instance: DELETE /admin/codex/accounts/${alias} first` };
|
|
298
298
|
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -192,7 +192,7 @@ export declare const SHORT_HELP = "\n dario: use your Claude or ChatGPT subscri
|
|
|
192
192
|
/** The help `argv` asks for: the full reference with `--all`, else the short one. Pure, exported for tests. */
|
|
193
193
|
export declare function helpText(argv: readonly string[]): string;
|
|
194
194
|
/** Every command and flag, `dario --help --all`. */
|
|
195
|
-
export declare const FULL_HELP = "\n dario: use your Claude or ChatGPT subscription as an API.\n\n Usage:\n dario login [--manual] [--no-proxy] [--force-reauth]\n Detect credentials + start proxy (or run OAuth).\n --manual (alias: --headless) for container / SSH\n setups \u2014 prints an authorize URL and reads the\n code you paste back instead of a local redirect.\n --no-proxy stops after auth \u2014 do not start the\n proxy (implied by --manual). Use this when the\n proxy is already running in a separate process /\n container so login doesn't collide on the port.\n --force-reauth (alias: --force) ignores any\n existing credentials and runs a fresh OAuth\n flow \u2014 for when the refresh token is dead and\n /health still reports access-token countdown.\n dario proxy [options] Start the API proxy server. Flags only: a bare\n word after \"proxy\" is an error, and \"dario proxy\n status\" prints the report instead of starting.\n dario status Check authentication status\n dario refresh Force token refresh\n dario resume Clear the overage-guard halt on a running proxy.\n v4.1.0+. Idempotent: returns \"no-op\" if the\n proxy isn't halted. Errors with a friendly hint\n if no proxy is running on localhost:3456.\n POSTs /admin/resume on the local proxy. (dario#288)\n dario logout Remove saved credentials\n dario accounts list List accounts in the multi-account pool\n (--live: the running proxy's view \u2014 status,\n window, 429s, organization, shared windows)\n dario accounts add NAME [--manual] [--from-keychain[=<target>]]\n Add a new account to the pool (runs OAuth flow).\n --manual (alias: --headless) prints an authorize\n URL and reads the code you paste back \u2014 for\n container / SSH / no-browser-on-this-machine\n setups, or as the on-Windows escape hatch when\n the URL dispatch chain truncates query params.\n --from-keychain skips OAuth and imports an\n existing Claude Code keychain entry on this\n host. With no value: uses the only/first match\n (errors with the candidate list if multiple\n entries exist). With =<target>: picks a specific\n entry by its platform identifier (Linux account\n attribute, Windows TargetName).\n dario accounts remove N Remove an account from the pool\n dario keys create NAME [--seat=ALIAS] [--models=a,b,prefix*] [--expires=30d]\n Mint a named key for one developer on a shared\n dario (v6.8). Prints the secret once; stores a\n hash in ~/.dario/keys.json. Requests it\n authenticates are attributed to NAME in\n /analytics, the ledger and the log, next to\n the root DARIO_API_KEY which keeps working.\n --seat prefers a pool seat when it has\n headroom (normal routing otherwise); --models\n refuses any other model with 403 before\n anything goes upstream; --expires refuses the\n key after 30d / 12h / 2w / an ISO date. The\n running proxy sees it on the next request.\n dario keys list Named keys: status, seat, last used, expiry,\n models. --json for the raw list.\n dario keys revoke NAME Refuse a key from now on (kept in the list).\n dario keys rotate NAME New secret, same name and settings; the old\n secret stops at once.\n dario keys remove NAME Forget a key entirely.\n dario codex list List ChatGPT-subscription accounts, served on\n /v1/chat/completions.\n dario codex add NAME Add a ChatGPT-subscription account (prints an\n authorize URL; paste the redirect URL back).\n dario codex remove NAME Remove a ChatGPT-subscription account.\n dario add altman [NAME] Attach a ChatGPT subscription \u2014 the same thing\n as 'dario codex add', named by whose plan it is.\n 'dario add amodei' attaches a Claude account.\n dario backend list List configured OpenAI-compat backends\n dario backend add NAME --key=sk-... [--base-url=...]\n Add an OpenAI-compat backend (OpenAI, OpenRouter, Groq, etc.)\n dario backend remove N Remove an OpenAI-compat backend\n dario subagent install Register ~/.claude/agents/dario.md so Claude Code\n can delegate dario diagnostics / template-refresh\n operations to a named sub-agent (v3.26)\n dario subagent remove Remove the registered sub-agent file\n dario subagent status Show whether the sub-agent is installed\n dario mcp Run dario as an MCP (Model Context Protocol)\n server on stdio. Exposes read-only tools\n (doctor, status, accounts_list, backends_list,\n subagent_status, fingerprint_info) so MCP\n clients (Claude Desktop, IDEs, etc.) can\n inspect dario's state. No destructive ops\n are exposed \u2014 mutations still require the\n CLI. (v3.27)\n dario doctor Print a health report: dario / Node / CC /\n template / drift / OAuth / pool / backends\n dario doctor --probe Also hit Anthropic's authorize endpoint with\n dario's effective OAuth config and surface\n the server's verdict \u2014 the single reliable\n signal for scope-policy drift (dario#42/#71\n class). One GET to claude.ai; no PII.\n dario doctor --usage Fire one minimal Haiku request through your\n OAuth and surface the rate-limit snapshot:\n All-models 5h/7d, per-model 7d buckets\n (Sonnet only, Opus only when Anthropic ships\n them), overage. Mirrors the user-dashboard\n usage page. Costs ~1 subscription request.\n dario doctor --obedience\n Probe each model family THROUGH the running\n proxy with a client system prompt (\"reply\n with ONLY the word PONG\") and assert the\n reply obeys. Catches upstream behavioral\n drift in client-system steering \u2014 the\n 2026-06-12 sonnet class (dario#509) that\n 200s, billing, and template checks all\n miss. Needs `dario proxy` running; costs\n at most a few tiny subscription requests.\n dario doctor --json Emit the check report as structured JSON\n for machine consumption (status bots,\n CI scripts, etc.) instead of the\n human-readable table.\n dario doctor --auth-check\n One-shot listener on an ephemeral loopback\n port. Send ONE request from your client\n (OpenClaw, Hermes, curl, etc.) and dario\n classifies the auth headers it sees against\n DARIO_API_KEY \u2014 with redacted previews and\n a targeted diagnosis (dario#97 class). Use\n --timeout-ms=N to adjust the 30s default.\n dario doctor --bun-bootstrap\n One-shot Bun installer. Closes the gap\n between \"doctor warned about Node-only TLS\n fingerprint\" and \"Bun on PATH\" without\n copy-pasting a curl-to-shell line. Skips\n when Bun is already installed. Pure\n delegation to the official installer at\n bun.com \u2014 dario does not vendor or pin a\n Bun version.\n dario config Print the effective configuration (port,\n host, DARIO_API_KEY state, OAuth status,\n pool, backends, paths) with credentials\n redacted. Safe to paste into bug reports.\n --json for structured output.\n dario usage Burn-rate summary of the running proxy's\n traffic (last 60 min): requests, token\n totals, subscription % vs. extra-usage,\n per-account rotation if pool mode is on.\n Hits /analytics on the local proxy. Works\n only when proxy is running; for a one-off\n rate-limit snapshot from Anthropic, see\n `dario doctor --usage`. --port=N to target\n a non-default port; --json for the raw\n /analytics payload. Above the window: the\n lifetime API-equivalent spend from the\n ledger (read from disk when the proxy is\n down). --card[=file.svg] writes a share\n card of that number (default\n dario-api-equivalent.svg). (v6.6)\n --donut[=file.svg] writes it as three\n rings: by model, by key, subscription\n vs metered (default dario-spend-donuts.svg).\n --by-key splits the lifetime number per\n consumer: named key, x-dario-consumer\n header, or hashed user id. (v6.8)\n dario compare Read the shadow-compare log written by\n requests carrying `x-dario-compare`:\n per-model calls, success rate, median\n latency, how often the answer parsed as\n JSON, average length \u2014 plus why any\n comparison was skipped. --dir=PATH to read\n elsewhere, --json for the summary. (v6.7)\n dario upgrade npm install -g @askalf/dario@latest with a\n pre-flight current-vs-latest check.\n\n Proxy options:\n --model=MODEL Force a model for all requests\n Shortcuts: fable, fable1m, opus, sonnet, haiku\n Full IDs: claude-fable-5, claude-opus-5-5,\n claude-sonnet-5 (append [1m] for 1M context)\n Provider prefix: openai:gpt-4o, groq:llama-3.3-70b,\n claude:opus, local:qwen-coder (forces backend)\n Default: passthrough (client decides)\n --fast-model=MODEL Route Haiku-tier sub-agent requests to MODEL\n instead of --model, so Claude Code's cheap\n sub-agents aren't upgraded to the forced model.\n Same MODEL forms as --model. No effect unless set.\n --analytics-token=TOKEN Read-only credential for /analytics*, /metrics and\n the /analytics/ui page (env DARIO_ANALYTICS_TOKEN).\n Refused on /v1/*, /accounts, /status, /admin/*:\n a scraper or a browser gets the numbers, never\n a request slot. Gates nothing without DARIO_API_KEY.\n --no-claude-auth Don't load or refresh the Claude OAuth token \u2014\n for OpenAI-only proxies (e.g. --model=openai:...).\n Prevents dario rotating a shared refresh token out\n from under an interactive Claude Code on the same\n machine. Claude-bound requests then return a clean\n unauthenticated error.\n --passthrough, --thin Thin proxy \u2014 OAuth swap only, no injection\n --preserve-tools Forward client tool schemas unchanged\n Loses subscription routing; use for custom agents\n --hybrid-tools Remap to CC tools, inject sessionId/requestId/etc.\n Keeps subscription routing for custom agents\n --merge-tools Send CC's canonical tools first, append the\n client's custom tools after (deduped by name).\n Model can call either; tool calls flow back\n unchanged. EXPERIMENTAL \u2014 Anthropic's billing\n classifier may flip routing on the appended\n tail. Validate with --verbose on the first\n 1-2 requests. Mutually exclusive with\n --preserve-tools and --hybrid-tools.\n --no-auto-detect Disable Cline/Kilo/Roo auto-preserve-tools\n (v3.19.3 behavior). Keeps CC fingerprint\n intact even when a text-tool client is\n detected; use --preserve-tools per session\n when edits are needed. (dario#40)\n --strict-tls Refuse to start proxy mode unless this process\n runs under Bun at a version whose JA3 is verified\n to match Claude Code (\u2265 v1.3.14). Bun is what\n Claude Code uses; matching its TLS stack keeps the\n proxy's JA3/JA4 ClientHello indistinguishable\n from a stock CC request \u2014 but an older Bun ships an\n older BoringSSL whose ClientHello diverges (#813).\n Install/upgrade Bun (https://bun.sh) so dario\n auto-relaunches under it. (v3.23)\n --stealth Single-flag behavioral-stealth preset.\n Flips pace-jitter, think-time, and\n session-start defaults from 0 to non-zero\n values sized for real-CC inter-arrival\n statistics (pace-jitter=300, think\n base/perToken/jitter=800/4/1500 capped at\n 25s, session-start min/jitter=1200/3000).\n Per-knob --pace-jitter / --think-time-* /\n --session-start-* flags and env vars\n still win \u2014 flip stealth on, tune any\n axis afterwards. Env: DARIO_STEALTH.\n --pace-min=MS Minimum ms between upstream requests\n (default: 500). Prevents request floods\n that are distinguishable from human-paced\n CC traffic.\n --pace-jitter=MS Max additional uniform-random jitter (ms)\n added on top of --pace-min per request.\n Default: 0 (off). Set to e.g. 300 to hide\n the floor from long-run inter-arrival\n statistics. (v3.24)\n --think-time-base=MS Post-response \"think time\" base \u2014 constant\n ms added before the next request fires.\n Models the wall-clock pause between an\n interactive CC user reading a response and\n typing the next message. Default: 0 (off).\n Env: DARIO_THINK_TIME_BASE_MS.\n --think-time-per-token=MS\n Additional ms per output token of the\n previous response (linear). e.g. 5 \u2192 a\n 1000-token response adds 5s of read time\n before the next request. Default: 0.\n Env: DARIO_THINK_TIME_PER_TOKEN_MS.\n --think-time-jitter=MS Max uniform-random jitter on top of\n base+perToken*tokens. Hides the formula\n from long-run inter-arrival statistics.\n Default: 0.\n Env: DARIO_THINK_TIME_JITTER_MS.\n --think-time-max=MS Upper bound on think time so a 50k-token\n response doesn't pause for minutes.\n Default: 30000 (30s).\n Env: DARIO_THINK_TIME_MAX_MS.\n --session-start-min=MS Floor on session-start delay \u2014 applied to\n the first request only (lastResponseTime\n === 0). Real CC sessions open with seconds\n of startup latency, not microseconds.\n Default: 0 (off).\n Env: DARIO_SESSION_START_MIN_MS.\n --session-start-jitter=MS\n Max uniform-random jitter on session-start\n delay. Default: 0.\n Env: DARIO_SESSION_START_JITTER_MS.\n --drain-on-close When the client disconnects mid-stream,\n keep consuming the upstream SSE to EOF\n so Anthropic sees the same read-to-\n completion pattern native Claude Code\n produces. Trades tokens (the response\n is fully generated even if nobody reads\n it) for fingerprint fidelity. Bounded by\n the 5-minute upstream timeout. (v3.25)\n --no-midstream-continue Do not finish a streamed answer that dies\n mid-way from the other subscription. By\n default a stream that breaks with content\n already on the wire (socket reset,\n overloaded_error, codex response.failed)\n is resumed through dario's own front door\n at the other provider's entry in\n --pool-fallback, spliced onto the same\n client stream, and closed cleanly; the\n client sees one message. Off, or with no\n fallback entry for the other provider,\n the stream ends truncated as before.\n Env: DARIO_MIDSTREAM_CONTINUE=0. (v6.1)\n --no-ledger Do not keep the lifetime ledger\n (~/.dario/ledger.json): per-day, per-model\n token totals that let /analytics and\n `dario usage` say what the traffic would\n have cost on the metered API since the\n first request, across restarts. Env:\n DARIO_LEDGER=0; DARIO_LEDGER_PATH=<file>\n moves it. (v6.6)\n --no-keys Ignore named keys (~/.dario/keys.json): only\n DARIO_API_KEY authenticates. Env: DARIO_KEYS=0;\n --keys-path=<file> / DARIO_KEYS_PATH moves the\n file. See `dario keys`. (v6.8)\n --key-socket=<path>=<key>\n Also listen on a unix socket bound to one\n named key: every request on it is that\n key's, with no secret sent or stored by the\n caller. Who may connect is the socket's\n directory and group (it is created 0660).\n Repeatable. Env: DARIO_KEY_SOCKETS=<spec>,..\n --session-idle-rotate=MS Idle ms before an account's session id\n rotates (default: 900000 = 15 min).\n Real CC rotates once per conversation, not\n per call; the default matches its observed\n cadence. Applies per account \u2014 a pool of one\n rotates as the pre-v5 single account did. (v3.28)\n --session-rotate-jitter=MS\n Max additional uniform-random jitter (ms)\n added to the idle threshold, sampled once\n per session at creation. Default: 0 (off).\n Hides the exact threshold from long-run\n rotation statistics. (v3.28)\n --session-max-age=MS Hard cap on a session id's lifetime\n regardless of activity. Default: off. Set\n for always-on pipelines where an idle\n window would never trigger. (v3.28)\n --session-per-client Give each upstream client (keyed by\n x-session-id / x-client-session-id\n header) its own rotated session id.\n Default: off (single session across all\n clients, v3.27 behaviour). (v3.28)\n --preserve-orchestration-tags[=TAG,TAG]\n Opt specific orchestration wrapper tags\n (<system-reminder>, <env>, <thinking>,\n etc.) out of the scrub. Bare flag =\n preserve all. Value form = preserve only\n those listed; everything else is still\n stripped. Default: strip every tag in\n ORCHESTRATION_TAG_NAMES. Env mirror:\n DARIO_PRESERVE_ORCHESTRATION_TAGS=*\n or =tag1,tag2. (v3.30.7, dario#78)\n --no-live-capture Skip the background live-fingerprint\n refresh entirely. dario uses the bundled\n snapshot and will NOT spawn the installed\n Claude Code binary. For air-gapped /\n reproducible-build / CI-harness runs.\n Env: DARIO_NO_LIVE_CAPTURE=1.\n (v3.30.8, dario#77)\n --strict-template Refuse to start if the loaded template\n is the bundled snapshot (no live capture\n ever succeeded) or drifts from the\n installed CC version. Same philosophy\n as --strict-tls: make the unsafe state\n require intent. Env: DARIO_STRICT_TEMPLATE=1.\n (v3.30.8, dario#77)\n --shutdown-grace=MS How long SIGTERM waits for in-flight requests\n to finish before exiting (default: 90000).\n The listener closes at once. Set the\n container's stop grace above it, or Docker\n SIGKILLs the drain. Env:\n DARIO_SHUTDOWN_GRACE_MS. (dario#1370)\n --upstream-timeout=MS How long dario waits on upstream (default:\n 300000). Until the response streams it caps\n the whole wait; while a 2xx streams it is\n the longest gap between two chunks, so a\n live stream is never cut. 0 keeps the\n default. Env: DARIO_UPSTREAM_TIMEOUT_MS.\n --max-concurrent=N Max in-flight requests across the WHOLE proxy,\n not per seat (default: 10; a pool defaults to\n 10 per seat). Past it, requests wait in dario.\n Env: DARIO_MAX_CONCURRENT. (dario#80, #1244)\n --max-queued=N Max requests buffered waiting for a\n concurrency slot before dario returns\n 429 \"queue-full\" (default: 128).\n Env: DARIO_MAX_QUEUED. (dario#80)\n --max-concurrent-per-consumer=N\n Max in-flight requests per consumer, keyed\n by the x-dario-consumer request header; a\n consumer at the cap waits while others keep\n flowing (default: 0 = off).\n Env: DARIO_MAX_CONCURRENT_PER_CONSUMER.\n --pool-shared-state Share rate-limit readings and sticky bindings\n with the other dario instances through the\n refresh-lock service (needs\n DARIO_REFRESH_LOCK_URL / _TOKEN). Fails open.\n Env: DARIO_POOL_SHARED_STATE=1.\n --pool-shared-state-interval=MS\n How often to pull peers' readings\n (default: 2000). Env:\n DARIO_POOL_SHARED_STATE_INTERVAL_MS.\n --pool-shared-accounts Keep the Claude accounts in a registry on the\n refresh-lock service, merged at startup and\n every 15s, so a new replica starts with its\n peers' accounts and adds and removals reach\n every instance. The registry holds every\n seat's credentials. Fails open.\n Env: DARIO_POOL_SHARED_ACCOUNTS=1.\n --queue-timeout=MS Max ms a queued request waits before\n dario returns 504 \"queue-timeout\"\n (default: 60000).\n Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)\n --pool-strategy=<headroom|headroom-by-plan|fill-first|expiring-first>\n Where new conversations land in a multi-\n account pool. headroom (default) spreads\n them to the seat with the most slack;\n headroom-by-plan (opt-in) counts each\n seat's slack by plan size (Max Nx = N,\n Pro = 1); fill-first concentrates them on the\n alphabetically-first eligible seat until\n it drains to the floor, then spills;\n expiring-first does the same in order of\n each seat's 7-day reset, soonest first.\n Sticky bindings are unaffected.\n Env: DARIO_POOL_STRATEGY.\n --pool-headroom-floor=<ratio|percent>\n Headroom at or below which a seat counts as\n drained: a sticky session rebinds off it and\n new conversations skip it. Setting this makes\n the floor a hard stop. 0.05 or 5%; implicit\n default 2% is a preference, max 50%. Use it when your seats\n answer with API errors in the last percent\n of a window (dario#1333).\n Env: DARIO_POOL_HEADROOM_FLOOR;\n config: pool.headroomFloor.\n --pool-fallback=<model> When every pool seat is drained or cooling,\n serve the request as <model> from whichever\n provider can, instead of surfacing the\n 429/503: a Codex/ChatGPT subscription that\n lists <model> (both wire shapes), else the\n openai-compat backend (OpenAI shape only).\n Response carries x-dario-pool-fallback.\n Needs one of those configured. Empty value\n disables. Env: DARIO_POOL_FALLBACK. Config:\n poolFallback.model.\n An entry written credits:<model> is a rung on\n the API credits a Max plan includes: with\n DARIO_CREDITS_API_KEY set (env only, never a\n flag) the request goes to the Claude API on\n that key as <model>, both wire shapes, after\n every seat and subscription rung; /analytics\n and the dashboard file it under credits, the\n ledger counts it as metered. Without the key\n the entry is inert and dario doctor says so.\n --family-fallback=<family=model,...>\n When every pool seat is parked for a model\n family, serve its requests as <model> on the\n Claude pool instead, while some seat can\n serve that model's family. For Fable, which\n runs out of included credit while Opus still\n has room: fable=claude-opus-5-5. Response\n carries x-dario-family-fallback. Empty value\n disables. Env: DARIO_FAMILY_FALLBACK.\n Config: familyFallback.\n --model-alias=<name=target>\n User-defined model alias, repeatable.\n Applied to the client's model name before\n provider-prefix parsing, so the target may\n carry a prefix to retarget the backend\n (--model-alias=my-fast=openai:gpt-4o-mini).\n Advertised on /v1/models. Names match\n case-insensitively; one step, never\n recursive. Env: DARIO_MODEL_ALIASES=\n name=target,name2=target2. Config:\n modelAliases.\n --effort=<low|medium|high|xhigh|ultracode|max|client>\n Pin the outbound output_config.effort on\n non-haiku requests, overriding the\n client's choice. Default (unset) forwards\n the client's own effort \u2014 it's a user\n knob; real CC wires whatever the user\n tuned \u2014 falling back to 'high' when the\n client sent none. 'client' is a compat\n alias for the default. 'ultracode' rides\n the wire as 'xhigh'.\n WARNING: pinned values override every\n client and may cause Anthropic's\n classifier to flip requests to 'overage'\n billing; watch -v logs for\n representative-claim changes.\n Env: DARIO_EFFORT. (dario#87)\n --max-tokens=<N|client> Override outbound max_tokens. Default\n (unset) pins 32000 (CC 2.1.116 wire default).\n Set a number to pin that value; set 'client'\n to pass through the client's requested\n max_tokens (Hermes requests 64k\u2013128k; the\n default pin silently truncates its output\n capacity). Anthropic enforces the per-model\n ceiling server-side, so too-high values\n return a clean 400.\n Env: DARIO_MAX_TOKENS. (dario#88)\n --no-overage-guard Disable the overage-guard (v4.1.0+). Default\n behavior halts the proxy on any response with\n representative-claim=overage and returns 503\n with an Anthropic-shaped error body until\n cooldown expires or `dario resume` clears\n the state. Subscribers should never see an\n overage hit during normal operation; one\n means something is wrong (wire drift,\n classifier change, account misconfig).\n Env: DARIO_OVERAGE_GUARD=off. (dario#288)\n --overage-behavior=<halt|warn>\n Behavior when overage is detected (v4.1.0+):\n halt \u2014 return 503 to new /v1/messages\n requests until resume / cooldown.\n Default. Strongest protection.\n warn \u2014 emit events + OS notification only,\n keep forwarding. Visibility mode for\n operators who want the signal without\n cutting off traffic.\n Env: DARIO_OVERAGE_BEHAVIOR.\n --overage-cooldown=MS Ms to wait before auto-clearing the halt\n state (v4.1.0+). Default: 1800000 (30 min).\n Manual `dario resume` clears immediately\n regardless of this value.\n Env: DARIO_OVERAGE_COOLDOWN.\n --no-overage-notify Suppress the native desktop notification on\n halt (v4.1.0+). Terminal BEL is the\n unconditional floor; TUI banner + SSE event\n still fire regardless. Use in headless / CI\n contexts where toast popups don't make sense.\n Env: DARIO_OVERAGE_NOTIFY=off.\n --port=PORT Port to listen on (default: 3456)\n --host=ADDRESS Address to bind to (default: 127.0.0.1)\n Use 0.0.0.0 for LAN; see README for DARIO_API_KEY\n --verbose, -v Log all requests\n --verbose=2, -vv Also dump redacted request bodies\n (env: DARIO_LOG_BODIES=1)\n --cache-trace One line per ChatGPT (codex) request on what it\n shared with the previous request on its cache\n key: the gap, whether the input extends it, a\n seat or prompt change (env: DARIO_CACHE_TRACE=1)\n --log-file=PATH Append one JSON-ND record per completed\n request to PATH. Useful for backgrounded\n proxies where stdout is unobserved (where\n --verbose can't help). Secrets scrubbed,\n no request bodies. Env: DARIO_LOG_FILE.\n --passthrough-betas=CSV Beta flags to ALWAYS forward upstream\n regardless of CC's captured set or the\n client's anthropic-beta header. Bypasses\n the billable-beta filter. Per-account\n rejection cache still applies (so a flag\n upstream 400's gets dropped, not retried\n forever). Use when you know a beta works\n on your account but isn't in the captured\n template. Env: DARIO_PASSTHROUGH_BETAS.\n\n --skip-fields=CSV CC body fields to NOT inject into outbound\n requests. Allowed: thinking,\n context_management, output_config. Headers\n and metadata stay intact (Max billing\n unchanged). Use when an upstream model 400s\n \"Extra inputs are not permitted\" on one of\n these fields \u2014 typically a non-CC SDK\n client routed through dario to a model\n that rejects the field despite the beta\n header. Env: DARIO_SKIP_FIELDS.\n\n --honor-client-thinking Pass the client body's `thinking` field\n through to upstream instead of dario's\n default `{type:\"adaptive\"}`. Lets SDK\n clients explicitly enable extended\n thinking with their own budget (e.g.\n `{type:\"enabled\", budget_tokens:8000}`).\n When honored, the paired\n `context_management.clear_thinking_*`\n edit is suppressed (shape-specific).\n No effect on Haiku or when client omits\n `thinking`. Env:\n DARIO_HONOR_CLIENT_THINKING.\n\n --preserve-output-format\n Pass the client body's\n `output_config.format` (structured-output\n JSON schema) through to upstream instead of\n dropping it during the CC rebuild. Lets SDK\n clients (e.g. the Vercel AI SDK's\n `generateObject`) get schema-constrained\n output through dario. No effect when the\n client omits `output_config.format`. Env:\n DARIO_PRESERVE_OUTPUT_FORMAT.\n\n --upstream-proxy=URL / --via=URL\n Route all of dario's outbound fetch\n calls (api.anthropic.com, OpenAI-compat\n backends, OAuth) through an HTTP/HTTPS\n proxy. Localhost calls bypass. Useful\n with a VPN provider's HTTP proxy mode\n (Mullvad, AirVPN, corporate proxy,\n privoxy/Tor) when you don't want to put\n the whole system on a system VPN.\n Requires Bun runtime. SOCKS5 not\n supported (Bun fetch limitation). See\n docs/vpn-routing.md. Env: DARIO_UPSTREAM_PROXY.\n (v3.35.0)\n\n --system-prompt=<MODE> System-prompt mode for outbound CC-shaped\n requests (v3.34.0). One of:\n verbatim \u2014 CC unchanged (default)\n partial \u2014 strip behavioral constraints\n (Tone-and-style, Text-output,\n verbosity / comment / scope\n bullets in Doing-tasks).\n Recovers ~1.2-2.8x output on\n open-ended work.\n aggressive \u2014 partial + remove prompt-level\n RLHF restatements + Executing-\n actions-with-care section.\n Adds <3% over partial; RLHF\n refusals on harmful content\n unaffected (alignment is in\n the weights, not the prompt).\n <filepath> \u2014 replace the slot entirely\n with the file's contents.\n Empirically validated as unfingerprinted by\n the billing classifier \u2014 see docs/research/\n system-prompt.md. Env: DARIO_SYSTEM_PROMPT.\n\n Quick start:\n dario login # auto-detects Claude Code credentials\n dario proxy --model=opus # or: dario proxy --passthrough\n\n Then point any Anthropic SDK at http://localhost:3456:\n export ANTHROPIC_BASE_URL=http://localhost:3456\n export ANTHROPIC_API_KEY=dario\n\n Examples:\n curl http://localhost:3456/v1/messages \\\n -H \"Content-Type: application/json\" \\\n -H \"anthropic-version: 2023-06-01\" \\\n -d '{\"model\":\"claude-fable-5\",\"max_tokens\":1024,\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n\n Your subscription handles the billing. No API key needed.\n Tokens auto-refresh in the background \u2014 set it and forget it.\n";
|
|
195
|
+
export declare const FULL_HELP = "\n dario: use your Claude or ChatGPT subscription as an API.\n\n Usage:\n dario login [--manual] [--no-proxy] [--force-reauth]\n Detect credentials + start proxy (or run OAuth).\n --manual (alias: --headless) for container / SSH\n setups \u2014 prints an authorize URL and reads the\n code you paste back instead of a local redirect.\n --no-proxy stops after auth \u2014 do not start the\n proxy (implied by --manual). Use this when the\n proxy is already running in a separate process /\n container so login doesn't collide on the port.\n --force-reauth (alias: --force) ignores any\n existing credentials and runs a fresh OAuth\n flow \u2014 for when the refresh token is dead and\n /health still reports access-token countdown.\n dario proxy [options] Start the API proxy server. Flags only: a bare\n word after \"proxy\" is an error, and \"dario proxy\n status\" prints the report instead of starting.\n dario status Check authentication status\n dario refresh Force token refresh\n dario resume Clear the overage-guard halt on a running proxy.\n v4.1.0+. Idempotent: returns \"no-op\" if the\n proxy isn't halted. Errors with a friendly hint\n if no proxy is running on localhost:3456.\n POSTs /admin/resume on the local proxy. (dario#288)\n dario logout Remove saved credentials\n dario accounts list List accounts in the multi-account pool\n (--live: the running proxy's view \u2014 status,\n window, 429s, organization, shared windows)\n dario accounts add NAME [--manual] [--from-keychain[=<target>]]\n Add a new account to the pool (runs OAuth flow).\n --manual (alias: --headless) prints an authorize\n URL and reads the code you paste back \u2014 for\n container / SSH / no-browser-on-this-machine\n setups, or as the on-Windows escape hatch when\n the URL dispatch chain truncates query params.\n --from-keychain skips OAuth and imports an\n existing Claude Code keychain entry on this\n host. With no value: uses the only/first match\n (errors with the candidate list if multiple\n entries exist). With =<target>: picks a specific\n entry by its platform identifier (Linux account\n attribute, Windows TargetName).\n dario accounts remove N Remove an account from the pool\n dario keys create NAME [--seat=ALIAS] [--models=a,b,prefix*] [--expires=30d]\n Mint a named key for one developer on a shared\n dario (v6.8). Prints the secret once; stores a\n hash in ~/.dario/keys.json. Requests it\n authenticates are attributed to NAME in\n /analytics, the ledger and the log, next to\n the root DARIO_API_KEY which keeps working.\n --seat prefers a pool seat when it has\n headroom (normal routing otherwise); --models\n refuses any other model with 403 before\n anything goes upstream; --expires refuses the\n key after 30d / 12h / 2w / an ISO date. The\n running proxy sees it on the next request.\n dario keys list Named keys: status, seat, last used, expiry,\n models. --json for the raw list.\n dario keys revoke NAME Refuse a key from now on (kept in the list).\n dario keys rotate NAME New secret, same name and settings; the old\n secret stops at once.\n dario keys remove NAME Forget a key entirely.\n dario codex list List ChatGPT-subscription accounts, served on\n /v1/chat/completions.\n dario codex add NAME Add a ChatGPT-subscription account (prints an\n authorize URL; paste the redirect URL back).\n dario codex remove NAME Remove a ChatGPT-subscription account.\n dario add altman [NAME] Attach a ChatGPT subscription \u2014 the same thing\n as 'dario codex add', named by whose plan it is.\n 'dario add amodei' attaches a Claude account.\n dario backend list List configured OpenAI-compat backends\n dario backend add NAME --key=sk-... [--base-url=...]\n Add an OpenAI-compat backend (OpenAI, OpenRouter, Groq, etc.)\n dario backend remove N Remove an OpenAI-compat backend\n dario subagent install Register ~/.claude/agents/dario.md so Claude Code\n can delegate dario diagnostics / template-refresh\n operations to a named sub-agent (v3.26)\n dario subagent remove Remove the registered sub-agent file\n dario subagent status Show whether the sub-agent is installed\n dario mcp Run dario as an MCP (Model Context Protocol)\n server on stdio. Exposes read-only tools\n (doctor, status, accounts_list, backends_list,\n subagent_status, fingerprint_info) so MCP\n clients (Claude Desktop, IDEs, etc.) can\n inspect dario's state. No destructive ops\n are exposed \u2014 mutations still require the\n CLI. (v3.27)\n dario doctor Print a health report: dario / Node / CC /\n template / drift / OAuth / pool / backends\n dario doctor --probe Also hit Anthropic's authorize endpoint with\n dario's effective OAuth config and surface\n the server's verdict \u2014 the single reliable\n signal for scope-policy drift (dario#42/#71\n class). One GET to claude.ai; no PII.\n dario doctor --usage Fire one minimal Haiku request through your\n OAuth and surface the rate-limit snapshot:\n All-models 5h/7d, per-model 7d buckets\n (Sonnet only, Opus only when Anthropic ships\n them), overage. Mirrors the user-dashboard\n usage page. Costs ~1 subscription request.\n dario doctor --obedience\n Probe each model family THROUGH the running\n proxy with a client system prompt (\"reply\n with ONLY the word PONG\") and assert the\n reply obeys. Catches upstream behavioral\n drift in client-system steering \u2014 the\n 2026-06-12 sonnet class (dario#509) that\n 200s, billing, and template checks all\n miss. Needs `dario proxy` running; costs\n at most a few tiny subscription requests.\n dario doctor --json Emit the check report as structured JSON\n for machine consumption (status bots,\n CI scripts, etc.) instead of the\n human-readable table.\n dario doctor --auth-check\n One-shot listener on an ephemeral loopback\n port. Send ONE request from your client\n (OpenClaw, Hermes, curl, etc.) and dario\n classifies the auth headers it sees against\n DARIO_API_KEY \u2014 with redacted previews and\n a targeted diagnosis (dario#97 class). Use\n --timeout-ms=N to adjust the 30s default.\n dario doctor --bun-bootstrap\n One-shot Bun installer. Closes the gap\n between \"doctor warned about Node-only TLS\n fingerprint\" and \"Bun on PATH\" without\n copy-pasting a curl-to-shell line. Skips\n when Bun is already installed. Pure\n delegation to the official installer at\n bun.com \u2014 dario does not vendor or pin a\n Bun version.\n dario config Print the effective configuration (port,\n host, DARIO_API_KEY state, OAuth status,\n pool, backends, paths) with credentials\n redacted. Safe to paste into bug reports.\n --json for structured output.\n dario usage Burn-rate summary of the running proxy's\n traffic (last 60 min): requests, token\n totals, subscription % vs. extra-usage,\n per-account rotation if pool mode is on.\n Hits /analytics on the local proxy. Works\n only when proxy is running; for a one-off\n rate-limit snapshot from Anthropic, see\n `dario doctor --usage`. --port=N to target\n a non-default port; --json for the raw\n /analytics payload. Above the window: the\n lifetime API-equivalent spend from the\n ledger (read from disk when the proxy is\n down). --card[=file.svg] writes a share\n card of that number (default\n dario-api-equivalent.svg). (v6.6)\n --donut[=file.svg] writes it as three\n rings: by model, by key, subscription\n vs metered (default dario-spend-donuts.svg).\n --by-key splits the lifetime number per\n consumer: named key, x-dario-consumer\n header, or hashed user id. (v6.8)\n dario compare Read the shadow-compare log written by\n requests carrying `x-dario-compare`:\n per-model calls, success rate, median\n latency, how often the answer parsed as\n JSON, average length \u2014 plus why any\n comparison was skipped. --dir=PATH to read\n elsewhere, --json for the summary. (v6.7)\n dario upgrade npm install -g @askalf/dario@latest with a\n pre-flight current-vs-latest check.\n\n Proxy options:\n --model=MODEL Force a model for all requests\n Shortcuts: fable, fable1m, opus, sonnet, haiku\n Full IDs: claude-fable-5, claude-opus-5-5,\n claude-sonnet-5 (append [1m] for 1M context)\n Provider prefix: openai:gpt-4o, groq:llama-3.3-70b,\n claude:opus, local:qwen-coder (forces backend)\n Default: passthrough (client decides)\n --fast-model=MODEL Route Haiku-tier sub-agent requests to MODEL\n instead of --model, so Claude Code's cheap\n sub-agents aren't upgraded to the forced model.\n Same MODEL forms as --model. No effect unless set.\n --analytics-token=TOKEN Read-only credential for /analytics*, /metrics and\n the /analytics/ui page (env DARIO_ANALYTICS_TOKEN).\n Refused on /v1/*, /accounts, /status, /admin/*:\n a scraper or a browser gets the numbers, never\n a request slot. Gates nothing without DARIO_API_KEY.\n --no-claude-auth Don't load or refresh the Claude OAuth token \u2014\n for OpenAI-only proxies (e.g. --model=openai:...).\n Prevents dario rotating a shared refresh token out\n from under an interactive Claude Code on the same\n machine. Claude-bound requests then return a clean\n unauthenticated error.\n --passthrough, --thin Thin proxy \u2014 OAuth swap only, no injection\n --preserve-tools Forward client tool schemas unchanged\n Loses subscription routing; use for custom agents\n --hybrid-tools Remap to CC tools, inject sessionId/requestId/etc.\n Keeps subscription routing for custom agents\n --merge-tools Send CC's canonical tools first, append the\n client's custom tools after (deduped by name).\n Model can call either; tool calls flow back\n unchanged. EXPERIMENTAL \u2014 Anthropic's billing\n classifier may flip routing on the appended\n tail. Validate with --verbose on the first\n 1-2 requests. Mutually exclusive with\n --preserve-tools and --hybrid-tools.\n --no-auto-detect Disable Cline/Kilo/Roo auto-preserve-tools\n (v3.19.3 behavior). Keeps CC fingerprint\n intact even when a text-tool client is\n detected; use --preserve-tools per session\n when edits are needed. (dario#40)\n --strict-tls Refuse to start proxy mode unless this process\n runs under Bun at a version whose JA3 is verified\n to match Claude Code (\u2265 v1.3.14). Bun is what\n Claude Code uses; matching its TLS stack keeps the\n proxy's JA3/JA4 ClientHello indistinguishable\n from a stock CC request \u2014 but an older Bun ships an\n older BoringSSL whose ClientHello diverges (#813).\n Install/upgrade Bun (https://bun.sh) so dario\n auto-relaunches under it. (v3.23)\n --stealth Single-flag behavioral-stealth preset.\n Flips pace-jitter, think-time, and\n session-start defaults from 0 to non-zero\n values sized for real-CC inter-arrival\n statistics (pace-jitter=300, think\n base/perToken/jitter=800/4/1500 capped at\n 25s, session-start min/jitter=1200/3000).\n Per-knob --pace-jitter / --think-time-* /\n --session-start-* flags and env vars\n still win \u2014 flip stealth on, tune any\n axis afterwards. Env: DARIO_STEALTH.\n --pace-min=MS Minimum ms between upstream requests\n (default: 500). Prevents request floods\n that are distinguishable from human-paced\n CC traffic.\n --pace-jitter=MS Max additional uniform-random jitter (ms)\n added on top of --pace-min per request.\n Default: 0 (off). Set to e.g. 300 to hide\n the floor from long-run inter-arrival\n statistics. (v3.24)\n --think-time-base=MS Post-response \"think time\" base \u2014 constant\n ms added before the next request fires.\n Models the wall-clock pause between an\n interactive CC user reading a response and\n typing the next message. Default: 0 (off).\n Env: DARIO_THINK_TIME_BASE_MS.\n --think-time-per-token=MS\n Additional ms per output token of the\n previous response (linear). e.g. 5 \u2192 a\n 1000-token response adds 5s of read time\n before the next request. Default: 0.\n Env: DARIO_THINK_TIME_PER_TOKEN_MS.\n --think-time-jitter=MS Max uniform-random jitter on top of\n base+perToken*tokens. Hides the formula\n from long-run inter-arrival statistics.\n Default: 0.\n Env: DARIO_THINK_TIME_JITTER_MS.\n --think-time-max=MS Upper bound on think time so a 50k-token\n response doesn't pause for minutes.\n Default: 30000 (30s).\n Env: DARIO_THINK_TIME_MAX_MS.\n --session-start-min=MS Floor on session-start delay \u2014 applied to\n the first request only (lastResponseTime\n === 0). Real CC sessions open with seconds\n of startup latency, not microseconds.\n Default: 0 (off).\n Env: DARIO_SESSION_START_MIN_MS.\n --session-start-jitter=MS\n Max uniform-random jitter on session-start\n delay. Default: 0.\n Env: DARIO_SESSION_START_JITTER_MS.\n --drain-on-close When the client disconnects mid-stream,\n keep consuming the upstream SSE to EOF\n so Anthropic sees the same read-to-\n completion pattern native Claude Code\n produces. Trades tokens (the response\n is fully generated even if nobody reads\n it) for fingerprint fidelity. Bounded by\n the 5-minute upstream timeout. (v3.25)\n --no-midstream-continue Do not finish a streamed answer that dies\n mid-way from the other subscription. By\n default a stream that breaks with content\n already on the wire (socket reset,\n overloaded_error, codex response.failed)\n is resumed through dario's own front door\n at the other provider's entry in\n --pool-fallback, spliced onto the same\n client stream, and closed cleanly; the\n client sees one message. Off, or with no\n fallback entry for the other provider,\n the stream ends truncated as before.\n Env: DARIO_MIDSTREAM_CONTINUE=0. (v6.1)\n --no-ledger Do not keep the lifetime ledger\n (~/.dario/ledger.json): per-day, per-model\n token totals that let /analytics and\n `dario usage` say what the traffic would\n have cost on the metered API since the\n first request, across restarts. Env:\n DARIO_LEDGER=0; DARIO_LEDGER_PATH=<file>\n moves it. (v6.6)\n --no-keys Ignore named keys (~/.dario/keys.json): only\n DARIO_API_KEY authenticates. Env: DARIO_KEYS=0;\n --keys-path=<file> / DARIO_KEYS_PATH moves the\n file. See `dario keys`. (v6.8)\n --key-socket=<path>=<key>\n Also listen on a unix socket bound to one\n named key: every request on it is that\n key's, with no secret sent or stored by the\n caller. Who may connect is the socket's\n directory and group (it is created 0660).\n Repeatable. Env: DARIO_KEY_SOCKETS=<spec>,..\n --session-idle-rotate=MS Idle ms before an account's session id\n rotates (default: 900000 = 15 min).\n Real CC rotates once per conversation, not\n per call; the default matches its observed\n cadence. Applies per account \u2014 a pool of one\n rotates as the pre-v5 single account did. (v3.28)\n --session-rotate-jitter=MS\n Max additional uniform-random jitter (ms)\n added to the idle threshold, sampled once\n per session at creation. Default: 0 (off).\n Hides the exact threshold from long-run\n rotation statistics. (v3.28)\n --session-max-age=MS Hard cap on a session id's lifetime\n regardless of activity. Default: off. Set\n for always-on pipelines where an idle\n window would never trigger. (v3.28)\n --session-per-client Give each upstream client (keyed by\n x-session-id / x-client-session-id\n header) its own rotated session id.\n Default: off (single session across all\n clients, v3.27 behaviour). (v3.28)\n --preserve-orchestration-tags[=TAG,TAG]\n Opt specific orchestration wrapper tags\n (<system-reminder>, <env>, <thinking>,\n etc.) out of the scrub. Bare flag =\n preserve all. Value form = preserve only\n those listed; everything else is still\n stripped. Default: strip every tag in\n ORCHESTRATION_TAG_NAMES. Env mirror:\n DARIO_PRESERVE_ORCHESTRATION_TAGS=*\n or =tag1,tag2. (v3.30.7, dario#78)\n --no-live-capture Skip the background live-fingerprint\n refresh entirely. dario uses the bundled\n snapshot and will NOT spawn the installed\n Claude Code binary. For air-gapped /\n reproducible-build / CI-harness runs.\n Env: DARIO_NO_LIVE_CAPTURE=1.\n (v3.30.8, dario#77)\n --strict-template Refuse to start if the loaded template\n is the bundled snapshot (no live capture\n ever succeeded) or drifts from the\n installed CC version. Same philosophy\n as --strict-tls: make the unsafe state\n require intent. Env: DARIO_STRICT_TEMPLATE=1.\n (v3.30.8, dario#77)\n --shutdown-grace=MS How long SIGTERM waits for in-flight requests\n to finish before exiting (default: 90000).\n The listener closes at once. Set the\n container's stop grace above it, or Docker\n SIGKILLs the drain. Env:\n DARIO_SHUTDOWN_GRACE_MS. (dario#1370)\n --upstream-timeout=MS How long dario waits on upstream (default:\n 300000). Until the response streams it caps\n the whole wait; while a 2xx streams it is\n the longest gap between two chunks, so a\n live stream is never cut. 0 keeps the\n default. Env: DARIO_UPSTREAM_TIMEOUT_MS.\n --max-concurrent=N Max in-flight requests across the WHOLE proxy,\n not per seat (default: 10; a pool defaults to\n 10 per seat). Past it, requests wait in dario.\n Env: DARIO_MAX_CONCURRENT. (dario#80, #1244)\n --max-queued=N Max requests buffered waiting for a\n concurrency slot before dario returns\n 429 \"queue-full\" (default: 128).\n Env: DARIO_MAX_QUEUED. (dario#80)\n --max-concurrent-per-consumer=N\n Max in-flight requests per consumer, keyed\n by the x-dario-consumer request header; a\n consumer at the cap waits while others keep\n flowing (default: 0 = off).\n Env: DARIO_MAX_CONCURRENT_PER_CONSUMER.\n --pool-shared-state Share rate-limit readings and sticky bindings\n with the other dario instances through the\n refresh-lock service (needs\n DARIO_REFRESH_LOCK_URL / _TOKEN). Fails open.\n Env: DARIO_POOL_SHARED_STATE=1.\n --pool-shared-state-interval=MS\n How often to pull peers' readings\n (default: 2000). Env:\n DARIO_POOL_SHARED_STATE_INTERVAL_MS.\n --pool-shared-accounts Keep the Claude accounts and ChatGPT seats in\n registries on the refresh-lock service, merged\n at startup and\n every 15s, so a new replica starts with its\n peers' accounts and adds and removals reach\n every instance. The registry holds every\n seat's credentials. Fails open.\n Env: DARIO_POOL_SHARED_ACCOUNTS=1.\n --queue-timeout=MS Max ms a queued request waits before\n dario returns 504 \"queue-timeout\"\n (default: 60000).\n Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)\n --pool-strategy=<headroom|headroom-by-plan|fill-first|expiring-first>\n Where new conversations land in a multi-\n account pool. headroom (default) spreads\n them to the seat with the most slack;\n headroom-by-plan (opt-in) counts each\n seat's slack by plan size (Max Nx = N,\n Pro = 1); fill-first concentrates them on the\n alphabetically-first eligible seat until\n it drains to the floor, then spills;\n expiring-first does the same in order of\n each seat's 7-day reset, soonest first.\n Sticky bindings are unaffected.\n Env: DARIO_POOL_STRATEGY.\n --pool-headroom-floor=<ratio|percent>\n Headroom at or below which a seat counts as\n drained: a sticky session rebinds off it and\n new conversations skip it. Setting this makes\n the floor a hard stop. 0.05 or 5%; implicit\n default 2% is a preference, max 50%. Use it when your seats\n answer with API errors in the last percent\n of a window (dario#1333).\n Env: DARIO_POOL_HEADROOM_FLOOR;\n config: pool.headroomFloor.\n --pool-fallback=<model> When every pool seat is drained or cooling,\n serve the request as <model> from whichever\n provider can, instead of surfacing the\n 429/503: a Codex/ChatGPT subscription that\n lists <model> (both wire shapes), else the\n openai-compat backend (OpenAI shape only).\n Response carries x-dario-pool-fallback.\n Needs one of those configured. Empty value\n disables. Env: DARIO_POOL_FALLBACK. Config:\n poolFallback.model.\n An entry written credits:<model> is a rung on\n the API credits a Max plan includes: with\n DARIO_CREDITS_API_KEY set (env only, never a\n flag) the request goes to the Claude API on\n that key as <model>, both wire shapes, after\n every seat and subscription rung; /analytics\n and the dashboard file it under credits, the\n ledger counts it as metered. Without the key\n the entry is inert and dario doctor says so.\n --family-fallback=<family=model,...>\n When every pool seat is parked for a model\n family, serve its requests as <model> on the\n Claude pool instead, while some seat can\n serve that model's family. For Fable, which\n runs out of included credit while Opus still\n has room: fable=claude-opus-5-5. Response\n carries x-dario-family-fallback. Empty value\n disables. Env: DARIO_FAMILY_FALLBACK.\n Config: familyFallback.\n --model-alias=<name=target>\n User-defined model alias, repeatable.\n Applied to the client's model name before\n provider-prefix parsing, so the target may\n carry a prefix to retarget the backend\n (--model-alias=my-fast=openai:gpt-4o-mini).\n Advertised on /v1/models. Names match\n case-insensitively; one step, never\n recursive. Env: DARIO_MODEL_ALIASES=\n name=target,name2=target2. Config:\n modelAliases.\n --effort=<low|medium|high|xhigh|ultracode|max|client>\n Pin the outbound output_config.effort on\n non-haiku requests, overriding the\n client's choice. Default (unset) forwards\n the client's own effort \u2014 it's a user\n knob; real CC wires whatever the user\n tuned \u2014 falling back to 'high' when the\n client sent none. 'client' is a compat\n alias for the default. 'ultracode' rides\n the wire as 'xhigh'.\n WARNING: pinned values override every\n client and may cause Anthropic's\n classifier to flip requests to 'overage'\n billing; watch -v logs for\n representative-claim changes.\n Env: DARIO_EFFORT. (dario#87)\n --max-tokens=<N|client> Override outbound max_tokens. Default\n (unset) pins 32000 (CC 2.1.116 wire default).\n Set a number to pin that value; set 'client'\n to pass through the client's requested\n max_tokens (Hermes requests 64k\u2013128k; the\n default pin silently truncates its output\n capacity). Anthropic enforces the per-model\n ceiling server-side, so too-high values\n return a clean 400.\n Env: DARIO_MAX_TOKENS. (dario#88)\n --no-overage-guard Disable the overage-guard (v4.1.0+). Default\n behavior halts the proxy on any response with\n representative-claim=overage and returns 503\n with an Anthropic-shaped error body until\n cooldown expires or `dario resume` clears\n the state. Subscribers should never see an\n overage hit during normal operation; one\n means something is wrong (wire drift,\n classifier change, account misconfig).\n Env: DARIO_OVERAGE_GUARD=off. (dario#288)\n --overage-behavior=<halt|warn>\n Behavior when overage is detected (v4.1.0+):\n halt \u2014 return 503 to new /v1/messages\n requests until resume / cooldown.\n Default. Strongest protection.\n warn \u2014 emit events + OS notification only,\n keep forwarding. Visibility mode for\n operators who want the signal without\n cutting off traffic.\n Env: DARIO_OVERAGE_BEHAVIOR.\n --overage-cooldown=MS Ms to wait before auto-clearing the halt\n state (v4.1.0+). Default: 1800000 (30 min).\n Manual `dario resume` clears immediately\n regardless of this value.\n Env: DARIO_OVERAGE_COOLDOWN.\n --no-overage-notify Suppress the native desktop notification on\n halt (v4.1.0+). Terminal BEL is the\n unconditional floor; TUI banner + SSE event\n still fire regardless. Use in headless / CI\n contexts where toast popups don't make sense.\n Env: DARIO_OVERAGE_NOTIFY=off.\n --port=PORT Port to listen on (default: 3456)\n --host=ADDRESS Address to bind to (default: 127.0.0.1)\n Use 0.0.0.0 for LAN; see README for DARIO_API_KEY\n --verbose, -v Log all requests\n --verbose=2, -vv Also dump redacted request bodies\n (env: DARIO_LOG_BODIES=1)\n --cache-trace One line per ChatGPT (codex) request on what it\n shared with the previous request on its cache\n key: the gap, whether the input extends it, a\n seat or prompt change (env: DARIO_CACHE_TRACE=1)\n --log-file=PATH Append one JSON-ND record per completed\n request to PATH. Useful for backgrounded\n proxies where stdout is unobserved (where\n --verbose can't help). Secrets scrubbed,\n no request bodies. Env: DARIO_LOG_FILE.\n --passthrough-betas=CSV Beta flags to ALWAYS forward upstream\n regardless of CC's captured set or the\n client's anthropic-beta header. Bypasses\n the billable-beta filter. Per-account\n rejection cache still applies (so a flag\n upstream 400's gets dropped, not retried\n forever). Use when you know a beta works\n on your account but isn't in the captured\n template. Env: DARIO_PASSTHROUGH_BETAS.\n\n --skip-fields=CSV CC body fields to NOT inject into outbound\n requests. Allowed: thinking,\n context_management, output_config. Headers\n and metadata stay intact (Max billing\n unchanged). Use when an upstream model 400s\n \"Extra inputs are not permitted\" on one of\n these fields \u2014 typically a non-CC SDK\n client routed through dario to a model\n that rejects the field despite the beta\n header. Env: DARIO_SKIP_FIELDS.\n\n --honor-client-thinking Pass the client body's `thinking` field\n through to upstream instead of dario's\n default `{type:\"adaptive\"}`. Lets SDK\n clients explicitly enable extended\n thinking with their own budget (e.g.\n `{type:\"enabled\", budget_tokens:8000}`).\n When honored, the paired\n `context_management.clear_thinking_*`\n edit is suppressed (shape-specific).\n No effect on Haiku or when client omits\n `thinking`. Env:\n DARIO_HONOR_CLIENT_THINKING.\n\n --preserve-output-format\n Pass the client body's\n `output_config.format` (structured-output\n JSON schema) through to upstream instead of\n dropping it during the CC rebuild. Lets SDK\n clients (e.g. the Vercel AI SDK's\n `generateObject`) get schema-constrained\n output through dario. No effect when the\n client omits `output_config.format`. Env:\n DARIO_PRESERVE_OUTPUT_FORMAT.\n\n --upstream-proxy=URL / --via=URL\n Route all of dario's outbound fetch\n calls (api.anthropic.com, OpenAI-compat\n backends, OAuth) through an HTTP/HTTPS\n proxy. Localhost calls bypass. Useful\n with a VPN provider's HTTP proxy mode\n (Mullvad, AirVPN, corporate proxy,\n privoxy/Tor) when you don't want to put\n the whole system on a system VPN.\n Requires Bun runtime. SOCKS5 not\n supported (Bun fetch limitation). See\n docs/vpn-routing.md. Env: DARIO_UPSTREAM_PROXY.\n (v3.35.0)\n\n --system-prompt=<MODE> System-prompt mode for outbound CC-shaped\n requests (v3.34.0). One of:\n verbatim \u2014 CC unchanged (default)\n partial \u2014 strip behavioral constraints\n (Tone-and-style, Text-output,\n verbosity / comment / scope\n bullets in Doing-tasks).\n Recovers ~1.2-2.8x output on\n open-ended work.\n aggressive \u2014 partial + remove prompt-level\n RLHF restatements + Executing-\n actions-with-care section.\n Adds <3% over partial; RLHF\n refusals on harmful content\n unaffected (alignment is in\n the weights, not the prompt).\n <filepath> \u2014 replace the slot entirely\n with the file's contents.\n Empirically validated as unfingerprinted by\n the billing classifier \u2014 see docs/research/\n system-prompt.md. Env: DARIO_SYSTEM_PROMPT.\n\n Quick start:\n dario login # auto-detects Claude Code credentials\n dario proxy --model=opus # or: dario proxy --passthrough\n\n Then point any Anthropic SDK at http://localhost:3456:\n export ANTHROPIC_BASE_URL=http://localhost:3456\n export ANTHROPIC_API_KEY=dario\n\n Examples:\n curl http://localhost:3456/v1/messages \\\n -H \"Content-Type: application/json\" \\\n -H \"anthropic-version: 2023-06-01\" \\\n -d '{\"model\":\"claude-fable-5\",\"max_tokens\":1024,\"messages\":[{\"role\":\"user\",\"content\":\"Hello\"}]}'\n\n Your subscription handles the billing. No API key needed.\n Tokens auto-refresh in the background \u2014 set it and forget it.\n";
|
|
196
196
|
/**
|
|
197
197
|
* Decide whether this module is being invoked as the CLI entry point or
|
|
198
198
|
* imported as a library. Pure, exported for tests; the file-bottom uses
|
package/dist/cli.js
CHANGED
|
@@ -63,7 +63,8 @@ import { VALID_EFFORT_VALUES } from './cc-template.js';
|
|
|
63
63
|
import { grantAge, describeGrantAge } from './refresh-grant.js';
|
|
64
64
|
import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, loadAccount, ensureLoginCredentialsInPool, resyncLoginFromCredentialsIfStale, MIGRATED_LOGIN_ALIAS } from './accounts.js';
|
|
65
65
|
import { removeAccountEverywhere } from './account-sync.js';
|
|
66
|
-
import {
|
|
66
|
+
import { removeCodexAccountEverywhere } from './codex-account-sync.js';
|
|
67
|
+
import { listCodexAccountAliases, loadAllCodexAccounts, loadCodexAccount, startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, parseCodexManualPaste } from './codex-accounts.js';
|
|
67
68
|
import { describeCodexWindow } from './codex-usage.js';
|
|
68
69
|
import { listBackends, saveBackend, removeBackend, defaultBackendBaseUrl } from './openai-backend.js';
|
|
69
70
|
import { parseOutboundProxy, installOutboundProxyWrapper } from './outbound-proxy.js';
|
|
@@ -572,8 +573,8 @@ async function proxy() {
|
|
|
572
573
|
|| process.env['DARIO_POOL_SHARED_STATE'] === 'true';
|
|
573
574
|
const poolSharedStateIntervalMs = parsePositiveIntFlag('--pool-shared-state-interval=')
|
|
574
575
|
?? parsePositiveIntEnv(process.env['DARIO_POOL_SHARED_STATE_INTERVAL_MS']);
|
|
575
|
-
// --pool-shared-accounts: keep the Claude accounts
|
|
576
|
-
// same service, merged at startup and every 15s (docs/multi-instance.md).
|
|
576
|
+
// --pool-shared-accounts: keep the Claude accounts and ChatGPT seats in
|
|
577
|
+
// registries on the same service, merged at startup and every 15s (docs/multi-instance.md).
|
|
577
578
|
const poolSharedAccounts = poolSharedAccountsEnabled();
|
|
578
579
|
// --effort=low|medium|high|xhigh|ultracode|max|client — pin the outbound
|
|
579
580
|
// output_config.effort (dario#87). Default (unset) forwards the client's
|
|
@@ -1265,7 +1266,34 @@ export function resolveUpstreamTimeoutMs(flag, env) {
|
|
|
1265
1266
|
const ms = flag ?? parsePositiveIntEnv(env);
|
|
1266
1267
|
return ms === 0 ? undefined : ms;
|
|
1267
1268
|
}
|
|
1268
|
-
/**
|
|
1269
|
+
/**
|
|
1270
|
+
* Remove an account with the shared registry on, and say how it went: the removal goes to the
|
|
1271
|
+
* registry first, and anything but a clean removal exits non-zero.
|
|
1272
|
+
*/
|
|
1273
|
+
async function removeEverywhereOrExit(label, lockUrl, alias, remove) {
|
|
1274
|
+
let result;
|
|
1275
|
+
try {
|
|
1276
|
+
result = await remove(lockUrl, process.env['DARIO_REFRESH_LOCK_TOKEN'] ?? '', alias);
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
console.error(`[dario] ${label} "${alias}" NOT removed: the shared account registry at ${lockUrl} did not take the removal (${err instanceof Error ? err.message : err}), and the other instances would put the account back. Try again once it answers, or unset DARIO_POOL_SHARED_ACCOUNTS to remove this instance's file only.`);
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
if (result === 'registry-newer') {
|
|
1283
|
+
console.error(`[dario] ${label} "${alias}" NOT removed: the shared account registry holds a later login for it, from another instance, so nothing was changed. Run the command again to remove that login too.`);
|
|
1284
|
+
process.exit(1);
|
|
1285
|
+
}
|
|
1286
|
+
if (result === 'local-newer') {
|
|
1287
|
+
console.error(`[dario] ${label} "${alias}" removed from the shared account registry, but the file here holds a later login, which stays and goes back to the registry on the next sync. Run the command again to remove that login too.`);
|
|
1288
|
+
process.exit(1);
|
|
1289
|
+
}
|
|
1290
|
+
if (result === 'local-failed') {
|
|
1291
|
+
console.error(`[dario] ${label} "${alias}" removed from the shared account registry, but its file here could not be deleted. Check that the accounts directory is writable and run the command again; until the file goes, this instance keeps serving the account.`);
|
|
1292
|
+
process.exit(1);
|
|
1293
|
+
}
|
|
1294
|
+
console.log(`[dario] ${label} "${alias}" removed, here and from the shared account registry; the other instances drop it on their next sync.`);
|
|
1295
|
+
}
|
|
1296
|
+
/** --pool-shared-accounts or DARIO_POOL_SHARED_ACCOUNTS: read by the proxy, `dario accounts remove` and `dario codex remove`. */
|
|
1269
1297
|
function poolSharedAccountsEnabled() {
|
|
1270
1298
|
return args.includes('--pool-shared-accounts')
|
|
1271
1299
|
|| process.env['DARIO_POOL_SHARED_ACCOUNTS'] === '1'
|
|
@@ -1692,27 +1720,7 @@ async function accounts() {
|
|
|
1692
1720
|
console.error(`[dario] No account "${alias}" found.`);
|
|
1693
1721
|
process.exit(1);
|
|
1694
1722
|
}
|
|
1695
|
-
|
|
1696
|
-
try {
|
|
1697
|
-
result = await removeAccountEverywhere(lockUrl, process.env['DARIO_REFRESH_LOCK_TOKEN'] ?? '', alias);
|
|
1698
|
-
}
|
|
1699
|
-
catch (err) {
|
|
1700
|
-
console.error(`[dario] Account "${alias}" NOT removed: the shared account registry at ${lockUrl} did not take the removal (${err instanceof Error ? err.message : err}), and the other instances would put the account back. Try again once it answers, or unset DARIO_POOL_SHARED_ACCOUNTS to remove this instance's file only.`);
|
|
1701
|
-
process.exit(1);
|
|
1702
|
-
}
|
|
1703
|
-
if (result === 'registry-newer') {
|
|
1704
|
-
console.error(`[dario] Account "${alias}" NOT removed: the shared account registry holds a later login for it, from another instance, so nothing was changed. Run the command again to remove that login too.`);
|
|
1705
|
-
process.exit(1);
|
|
1706
|
-
}
|
|
1707
|
-
if (result === 'local-newer') {
|
|
1708
|
-
console.error(`[dario] Account "${alias}" removed from the shared account registry, but the file here holds a later login, which stays and goes back to the registry on the next sync. Run the command again to remove that login too.`);
|
|
1709
|
-
process.exit(1);
|
|
1710
|
-
}
|
|
1711
|
-
if (result === 'local-failed') {
|
|
1712
|
-
console.error(`[dario] Account "${alias}" removed from the shared account registry, but its file here could not be deleted. Check that the accounts directory is writable and run the command again; until the file goes, this instance keeps serving the account.`);
|
|
1713
|
-
process.exit(1);
|
|
1714
|
-
}
|
|
1715
|
-
console.log(`[dario] Account "${alias}" removed, here and from the shared account registry; the other instances drop it on their next sync.`);
|
|
1723
|
+
await removeEverywhereOrExit('Account', lockUrl, alias, removeAccountEverywhere);
|
|
1716
1724
|
return;
|
|
1717
1725
|
}
|
|
1718
1726
|
const ok = await removeAccount(alias);
|
|
@@ -1837,6 +1845,17 @@ async function codex() {
|
|
|
1837
1845
|
console.error('');
|
|
1838
1846
|
process.exit(1);
|
|
1839
1847
|
}
|
|
1848
|
+
// As in `dario accounts remove`: the removal is published before the file
|
|
1849
|
+
// goes, or a replica's next sync pushes the seat back from its own copy.
|
|
1850
|
+
const lockUrl = process.env['DARIO_REFRESH_LOCK_URL'];
|
|
1851
|
+
if (poolSharedAccountsEnabled() && lockUrl) {
|
|
1852
|
+
if (!(await loadCodexAccount(alias))) {
|
|
1853
|
+
console.error(`[dario] No Codex account "${alias}" found.`);
|
|
1854
|
+
process.exit(1);
|
|
1855
|
+
}
|
|
1856
|
+
await removeEverywhereOrExit('Codex account', lockUrl, alias, removeCodexAccountEverywhere);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1840
1859
|
const ok = await removeCodexAccount(alias);
|
|
1841
1860
|
if (ok) {
|
|
1842
1861
|
console.log(`[dario] Codex account "${alias}" removed.`);
|
|
@@ -2370,8 +2389,9 @@ export const FULL_HELP = `
|
|
|
2370
2389
|
How often to pull peers' readings
|
|
2371
2390
|
(default: 2000). Env:
|
|
2372
2391
|
DARIO_POOL_SHARED_STATE_INTERVAL_MS.
|
|
2373
|
-
--pool-shared-accounts Keep the Claude accounts
|
|
2374
|
-
refresh-lock service, merged
|
|
2392
|
+
--pool-shared-accounts Keep the Claude accounts and ChatGPT seats in
|
|
2393
|
+
registries on the refresh-lock service, merged
|
|
2394
|
+
at startup and
|
|
2375
2395
|
every 15s, so a new replica starts with its
|
|
2376
2396
|
peers' accounts and adds and removals reach
|
|
2377
2397
|
every instance. The registry holds every
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT seats in the shared account registry (`--pool-shared-accounts`). The
|
|
3
|
+
* sync and its rules are account-sync.ts; this is where these records live and
|
|
4
|
+
* how to read them.
|
|
5
|
+
*
|
|
6
|
+
* POST /codex-accounts/list {} -> { accounts, removed }
|
|
7
|
+
* POST /codex-accounts/<alias>/put { credentials } -> { stored }
|
|
8
|
+
* POST /codex-accounts/<alias>/remove { removedAt } -> { removed }
|
|
9
|
+
*
|
|
10
|
+
* The seats need no refresh lock (getFreshCodexAccount says why: two refreshes
|
|
11
|
+
* of one token both succeed), so each replica refreshes on its own and the
|
|
12
|
+
* later expiry wins here. Nothing holds a seat in memory either, since the
|
|
13
|
+
* proxy reads the files on every request, so a sync only has to write the file:
|
|
14
|
+
* there is no pool to attach.
|
|
15
|
+
*
|
|
16
|
+
* Credentials only. A seat's usage readings and cool-downs stay per instance.
|
|
17
|
+
*/
|
|
18
|
+
import { type AccountRegistry, type AccountSyncStore, type SharedRemovalResult } from './account-sync.js';
|
|
19
|
+
import { type CodexAccountCredentials } from './codex-accounts.js';
|
|
20
|
+
export declare const codexAccountFileStore: AccountSyncStore<CodexAccountCredentials>;
|
|
21
|
+
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
22
|
+
export declare function isCodexRegistryRecord(alias: string, rec: unknown): rec is CodexAccountCredentials;
|
|
23
|
+
export declare const CODEX_REGISTRY: AccountRegistry<CodexAccountCredentials>;
|
|
24
|
+
/** removeAccountEverywhere for a ChatGPT seat, for `dario codex remove`. */
|
|
25
|
+
export declare function removeCodexAccountEverywhere(baseUrl: string, token: string, alias: string, opts?: {
|
|
26
|
+
store?: AccountSyncStore<CodexAccountCredentials>;
|
|
27
|
+
removedAt?: number;
|
|
28
|
+
}): Promise<SharedRemovalResult>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT seats in the shared account registry (`--pool-shared-accounts`). The
|
|
3
|
+
* sync and its rules are account-sync.ts; this is where these records live and
|
|
4
|
+
* how to read them.
|
|
5
|
+
*
|
|
6
|
+
* POST /codex-accounts/list {} -> { accounts, removed }
|
|
7
|
+
* POST /codex-accounts/<alias>/put { credentials } -> { stored }
|
|
8
|
+
* POST /codex-accounts/<alias>/remove { removedAt } -> { removed }
|
|
9
|
+
*
|
|
10
|
+
* The seats need no refresh lock (getFreshCodexAccount says why: two refreshes
|
|
11
|
+
* of one token both succeed), so each replica refreshes on its own and the
|
|
12
|
+
* later expiry wins here. Nothing holds a seat in memory either, since the
|
|
13
|
+
* proxy reads the files on every request, so a sync only has to write the file:
|
|
14
|
+
* there is no pool to attach.
|
|
15
|
+
*
|
|
16
|
+
* Credentials only. A seat's usage readings and cool-downs stay per instance.
|
|
17
|
+
*/
|
|
18
|
+
import { removeAccountEverywhere } from './account-sync.js';
|
|
19
|
+
import { extractChatGPTAccountId } from './codex-backend.js';
|
|
20
|
+
import { isValidCodexAlias, loadAllCodexAccounts, localCodexAccountRemovals, removeSharedCodexAccount, saveSharedCodexAccount, } from './codex-accounts.js';
|
|
21
|
+
export const codexAccountFileStore = {
|
|
22
|
+
loadAll: loadAllCodexAccounts,
|
|
23
|
+
save: saveSharedCodexAccount,
|
|
24
|
+
remove: removeSharedCodexAccount,
|
|
25
|
+
localRemovals: localCodexAccountRemovals,
|
|
26
|
+
};
|
|
27
|
+
/** A registry record complete enough to serve from, stored under its own alias. */
|
|
28
|
+
export function isCodexRegistryRecord(alias, rec) {
|
|
29
|
+
const r = rec;
|
|
30
|
+
return !!r && typeof r === 'object' && r.alias === alias && isValidCodexAlias(alias)
|
|
31
|
+
&& typeof r.accessToken === 'string' && r.accessToken.length > 0
|
|
32
|
+
&& typeof r.refreshToken === 'string' && r.refreshToken.length > 0
|
|
33
|
+
&& typeof r.expiresAt === 'number' && Number.isFinite(r.expiresAt);
|
|
34
|
+
}
|
|
35
|
+
// Enough of the account id to tell two apart in a log, and no more.
|
|
36
|
+
function describeSeat(c) {
|
|
37
|
+
const id = extractChatGPTAccountId(c.idToken);
|
|
38
|
+
return id ? `ChatGPT account ending ${id.slice(-6)}` : 'ChatGPT seat';
|
|
39
|
+
}
|
|
40
|
+
export const CODEX_REGISTRY = {
|
|
41
|
+
path: '/codex-accounts',
|
|
42
|
+
noun: 'codex account',
|
|
43
|
+
store: codexAccountFileStore,
|
|
44
|
+
isRecord: isCodexRegistryRecord,
|
|
45
|
+
describe: describeSeat,
|
|
46
|
+
differ: (a, b) => {
|
|
47
|
+
const x = extractChatGPTAccountId(a.idToken), y = extractChatGPTAccountId(b.idToken);
|
|
48
|
+
return !!x && !!y && x !== y;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/** removeAccountEverywhere for a ChatGPT seat, for `dario codex remove`. */
|
|
52
|
+
export function removeCodexAccountEverywhere(baseUrl, token, alias, opts = {}) {
|
|
53
|
+
return removeAccountEverywhere(baseUrl, token, alias, { ...opts, registry: CODEX_REGISTRY });
|
|
54
|
+
}
|
package/dist/codex-accounts.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface CodexAccountCredentials {
|
|
|
5
5
|
refreshToken: string;
|
|
6
6
|
expiresAt: number;
|
|
7
7
|
idToken?: string;
|
|
8
|
+
/**
|
|
9
|
+
* When this login was made, epoch ms. A refresh keeps it. The shared registry
|
|
10
|
+
* orders logins by it and weighs them against removals; a seat stored before
|
|
11
|
+
* it existed has none, which reads as older than any removal.
|
|
12
|
+
*/
|
|
13
|
+
grantedAt?: number;
|
|
8
14
|
}
|
|
9
15
|
export declare function listCodexAccountAliases(): Promise<string[]>;
|
|
10
16
|
export declare function hasAnyCodexAccount(nowMs?: number): Promise<boolean>;
|
|
@@ -19,7 +25,33 @@ export declare function _resetCodexPresenceCacheForTest(): void;
|
|
|
19
25
|
export declare function loadCodexAccount(alias: string): Promise<CodexAccountCredentials | null>;
|
|
20
26
|
export declare function loadAllCodexAccounts(): Promise<CodexAccountCredentials[]>;
|
|
21
27
|
export declare function saveCodexAccount(creds: CodexAccountCredentials): Promise<void>;
|
|
28
|
+
/** The seat files this process has removed, alias to epoch ms. */
|
|
29
|
+
export declare function localCodexAccountRemovals(): ReadonlyMap<string, number>;
|
|
22
30
|
export declare function removeCodexAccount(alias: string): Promise<boolean>;
|
|
31
|
+
/**
|
|
32
|
+
* Write a record read from the shared registry, inside the alias's write chain.
|
|
33
|
+
* Skipped when this process removed the file at or after `fetchedAt`, since the
|
|
34
|
+
* registry was read before that removal, and when the file already holds
|
|
35
|
+
* credentials at least as new that outlive `removedAt`. `replaced` is the record
|
|
36
|
+
* the write overwrote.
|
|
37
|
+
*/
|
|
38
|
+
export declare function saveSharedCodexAccount(creds: CodexAccountCredentials, opts: {
|
|
39
|
+
fetchedAt: number;
|
|
40
|
+
removedAt?: number;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
saved: boolean;
|
|
43
|
+
replaced?: CodexAccountCredentials;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Remove a seat file because the shared registry holds a removal made at
|
|
47
|
+
* `removedAt`, inside the alias's write chain. A file granted after the removal
|
|
48
|
+
* is a later login and is kept. Not recorded in localCodexAccountRemovals:
|
|
49
|
+
* publishing it again under a later time would remove a login a peer made
|
|
50
|
+
* between the two.
|
|
51
|
+
*/
|
|
52
|
+
export declare function removeSharedCodexAccount(alias: string, removedAt: number): Promise<boolean>;
|
|
53
|
+
/** Whether `alias` could name a seat file: the registry never hands the disk anything else. */
|
|
54
|
+
export declare function isValidCodexAlias(alias: string): boolean;
|
|
23
55
|
export declare function getCodexAccountsDir(): string;
|
|
24
56
|
export declare function startAddCodexAccount(alias: string): Promise<{
|
|
25
57
|
authorizeUrl: string;
|
package/dist/codex-accounts.js
CHANGED
|
@@ -8,12 +8,17 @@
|
|
|
8
8
|
* getFreshCodexAccount comment below). Request routing lives in
|
|
9
9
|
* provider-adapter.ts + codex-backend.ts; this module owns credentials on
|
|
10
10
|
* disk and the selection/refresh in front of them.
|
|
11
|
+
*
|
|
12
|
+
* With --pool-shared-accounts the files also sync through a registry of their
|
|
13
|
+
* own (codex-account-sync.ts). The only thing that shares with accounts.ts is
|
|
14
|
+
* how two records order: credentialsAreNewer and grantedAfter.
|
|
11
15
|
*/
|
|
12
16
|
import { readFile, mkdir, readdir, unlink } from 'node:fs/promises';
|
|
13
17
|
import { join, basename } from 'node:path';
|
|
14
18
|
import { homedir } from 'node:os';
|
|
15
19
|
import { randomBytes } from 'node:crypto';
|
|
16
20
|
import { generateCodexPKCE, buildCodexAuthorizeUrl, exchangeCodexAuthorizationCode, refreshCodexAccessToken, CodexRefreshError, } from './codex-oauth.js';
|
|
21
|
+
import { credentialsAreNewer, grantedAfter } from './accounts.js';
|
|
17
22
|
import { durableWriteFile } from './durable-write.js';
|
|
18
23
|
import { ProviderCooldowns } from './provider-cooldown.js';
|
|
19
24
|
import { codexHeadroom, codexLongWindowResetAt } from './codex-usage.js';
|
|
@@ -100,29 +105,108 @@ export async function loadAllCodexAccounts() {
|
|
|
100
105
|
const loaded = await Promise.all(aliases.map(a => loadCodexAccount(a)));
|
|
101
106
|
return loaded.filter((a) => a !== null);
|
|
102
107
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
// One write at a time per alias: a refresh, a login, a removal and the shared
|
|
109
|
+
// registry all write the same file, and the registry's check of what the file
|
|
110
|
+
// holds has to see the last write rather than race it.
|
|
111
|
+
const codexWrites = new Map();
|
|
112
|
+
function serializeCodexWrite(alias, fn) {
|
|
113
|
+
const next = (codexWrites.get(alias) ?? Promise.resolve()).then(fn);
|
|
114
|
+
const tail = next.catch(() => { });
|
|
115
|
+
codexWrites.set(alias, tail);
|
|
116
|
+
void tail.then(() => { if (codexWrites.get(alias) === tail)
|
|
117
|
+
codexWrites.delete(alias); });
|
|
118
|
+
return next;
|
|
119
|
+
}
|
|
120
|
+
async function writeCodexFile(path, creds) {
|
|
107
121
|
await ensureDir();
|
|
108
122
|
await durableWriteFile(path, JSON.stringify(creds, null, 2), 0o600);
|
|
109
123
|
// An account stored in THIS process (`dario codex add` in a running admin
|
|
110
|
-
// path, or a test) must not be hidden by a negative
|
|
111
|
-
// moments earlier.
|
|
124
|
+
// path, the shared registry, or a test) must not be hidden by a negative
|
|
125
|
+
// cache entry taken moments earlier.
|
|
112
126
|
codexAbsentUntil = 0;
|
|
113
127
|
}
|
|
128
|
+
export async function saveCodexAccount(creds) {
|
|
129
|
+
const path = safeAliasPath(creds.alias);
|
|
130
|
+
if (!path)
|
|
131
|
+
throw new Error(`invalid codex account alias: ${creds.alias}`);
|
|
132
|
+
await serializeCodexWrite(creds.alias, () => writeCodexFile(path, creds));
|
|
133
|
+
}
|
|
134
|
+
// When this process removed each seat file, epoch ms, set inside the alias's
|
|
135
|
+
// write chain. The shared registry publishes these as removal markers, so a
|
|
136
|
+
// registry read already out when the file went cannot bring it back.
|
|
137
|
+
const localCodexRemovals = new Map();
|
|
138
|
+
/** The seat files this process has removed, alias to epoch ms. */
|
|
139
|
+
export function localCodexAccountRemovals() {
|
|
140
|
+
return new Map(localCodexRemovals);
|
|
141
|
+
}
|
|
114
142
|
export async function removeCodexAccount(alias) {
|
|
115
143
|
const path = safeAliasPath(alias);
|
|
116
144
|
if (!path)
|
|
117
145
|
return false;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
146
|
+
return serializeCodexWrite(alias, async () => {
|
|
147
|
+
try {
|
|
148
|
+
await unlink(path);
|
|
149
|
+
localCodexRemovals.set(alias, Date.now());
|
|
150
|
+
refreshFailures.delete(alias);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Write a record read from the shared registry, inside the alias's write chain.
|
|
160
|
+
* Skipped when this process removed the file at or after `fetchedAt`, since the
|
|
161
|
+
* registry was read before that removal, and when the file already holds
|
|
162
|
+
* credentials at least as new that outlive `removedAt`. `replaced` is the record
|
|
163
|
+
* the write overwrote.
|
|
164
|
+
*/
|
|
165
|
+
export async function saveSharedCodexAccount(creds, opts) {
|
|
166
|
+
const path = safeAliasPath(creds.alias);
|
|
167
|
+
if (!path)
|
|
168
|
+
return { saved: false };
|
|
169
|
+
return serializeCodexWrite(creds.alias, async () => {
|
|
170
|
+
const removedHere = localCodexRemovals.get(creds.alias);
|
|
171
|
+
if (removedHere !== undefined && removedHere >= opts.fetchedAt)
|
|
172
|
+
return { saved: false };
|
|
173
|
+
const current = await loadCodexAccount(creds.alias);
|
|
174
|
+
if (current && grantedAfter(current, opts.removedAt) && !credentialsAreNewer(creds, current))
|
|
175
|
+
return { saved: false };
|
|
176
|
+
await writeCodexFile(path, creds);
|
|
177
|
+
// New credentials: a failure remembered against the old ones says nothing about these.
|
|
178
|
+
refreshFailures.delete(creds.alias);
|
|
179
|
+
return { saved: true, replaced: current ?? undefined };
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Remove a seat file because the shared registry holds a removal made at
|
|
184
|
+
* `removedAt`, inside the alias's write chain. A file granted after the removal
|
|
185
|
+
* is a later login and is kept. Not recorded in localCodexAccountRemovals:
|
|
186
|
+
* publishing it again under a later time would remove a login a peer made
|
|
187
|
+
* between the two.
|
|
188
|
+
*/
|
|
189
|
+
export async function removeSharedCodexAccount(alias, removedAt) {
|
|
190
|
+
const path = safeAliasPath(alias);
|
|
191
|
+
if (!path)
|
|
124
192
|
return false;
|
|
125
|
-
|
|
193
|
+
return serializeCodexWrite(alias, async () => {
|
|
194
|
+
const current = await loadCodexAccount(alias);
|
|
195
|
+
if (!current || grantedAfter(current, removedAt))
|
|
196
|
+
return false;
|
|
197
|
+
try {
|
|
198
|
+
await unlink(path);
|
|
199
|
+
refreshFailures.delete(alias);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Whether `alias` could name a seat file: the registry never hands the disk anything else. */
|
|
208
|
+
export function isValidCodexAlias(alias) {
|
|
209
|
+
return safeAliasPath(alias) !== null;
|
|
126
210
|
}
|
|
127
211
|
export function getCodexAccountsDir() {
|
|
128
212
|
return CODEX_ACCOUNTS_DIR;
|
|
@@ -150,6 +234,7 @@ export async function completeAddCodexAccount(alias, code, codeVerifier) {
|
|
|
150
234
|
refreshToken: tokens.refreshToken,
|
|
151
235
|
expiresAt: tokens.expiresAt,
|
|
152
236
|
idToken: tokens.idToken,
|
|
237
|
+
grantedAt: Date.now(),
|
|
153
238
|
};
|
|
154
239
|
await saveCodexAccount(creds);
|
|
155
240
|
// Freshly minted credentials — whatever the old ones failed with is history.
|
|
@@ -174,8 +259,39 @@ export async function refreshCodexAccount(creds) {
|
|
|
174
259
|
expiresAt: tokens.expiresAt,
|
|
175
260
|
idToken: tokens.idToken,
|
|
176
261
|
};
|
|
177
|
-
|
|
178
|
-
|
|
262
|
+
return commitCodexRefresh(creds, updated);
|
|
263
|
+
}
|
|
264
|
+
/** A refresh finished for a seat whose file has gone: a removal, here or on another instance. */
|
|
265
|
+
class CodexSeatRemovedError extends Error {
|
|
266
|
+
constructor(alias) {
|
|
267
|
+
super(`codex account ${alias} was removed while it refreshed`);
|
|
268
|
+
this.name = 'CodexSeatRemovedError';
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Save what a refresh returned and answer with the credentials that survive.
|
|
273
|
+
* The token request takes seconds, and in that time the shared registry can
|
|
274
|
+
* write a newer login or remove the seat, or a peer's later refresh can land.
|
|
275
|
+
* So the result is weighed against the file inside this process's write chain
|
|
276
|
+
* for the alias: a file that changed to another login, or to a later expiry of this one, stands,
|
|
277
|
+
* and a file that has gone is not brought back. A file that did not change takes
|
|
278
|
+
* the result whatever its expiry: a forced refresh replaces a token the backend
|
|
279
|
+
* refused, which may have looked good for longer than the new one.
|
|
280
|
+
*/
|
|
281
|
+
async function commitCodexRefresh(before, updated) {
|
|
282
|
+
const path = safeAliasPath(before.alias);
|
|
283
|
+
if (!path)
|
|
284
|
+
throw new Error(`invalid codex account alias: ${before.alias}`);
|
|
285
|
+
return serializeCodexWrite(before.alias, async () => {
|
|
286
|
+
const current = await loadCodexAccount(before.alias);
|
|
287
|
+
if (!current)
|
|
288
|
+
throw new CodexSeatRemovedError(before.alias);
|
|
289
|
+
const changed = current.refreshToken !== before.refreshToken || current.accessToken !== before.accessToken;
|
|
290
|
+
if (changed && (current.grantedAt !== before.grantedAt || credentialsAreNewer(current, updated)))
|
|
291
|
+
return current;
|
|
292
|
+
await writeCodexFile(path, updated);
|
|
293
|
+
return updated;
|
|
294
|
+
});
|
|
179
295
|
}
|
|
180
296
|
/**
|
|
181
297
|
* A refresh this process is not going to attempt right now: either the last one
|
|
@@ -289,8 +405,10 @@ function noteRefreshRecovered(alias) {
|
|
|
289
405
|
* What's left is purely an efficiency concern — N concurrent requests arriving
|
|
290
406
|
* on an expiring token shouldn't fire N identical refresh calls. One in-memory
|
|
291
407
|
* promise per alias collapses them. If a second dario process refreshes the same
|
|
292
|
-
* account at the same time, both simply succeed
|
|
293
|
-
*
|
|
408
|
+
* account at the same time, both simply succeed, and the other process's token
|
|
409
|
+
* stays valid until its own expiry. Within one process commitCodexRefresh keeps
|
|
410
|
+
* the later expiry on disk. Two processes sharing a directory have no write chain
|
|
411
|
+
* in common, so there the last writer wins, which costs nothing for that reason.
|
|
294
412
|
*/
|
|
295
413
|
const inflightRefresh = new Map();
|
|
296
414
|
/**
|
|
@@ -339,6 +457,11 @@ async function refreshNow(creds) {
|
|
|
339
457
|
return fresh;
|
|
340
458
|
})
|
|
341
459
|
.catch((err) => {
|
|
460
|
+
// Nothing failed upstream, so nothing is remembered against the alias: a
|
|
461
|
+
// later login under it starts clean.
|
|
462
|
+
if (err instanceof CodexSeatRemovedError) {
|
|
463
|
+
throw new CodexCredentialsUnavailableError(creds.alias, { at: Date.now(), status: 0, message: err.message });
|
|
464
|
+
}
|
|
342
465
|
throw new CodexCredentialsUnavailableError(creds.alias, noteRefreshFailure(creds.alias, err));
|
|
343
466
|
})
|
|
344
467
|
.finally(() => {
|
package/dist/proxy.js
CHANGED
|
@@ -21,6 +21,7 @@ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCo
|
|
|
21
21
|
import { backfillIdentity } from './accounts.js';
|
|
22
22
|
import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
|
|
23
23
|
import { AccountSync, DEFAULT_ACCOUNT_SYNC_INTERVAL_MS } from './account-sync.js';
|
|
24
|
+
import { CODEX_REGISTRY } from './codex-account-sync.js';
|
|
24
25
|
import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, thinkingTokensFromUsage, CODEX_CLAIM, CREDITS_CLAIM } from './analytics.js';
|
|
25
26
|
import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
|
|
26
27
|
import { renderPrometheus } from './metrics.js';
|
|
@@ -1538,7 +1539,21 @@ export async function startProxy(opts = {}) {
|
|
|
1538
1539
|
// already-running proxy is picked up without a restart — `dario login`
|
|
1539
1540
|
// restarts the proxy by convention, `dario codex add` does not. The
|
|
1540
1541
|
// credentials themselves are re-read per request too (they rotate on
|
|
1541
|
-
// refresh).
|
|
1542
|
+
// refresh). So a sync only has to write the file, and the first one runs
|
|
1543
|
+
// here: a replica whose only seat is a peer's still boots.
|
|
1544
|
+
// The seats need no Claude login, so --no-claude-auth leaves this on.
|
|
1545
|
+
const lockUrl = process.env['DARIO_REFRESH_LOCK_URL'];
|
|
1546
|
+
const codexAccountSync = opts.poolSharedAccounts && lockUrl
|
|
1547
|
+
? new AccountSync({
|
|
1548
|
+
baseUrl: lockUrl,
|
|
1549
|
+
token: process.env['DARIO_REFRESH_LOCK_TOKEN'] ?? '',
|
|
1550
|
+
intervalMs: DEFAULT_ACCOUNT_SYNC_INTERVAL_MS,
|
|
1551
|
+
log: (line) => console.error(line),
|
|
1552
|
+
registry: CODEX_REGISTRY,
|
|
1553
|
+
})
|
|
1554
|
+
: null;
|
|
1555
|
+
if (codexAccountSync)
|
|
1556
|
+
await codexAccountSync.syncOnce();
|
|
1542
1557
|
const startupCodexAliases = await listCodexAccountAliases();
|
|
1543
1558
|
if (startupCodexAliases.length > 0) {
|
|
1544
1559
|
console.log(` Codex accounts: ${startupCodexAliases.join(', ')} → ${CODEX_BACKEND_BASE_URL}`);
|
|
@@ -1659,8 +1674,8 @@ export async function startProxy(opts = {}) {
|
|
|
1659
1674
|
// Shared account registry (account-sync.ts): opt-in, rides the refresh-lock
|
|
1660
1675
|
// service, fails open. Merged before the pool loads, so a replica that
|
|
1661
1676
|
// boots with an empty accounts directory starts with its peers' accounts.
|
|
1662
|
-
// Claude seats
|
|
1663
|
-
|
|
1677
|
+
// Claude seats, so not under --no-claude-auth; ChatGPT seats have their own
|
|
1678
|
+
// above.
|
|
1664
1679
|
const accountSync = opts.poolSharedAccounts && lockUrl && !opts.noClaudeAuth
|
|
1665
1680
|
? new AccountSync({
|
|
1666
1681
|
baseUrl: lockUrl,
|
|
@@ -1673,7 +1688,7 @@ export async function startProxy(opts = {}) {
|
|
|
1673
1688
|
console.error('[dario] --pool-shared-accounts needs DARIO_REFRESH_LOCK_URL (the lock service holds the registry); serving the accounts on this instance\'s disk only');
|
|
1674
1689
|
}
|
|
1675
1690
|
else if (opts.poolSharedAccounts && opts.noClaudeAuth) {
|
|
1676
|
-
console.error('[dario] --pool-shared-accounts
|
|
1691
|
+
console.error('[dario] --pool-shared-accounts shares ChatGPT seats only under --no-claude-auth: this instance loads no Claude seats');
|
|
1677
1692
|
}
|
|
1678
1693
|
if (accountSync)
|
|
1679
1694
|
await accountSync.syncOnce();
|
|
@@ -2506,6 +2521,10 @@ export async function startProxy(opts = {}) {
|
|
|
2506
2521
|
console.log(` Pool shared accounts: on (via ${lockUrl}, syncing every ${accountSync.intervalMs / 1000}s; fails open)`);
|
|
2507
2522
|
accountSync.start();
|
|
2508
2523
|
}
|
|
2524
|
+
if (codexAccountSync) {
|
|
2525
|
+
console.log(` Pool shared ChatGPT seats: on (via ${lockUrl}, syncing every ${codexAccountSync.intervalMs / 1000}s; fails open)`);
|
|
2526
|
+
codexAccountSync.start();
|
|
2527
|
+
}
|
|
2509
2528
|
const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
|
|
2510
2529
|
const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/chat/completions, GET /v1/models' });
|
|
2511
2530
|
const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
|
|
@@ -2997,11 +3016,14 @@ export async function startProxy(opts = {}) {
|
|
|
2997
3016
|
keys: keyStore,
|
|
2998
3017
|
// Logins started and not yet completed ride the shared registry's
|
|
2999
3018
|
// service, so a login started on one replica completes on another.
|
|
3000
|
-
sharedLogins: accountSync,
|
|
3019
|
+
sharedLogins: accountSync ?? codexAccountSync,
|
|
3001
3020
|
onCodexAccountsChanged: async () => {
|
|
3002
3021
|
// A ChatGPT seat came or went over HTTP (dario#1009): forget the
|
|
3003
3022
|
// "no codex account" answer so the next request routes to it.
|
|
3004
3023
|
resetCodexPresenceCache();
|
|
3024
|
+
// Publish the change now rather than at the next interval.
|
|
3025
|
+
if (codexAccountSync)
|
|
3026
|
+
void codexAccountSync.syncOnce();
|
|
3005
3027
|
if (verbose)
|
|
3006
3028
|
console.log('[dario] admin: codex accounts changed — re-read on the next request');
|
|
3007
3029
|
},
|
|
@@ -3248,6 +3270,7 @@ export async function startProxy(opts = {}) {
|
|
|
3248
3270
|
sharedState: poolSync ? poolSync.status() : null,
|
|
3249
3271
|
// Shared account registry (account-sync.ts), null when off.
|
|
3250
3272
|
sharedAccounts: accountSync ? accountSync.status() : null,
|
|
3273
|
+
sharedCodexAccounts: codexAccountSync ? codexAccountSync.status() : null,
|
|
3251
3274
|
accounts,
|
|
3252
3275
|
}));
|
|
3253
3276
|
return;
|
|
@@ -6913,6 +6936,7 @@ export async function startProxy(opts = {}) {
|
|
|
6913
6936
|
clearInterval(refreshInterval);
|
|
6914
6937
|
poolSync?.stop();
|
|
6915
6938
|
accountSync?.stop();
|
|
6939
|
+
codexAccountSync?.stop();
|
|
6916
6940
|
// Stop accepting connections now; responses already streaming keep going.
|
|
6917
6941
|
// Tokens and what the ledger already holds are written while the drain
|
|
6918
6942
|
// waits. The ledger, the key store and the request log close only after
|
package/docs/admin-api.md
CHANGED
|
@@ -144,11 +144,11 @@ login holds it, so two instances starting at once get different aliases (if
|
|
|
144
144
|
all 8 candidates it tries are taken it answers `409`). If the service does not answer when a login starts, it is
|
|
145
145
|
kept on that instance only and logged (never the verifier or state); a
|
|
146
146
|
`/complete` that lands elsewhere gets the usual `410`, so retry it through
|
|
147
|
-
the instance that started it or start again. A
|
|
148
|
-
|
|
149
|
-
the seat
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
the instance that started it or start again. A login completes once: a retry
|
|
148
|
+
after it gets `410` too, wherever it lands. The instance that completes a ChatGPT
|
|
149
|
+
login publishes the seat, and the others have it within a sync. An instance that
|
|
150
|
+
already holds a seat under the alias refuses with `409`, without consuming the
|
|
151
|
+
login, so `DELETE /admin/codex/accounts/<alias>` first to replace one. With the switch off, pending logins stay in the process that started
|
|
152
152
|
them, so send `/start` and `/complete` to the same instance.
|
|
153
153
|
|
|
154
154
|
## Pinning a request to one seat
|
package/docs/multi-instance.md
CHANGED
|
@@ -119,7 +119,7 @@ It fails open like the lock: an unreachable service is one log line per outage a
|
|
|
119
119
|
|
|
120
120
|
## Sharing the account list
|
|
121
121
|
|
|
122
|
-
Without it, each instance serves the accounts in its own `~/.dario/accounts/`. With `--pool-shared-accounts` the instances keep one account list in the same lock service: a replica that starts with an empty accounts directory (a second pod on a fresh volume) starts with its peers' accounts, and an account added or removed on one instance is added or removed on all of them.
|
|
122
|
+
Without it, each instance serves the accounts in its own `~/.dario/accounts/`. With `--pool-shared-accounts` the instances keep one account list in the same lock service: a replica that starts with an empty accounts directory (a second pod on a fresh volume) starts with its peers' accounts, and an account added or removed on one instance is added or removed on all of them. ChatGPT seats in `~/.dario/codex-accounts/` are shared the same way, in a registry of their own.
|
|
123
123
|
|
|
124
124
|
```
|
|
125
125
|
DARIO_REFRESH_LOCK_URL=http://<lock-host>:8080
|
|
@@ -143,9 +143,9 @@ Before turning it on:
|
|
|
143
143
|
- **The registry holds every seat's credentials, refresh tokens included, and they do not expire there.** That is what lets a new replica start with no files. Guard the lock service like the accounts directory itself: a real random `DARIO_REFRESH_LOCK_TOKEN`, the service on a private network, and a Redis that nothing else uses. Anyone who can read that Redis or the Durable Object can use every subscription in the pool.
|
|
144
144
|
- **Aliases are one namespace across all instances.** Two instances that each log a different subscription in under the same alias (`login`, most often) keep only the newer login, and the instance whose account was replaced logs it loudly: `account "login" here was ... and is now ...; aliases are shared across instances, so give each subscription its own alias`. Give each subscription its own alias.
|
|
145
145
|
- **Clocks have to agree.** The newer login and removal rules compare wall-clock times taken on different machines. Run NTP; Kubernetes nodes do by default.
|
|
146
|
-
- **
|
|
146
|
+
- **ChatGPT seats share their credentials, not their state.** A seat's login reaches every instance and a removal leaves them all, under the same rules as a Claude account, and `dario codex remove` publishes first in the same way. Each instance still reads a seat's usage and keeps its cool-downs on its own, so one instance can pick a seat another has just found rate limited. The seats need no refresh lock: two refreshes of one token both succeed, so each instance refreshes on its own and the later expiry wins. Aliases are a namespace per engine, so `rama` can be a Claude account and a ChatGPT seat. Within an engine the rule above holds: replicas with different subscriptions under one alias keep only the newer login, and until now each replica's default was `altman-1`, so check before turning this on. Under `--no-claude-auth` only the ChatGPT seats sync. A seat stored before this version has no grant time, so it orders by expiry until its next login, and a removal covers it whenever it was made. An instance that already holds a seat under an alias answers `409` to a login for it, and leaves the login unconsumed.
|
|
147
147
|
- **It fails open.** With the service unreachable, each instance serves the accounts on its own disk and logs once per outage. A removal made while it is down is published when it answers again; if the instance restarts before then, that removal is lost and the account comes back from the registry, so remove it again.
|
|
148
|
-
- **Upgrade the lock service.** The `/accounts/*` endpoints are new: rebuild the Redis image or redeploy the Worker. An older service answers 404, which an instance logs as the service being unreachable and then serves its own disk, so nothing breaks, but nothing is shared either. The `/logins/*` endpoints for pending admin API logins arrived after `/accounts/*`; on a service without them an instance keeps its pending logins to itself and logs it.
|
|
148
|
+
- **Upgrade the lock service.** The `/accounts/*` endpoints are new: rebuild the Redis image or redeploy the Worker. An older service answers 404, which an instance logs as the service being unreachable and then serves its own disk, so nothing breaks, but nothing is shared either. The `/logins/*` endpoints for pending admin API logins arrived after `/accounts/*`; on a service without them an instance keeps its pending logins to itself and logs it. The `/codex-accounts/*` endpoints came later still; on a service without them the ChatGPT seats stay per instance, and the instance logs the service as unreachable for them while its Claude accounts sync as usual.
|
|
149
149
|
|
|
150
150
|
## Proving it works on your own setup
|
|
151
151
|
|
|
@@ -174,7 +174,7 @@ Anthropic's token endpoint is the one mocked part, and only that — hammering t
|
|
|
174
174
|
|
|
175
175
|
Everything above is as far as dario goes today: shared credentials through the lock, shared rate-limit readings and sticky bindings through `--pool-shared-state`, and a shared account list through `--pool-shared-accounts`, all eventually consistent and all failing open. Without `--pool-shared-accounts` the account list is per instance too, and copying the account files to every instance is safe with the refresh lock on: the copies share one refresh token, and whichever instance refreshes first hands the result to the others ([#1733](https://github.com/askalf/dario/issues/1733)). Without the lock, copies race exactly as described in section 1. Three things are still per instance:
|
|
176
176
|
|
|
177
|
-
- **ChatGPT
|
|
177
|
+
- **ChatGPT seat state.** `--pool-shared-accounts` shares their credentials, but each instance keeps its own usage readings and cool-downs.
|
|
178
178
|
- **The ledger.** Each instance writes its own file, so `dario usage` and `/analytics` show that instance's share of the spend.
|
|
179
179
|
- **The overage guard's halt**, which each instance tracks on its own.
|
|
180
180
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.22.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, opt-in failover when one hits its limit, and a ledger of what each plan absorbed.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|