@askalf/dario 6.21.0 → 6.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js 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 { listCodexAccountAliases, loadAllCodexAccounts, startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, parseCodexManualPaste } from './codex-accounts.js';
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 in a registry on the
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
- /** --pool-shared-accounts or DARIO_POOL_SHARED_ACCOUNTS: read by the proxy and by `dario accounts remove`. */
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
- let result;
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 in a registry on the
2374
- refresh-lock service, merged at startup and
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
+ }
@@ -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;
@@ -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
- export async function saveCodexAccount(creds) {
104
- const path = safeAliasPath(creds.alias);
105
- if (!path)
106
- throw new Error(`invalid codex account alias: ${creds.alias}`);
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 cache entry taken
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
- try {
119
- await unlink(path);
120
- refreshFailures.delete(alias);
121
- return true;
122
- }
123
- catch {
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
- await saveCodexAccount(updated);
178
- return updated;
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; last writer wins on disk and
293
- * the other process's token stays valid until its own expiry.
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(() => {
@@ -518,7 +518,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
518
518
  */
519
519
  export declare const SUPPORTED_CC_RANGE: {
520
520
  readonly min: "1.0.0";
521
- readonly maxTested: "2.1.318";
521
+ readonly maxTested: "2.1.319";
522
522
  };
523
523
  /**
524
524
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -1238,7 +1238,7 @@ export function detectDrift(t, installedOverride) {
1238
1238
  */
1239
1239
  export const SUPPORTED_CC_RANGE = {
1240
1240
  min: '1.0.0',
1241
- maxTested: '2.1.318',
1241
+ maxTested: '2.1.319',
1242
1242
  };
1243
1243
  /**
1244
1244
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
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 only, so not under --no-claude-auth.
1663
- const lockUrl = process.env['DARIO_REFRESH_LOCK_URL'];
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 is off under --no-claude-auth: the registry holds Claude seats, and this instance loads none');
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 ChatGPT seat is not part of the
148
- shared registry, so the instance that completes the login is the one that holds
149
- the seat. That instance refuses with `409` if it already holds a seat under
150
- the alias, without consuming the login, so complete it on an instance that
151
- does not (or `DELETE /admin/codex/accounts/<alias>` there first). With the switch off, pending logins stay in the process that started
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
@@ -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
- - **Claude seats only.** ChatGPT accounts stay per instance, and the switch is off under `--no-claude-auth`. A ChatGPT login started over the admin API can still complete on another instance, but the seat is then held by the instance that completed it. An instance that already holds a seat under that alias answers `409` and leaves the login unconsumed, so it can complete on another instance.
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 seats.** The Codex accounts have their own refresh flow and usage windows; neither the lock nor the shared state covers them.
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.21.0",
3
+ "version": "6.22.1",
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": {