@askalf/dario 6.20.3 → 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 +92 -18
- package/dist/account-sync.js +87 -17
- package/dist/admin-api.d.ts +8 -0
- package/dist/admin-api.js +179 -24
- 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 +31 -4
- package/docs/admin-api.md +21 -1
- package/docs/multi-instance.md +6 -4
- package/package.json +1 -1
|
@@ -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' });
|
|
@@ -2995,10 +3014,16 @@ export async function startProxy(opts = {}) {
|
|
|
2995
3014
|
// through the store the live proxy authenticates from, so a key made
|
|
2996
3015
|
// here works on the next request.
|
|
2997
3016
|
keys: keyStore,
|
|
3017
|
+
// Logins started and not yet completed ride the shared registry's
|
|
3018
|
+
// service, so a login started on one replica completes on another.
|
|
3019
|
+
sharedLogins: accountSync ?? codexAccountSync,
|
|
2998
3020
|
onCodexAccountsChanged: async () => {
|
|
2999
3021
|
// A ChatGPT seat came or went over HTTP (dario#1009): forget the
|
|
3000
3022
|
// "no codex account" answer so the next request routes to it.
|
|
3001
3023
|
resetCodexPresenceCache();
|
|
3024
|
+
// Publish the change now rather than at the next interval.
|
|
3025
|
+
if (codexAccountSync)
|
|
3026
|
+
void codexAccountSync.syncOnce();
|
|
3002
3027
|
if (verbose)
|
|
3003
3028
|
console.log('[dario] admin: codex accounts changed — re-read on the next request');
|
|
3004
3029
|
},
|
|
@@ -3245,6 +3270,7 @@ export async function startProxy(opts = {}) {
|
|
|
3245
3270
|
sharedState: poolSync ? poolSync.status() : null,
|
|
3246
3271
|
// Shared account registry (account-sync.ts), null when off.
|
|
3247
3272
|
sharedAccounts: accountSync ? accountSync.status() : null,
|
|
3273
|
+
sharedCodexAccounts: codexAccountSync ? codexAccountSync.status() : null,
|
|
3248
3274
|
accounts,
|
|
3249
3275
|
}));
|
|
3250
3276
|
return;
|
|
@@ -6910,6 +6936,7 @@ export async function startProxy(opts = {}) {
|
|
|
6910
6936
|
clearInterval(refreshInterval);
|
|
6911
6937
|
poolSync?.stop();
|
|
6912
6938
|
accountSync?.stop();
|
|
6939
|
+
codexAccountSync?.stop();
|
|
6913
6940
|
// Stop accepting connections now; responses already streaming keep going.
|
|
6914
6941
|
// Tokens and what the ledger already holds are written while the drain
|
|
6915
6942
|
// waits. The ledger, the key store and the request log close only after
|
package/docs/admin-api.md
CHANGED
|
@@ -78,7 +78,8 @@ Pending-login mechanics, for scripting against it:
|
|
|
78
78
|
different login attempt.
|
|
79
79
|
- An expired or unknown alias gets `410` — start a new login.
|
|
80
80
|
- The PKCE verifier and state never touch disk and are never returned to the
|
|
81
|
-
client.
|
|
81
|
+
client. With `--pool-shared-accounts` on they are also held by the shared
|
|
82
|
+
service until the login completes, is replaced or lapses; see below.
|
|
82
83
|
|
|
83
84
|
## Endpoint reference
|
|
84
85
|
|
|
@@ -131,6 +132,25 @@ one instance this way provisions them all, and a new replica starts with the
|
|
|
131
132
|
accounts already in the registry. `GET /accounts` then carries a
|
|
132
133
|
`sharedAccounts` block with the sync's state.
|
|
133
134
|
|
|
135
|
+
**Behind a load balancer, `/start` and `/complete` can land on different
|
|
136
|
+
instances.** With the same switch on, a pending login is published to the shared
|
|
137
|
+
service too, for both Claude and ChatGPT logins, and `/complete` reads it from
|
|
138
|
+
there. The mechanics above hold fleet-wide: 10 minutes, single-use (of two
|
|
139
|
+
instances completing at once, one gets `200` and the other `410`; a
|
|
140
|
+
`/start` that replaces the login in between leaves the new one for its own
|
|
141
|
+
paste), one per alias, at most 64 per engine. A generated default alias skips
|
|
142
|
+
one pending on another instance, and the service creates it only if no live
|
|
143
|
+
login holds it, so two instances starting at once get different aliases (if
|
|
144
|
+
all 8 candidates it tries are taken it answers `409`). If the service does not answer when a login starts, it is
|
|
145
|
+
kept on that instance only and logged (never the verifier or state); a
|
|
146
|
+
`/complete` that lands elsewhere gets the usual `410`, so retry it through
|
|
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
|
+
them, so send `/start` and `/complete` to the same instance.
|
|
153
|
+
|
|
134
154
|
## Pinning a request to one seat
|
|
135
155
|
|
|
136
156
|
`x-dario-account: <alias>` on `POST /v1/messages` (or `/v1/chat/completions`) routes that one request to the named pool account with **no failover**: no headroom selection, no sticky rebinding, no peer retry on 401/429, no Codex leg. The upstream status comes back as-is. It is the primitive behind `dario accounts check <alias>` — the answer to "does this seat serve Sonnet right now?" without copying a credential anywhere or restarting anything.
|
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
|
|
@@ -131,6 +131,7 @@ Set it on every instance. It works with or without `--pool-shared-state`.
|
|
|
131
131
|
|
|
132
132
|
- **At startup**, before the pool loads, an instance reads the registry and merges it with its accounts directory. Per alias the newer record wins: a newer login first, then, for the same login, the later expiry. Accounts only this instance holds are published; accounts only the registry holds are written to this instance's accounts directory. The pool then loads from that directory as usual, so `/health` is ready as soon as a peer has an account.
|
|
133
133
|
- **Every 15 seconds** after that it merges again, and updates the running pool too: a new account joins it, newer credentials replace older ones, and a removed account leaves it. A `dario accounts add`, an admin API login or a token refresh on one instance reaches the others within one sync. An admin API login or removal is published straight away, and the other instances apply it on their next sync.
|
|
134
|
+
- **Admin API logins complete on any instance.** A login started with `POST /admin/login/start` (or `/admin/codex/login/start`) is also published to the service for its 10 minutes, so `/complete` can land on a different replica behind the load balancer. It is single-use across the fleet, and is removed from the service when it completes or is replaced. A lapsed login is never served; the Worker deletes it at its lapse by an alarm, and Redis deletes it on the next start after the lapse or when the hash expires at the newest lapse of any login in it, at most an hour past its own. If the service does not answer at `/start`, that login stays on the instance that started it, with a log line that names the alias only. During an outage a login found only on the service cannot complete; this instance completes one only if it still holds its own copy, and only one overlapping call gets it. An instance keeps at most 64 pending logins: copies of logins the service holds give way to new starts, logins kept here alone do not. Without the switch, send both calls to the same instance.
|
|
134
135
|
- **A removal leaves a marker** in the registry. The marker beats every login made before the removal, so an instance that was offline and still holds the old file deletes it instead of publishing it back. That holds for the `login` account taken from `credentials.json` at startup too: an instance whose login was removed starts without it instead of serving it. A login made after the removal beats the marker, and the account comes back everywhere.
|
|
135
136
|
- **`dario accounts remove <alias>`** publishes the removal first and deletes the file only once the service has taken it. If the service cannot be reached it removes nothing and says so, since the other instances would put the account straight back. A login made after the removal stays, in the registry or in the file here, and the command says so; run it again to remove that login too. If the file here can't be deleted, the command says so and exits non-zero, and this instance keeps serving the account until the file goes.
|
|
136
137
|
|
|
@@ -138,12 +139,13 @@ The proxy says it is on at startup (`Pool shared accounts: on (via …, syncing
|
|
|
138
139
|
|
|
139
140
|
Before turning it on:
|
|
140
141
|
|
|
142
|
+
- **The service also holds the PKCE verifier and state of a login in progress,** for its 10 minutes, and in Redis possibly up to an hour past that (see above). They are secrets, stored as the account records are, and a caller holding `DARIO_REFRESH_LOCK_TOKEN` can complete that login.
|
|
141
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.
|
|
142
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.
|
|
143
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.
|
|
144
|
-
- **
|
|
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.
|
|
145
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.
|
|
146
|
-
- **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.
|
|
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.
|
|
147
149
|
|
|
148
150
|
## Proving it works on your own setup
|
|
149
151
|
|
|
@@ -172,7 +174,7 @@ Anthropic's token endpoint is the one mocked part, and only that — hammering t
|
|
|
172
174
|
|
|
173
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:
|
|
174
176
|
|
|
175
|
-
- **ChatGPT
|
|
177
|
+
- **ChatGPT seat state.** `--pool-shared-accounts` shares their credentials, but each instance keeps its own usage readings and cool-downs.
|
|
176
178
|
- **The ledger.** Each instance writes its own file, so `dario usage` and `/analytics` show that instance's share of the spend.
|
|
177
179
|
- **The overage guard's halt**, which each instance tracks on its own.
|
|
178
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": {
|