@crvouga/dev-fleet 0.2.0 → 0.3.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.
Files changed (72) hide show
  1. package/README.md +28 -0
  2. package/ansible/macos/manager.yml +3 -0
  3. package/ansible/macos/t3-policy.yml +49 -0
  4. package/ansible/ubuntu/policy.json +1 -0
  5. package/ansible/ubuntu/t3-desktop.yml +19 -0
  6. package/ansible/ubuntu/t3-health.sh +93 -22
  7. package/ansible/ubuntu/t3-idle.py +42 -0
  8. package/ansible/ubuntu/t3-policy.yml +4 -11
  9. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  10. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  11. package/ansible/ubuntu/wip-guard.py +121 -0
  12. package/bootstrap/native-t3.ts +22 -11
  13. package/cliproxy/README.md +3 -3
  14. package/cliproxy/scripts/accounts.py +216 -0
  15. package/cliproxy/scripts/checkpoint.py +29 -1
  16. package/cliproxy/scripts/cli.py +1 -1
  17. package/cliproxy/scripts/clientkeys.py +99 -0
  18. package/cliproxy/scripts/lease.py +80 -0
  19. package/cliproxy/scripts/ops +137 -25
  20. package/cliproxy/scripts/restore-verify +15 -0
  21. package/docs/accounts.md +154 -0
  22. package/docs/declared.md +3 -1
  23. package/docs/durability.md +26 -0
  24. package/docs/machine-setup.md +4 -3
  25. package/docs/topology.md +2 -0
  26. package/docs/ubuntu-sync.md +28 -22
  27. package/llms.txt +1 -0
  28. package/mise.toml +3 -1
  29. package/package.json +2 -2
  30. package/schema/fleet.schema.json +66 -0
  31. package/src/accounts/checks.ts +220 -0
  32. package/src/accounts/context.ts +76 -0
  33. package/src/accounts/propagate.ts +62 -0
  34. package/src/accounts/roster.ts +85 -0
  35. package/src/accounts/selection.ts +67 -0
  36. package/src/accounts/sessions.ts +120 -0
  37. package/src/agents/account-relay.ts +176 -26
  38. package/src/agents/launch.ts +84 -5
  39. package/src/agents/native-verify.ts +128 -2
  40. package/src/cli/fleet-accounts.ts +253 -0
  41. package/src/cli/fleet-sync-accounts.ts +58 -0
  42. package/src/cli/fleet-sync-manager.ts +20 -4
  43. package/src/cli/fleet-sync-output.ts +3 -2
  44. package/src/cli/fleet-sync-remote.ts +20 -13
  45. package/src/cli/fleet-sync.ts +36 -0
  46. package/src/cli/register-fleet.ts +35 -0
  47. package/src/cli/registry.ts +13 -0
  48. package/src/definition.ts +85 -3
  49. package/src/ide/definition.ts +86 -0
  50. package/src/ide/ide.ts +43 -0
  51. package/src/ide/index.ts +5 -0
  52. package/src/ide/orchestrate.ts +50 -0
  53. package/src/ide/t3.ts +111 -0
  54. package/src/manager-sync.ts +1 -15
  55. package/src/node-converge-linux.ts +28 -7
  56. package/src/platform/darwin.ts +0 -13
  57. package/src/platform/fallback.ts +0 -8
  58. package/src/platform/linux.ts +0 -10
  59. package/src/platform/t3-policy.ts +33 -6
  60. package/src/platform/t3-service.ts +104 -0
  61. package/src/platform/types.ts +0 -4
  62. package/src/platform/windows.ts +0 -14
  63. package/src/remote-entry.ts +15 -1
  64. package/src/service-auth.ts +15 -5
  65. package/src/t3/accounts.ts +7 -1
  66. package/src/t3/models.ts +133 -0
  67. package/src/t3/pairing.ts +31 -0
  68. package/src/t3/profile.ts +203 -0
  69. package/src/t3/version.ts +119 -0
  70. package/src/t3-linked-check.ts +1 -1
  71. package/src/transport.ts +3 -2
  72. package/src/worker-host-checks.ts +36 -13
@@ -126,6 +126,41 @@
126
126
  "type": "string",
127
127
  "pattern": "^https://[A-Za-z0-9.-]+$",
128
128
  "description": "Public HTTPS origin of the CLI proxy every node uses."
129
+ },
130
+ "accounts": {
131
+ "type": "object",
132
+ "additionalProperties": false,
133
+ "description": "How provider accounts signed in on the proxy are watched and chosen. See docs/accounts.md.",
134
+ "properties": {
135
+ "minimumUsable": {
136
+ "type": "integer",
137
+ "minimum": 1,
138
+ "description": "proxy:account:<provider> warns when a provider has fewer usable accounts than this (default 2) and fails when it has none."
139
+ },
140
+ "selection": {
141
+ "type": "object",
142
+ "description": "Per provider (claude, codex, antigravity): which account an unpinned request goes to first. Accounts cooling down are always skipped.",
143
+ "additionalProperties": {
144
+ "type": "object",
145
+ "additionalProperties": false,
146
+ "required": ["strategy"],
147
+ "properties": {
148
+ "strategy": {
149
+ "enum": [
150
+ "fill-first",
151
+ "round-robin",
152
+ "least-recently-limited"
153
+ ]
154
+ },
155
+ "order": {
156
+ "type": "array",
157
+ "items": { "type": "string" },
158
+ "description": "Account labels (sign-in emails) first, in this order; the others follow in the order of their routing prefix."
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
129
164
  }
130
165
  }
131
166
  },
@@ -244,6 +279,37 @@
244
279
  }
245
280
  }
246
281
  },
282
+ "ides": {
283
+ "type": "array",
284
+ "minItems": 1,
285
+ "uniqueItems": true,
286
+ "items": { "enum": ["t3"] },
287
+ "description": "The IDEs every machine runs. Defaults to [\"t3\"]; the README's support matrix lists the supported ones."
288
+ },
289
+ "t3": {
290
+ "type": "object",
291
+ "additionalProperties": false,
292
+ "description": "What every machine's T3 Code holds beyond the provider instances generated from the proxy's accounts.",
293
+ "properties": {
294
+ "profile": {
295
+ "type": "object",
296
+ "description": "Top-level T3 settings every machine holds the same (tool servers, projects, selected preferences). Keys not named here stay local; a key edited on a machine since dev-fleet last wrote it is reported as a conflict, never overwritten.",
297
+ "propertyNames": {
298
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$",
299
+ "not": { "const": "providerInstances" }
300
+ }
301
+ },
302
+ "requiredModels": {
303
+ "type": "object",
304
+ "description": "Models T3 must offer, and the proxy must serve, for every account of a tool, by tool (claude, codex, opencode).",
305
+ "propertyNames": { "pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$" },
306
+ "additionalProperties": {
307
+ "type": "array",
308
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9._:-]{1,128}$" }
309
+ }
310
+ }
311
+ }
312
+ },
247
313
  "state": {
248
314
  "description": "The bucket that holds the fleet state (the roster, the approved revision and the configuration last applied). Any S3-compatible service, or a directory. Never credentials: a devfleet command reads those from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY.",
249
315
  "oneOf": [
@@ -0,0 +1,220 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { Check } from '../types';
5
+ import type { ProxyRoster, RosterAccount } from './roster';
6
+
7
+ /** Fewer usable accounts than this warns unless the fleet says otherwise. */
8
+ export const MINIMUM_USABLE = 2;
9
+
10
+ /** `proxy:account:<provider>`: fails when a provider has no usable account,
11
+ * warns below the fleet's minimum. Accounts are named by their opaque index
12
+ * and counts only, never by an identifier.
13
+ */
14
+ export function providerAccountChecks(
15
+ roster: ProxyRoster,
16
+ minimumUsable = MINIMUM_USABLE
17
+ ): Check[] {
18
+ const providers = new Map<string, RosterAccount[]>();
19
+ for (const account of roster.accounts)
20
+ if (account.state !== 'removed')
21
+ providers.set(account.provider, [
22
+ ...(providers.get(account.provider) ?? []),
23
+ account,
24
+ ]);
25
+ return [...providers.entries()]
26
+ .sort(([a], [b]) => a.localeCompare(b))
27
+ .map(([provider, accounts]) =>
28
+ providerCheck(provider, accounts, minimumUsable)
29
+ );
30
+ }
31
+
32
+ /** "account 2 needs-login, account 3 cooling-down until …" */
33
+ const describe = (accounts: RosterAccount[]): string[] =>
34
+ accounts
35
+ .filter((account) => account.state !== 'usable')
36
+ .map(
37
+ (account) =>
38
+ `account ${account.index} ${account.state}${account.until ? ` until ${account.until}` : ''}`
39
+ );
40
+
41
+ function reloginRemedy(provider: string, accounts: RosterAccount[]) {
42
+ const login = accounts.find((account) => account.state === 'needs-login');
43
+ return login
44
+ ? {
45
+ action: `Sign ${provider} account ${login.index} in again. Signing in is the only manual step.`,
46
+ command: `devfleet fleet-accounts --relogin ${provider} ${login.index}`,
47
+ }
48
+ : undefined;
49
+ }
50
+
51
+ function providerCheck(
52
+ provider: string,
53
+ accounts: RosterAccount[],
54
+ minimumUsable: number
55
+ ): Check {
56
+ const usable = accounts.filter((account) => account.state === 'usable');
57
+ const id = `proxy:account:${provider}`;
58
+ const detail = describe(accounts);
59
+ const counted = `${usable.length} of ${accounts.length} ${provider} accounts usable${detail.length ? ` (${detail.join(', ')})` : ''}`;
60
+ const remedy = reloginRemedy(provider, accounts);
61
+ if (usable.length === 0) return noneUsable(provider, accounts);
62
+ if (usable.length < minimumUsable)
63
+ return {
64
+ id,
65
+ status: 'warn',
66
+ summary: `${counted}; one limit or expired login away from none.`,
67
+ remedy: remedy ?? {
68
+ action: `Add a ${provider} account: devfleet fleet-accounts --add ${provider}.`,
69
+ },
70
+ };
71
+ return {
72
+ id,
73
+ status: 'pass',
74
+ summary: `${counted}.`,
75
+ ...(remedy ? { remedy } : {}),
76
+ };
77
+ }
78
+
79
+ /** No usable account: say why, and when the first cooldown clears. */
80
+ function noneUsable(provider: string, accounts: RosterAccount[]): Check {
81
+ const clears = accounts
82
+ .map((account) => account.until)
83
+ .filter((until): until is string => until !== null)
84
+ .sort()[0];
85
+ const cooling = accounts.every((account) => account.state === 'cooling-down');
86
+ const wait = `Wait until ${clears ?? 'the provider lifts the limit'}, or add an account: devfleet fleet-accounts --add ${provider}.`;
87
+ return {
88
+ id: `proxy:account:${provider}`,
89
+ status: 'fail',
90
+ summary: cooling
91
+ ? `No usable ${provider} account: every one is cooling down${clears ? `; the first clears at ${clears}` : ''}.`
92
+ : `No usable ${provider} account (${describe(accounts).join(', ')}).`,
93
+ remedy: reloginRemedy(provider, accounts) ?? {
94
+ action: cooling
95
+ ? wait
96
+ : `Inspect the ${provider} accounts with devfleet fleet-accounts.`,
97
+ },
98
+ };
99
+ }
100
+
101
+ /** `accounts:roster-current`: this node offers exactly the accounts the
102
+ * proxy holds. `proxy` is the proxy's roster version (null: unreadable),
103
+ * `node` the version of the account instances the node exposes.
104
+ */
105
+ export function rosterCurrentCheck(proxy: string | null, node: string): Check {
106
+ if (proxy === null)
107
+ return {
108
+ id: 'accounts:roster-current',
109
+ status: 'warn',
110
+ summary: `The proxy's account roster could not be read; this node offers roster version ${node}.`,
111
+ remedy: {
112
+ action: 'Check proxy:reachable-from, then run fleet-status again.',
113
+ },
114
+ };
115
+ return proxy === node
116
+ ? {
117
+ id: 'accounts:roster-current',
118
+ status: 'pass',
119
+ summary: `This node offers the proxy's account roster (version ${node}).`,
120
+ }
121
+ : {
122
+ id: 'accounts:roster-current',
123
+ status: 'fail',
124
+ summary: `This node offers account roster version ${node}; the proxy holds ${proxy}.`,
125
+ remedy: {
126
+ action: 'Propagate the accounts to every node without a full sync.',
127
+ command: 'devfleet fleet-sync --only accounts',
128
+ },
129
+ };
130
+ }
131
+
132
+ /** Where agent CLIs keep a provider login of their own, relative to home. */
133
+ export const NATIVE_LOGINS: Record<string, string[]> = {
134
+ claude: ['.claude/.credentials.json'],
135
+ codex: ['.codex/auth.json'],
136
+ gemini: ['.gemini/oauth_creds.json'],
137
+ opencode: ['.local/share/opencode/auth.json'],
138
+ grok: ['.grok/auth.json'],
139
+ };
140
+
141
+ /** The provider logins found on this node outside the proxy path. */
142
+ export function nativeLogins(
143
+ home = homedir(),
144
+ exists: (path: string) => boolean = existsSync
145
+ ): { cli: string; path: string }[] {
146
+ return Object.entries(NATIVE_LOGINS).flatMap(([cli, paths]) =>
147
+ paths
148
+ .map((path) => join(home, path))
149
+ .filter((path) => exists(path))
150
+ .map((path) => ({ cli, path }))
151
+ );
152
+ }
153
+
154
+ /** `accounts:native-login`: a node holds no provider login of its own, so it
155
+ * can use no account the proxy does not know. Names the CLIs only.
156
+ */
157
+ export function nativeLoginCheck(found: { cli: string }[]): Check {
158
+ const clis = [...new Set(found.map((login) => login.cli))].sort();
159
+ return clis.length === 0
160
+ ? {
161
+ id: 'accounts:native-login',
162
+ status: 'pass',
163
+ summary:
164
+ 'No agent CLI on this node holds a provider login of its own; every account comes from the proxy.',
165
+ }
166
+ : {
167
+ id: 'accounts:native-login',
168
+ status: 'warn',
169
+ summary: `Provider logins outside the proxy path: ${clis.join(', ')}.`,
170
+ remedy: {
171
+ action:
172
+ 'Provider accounts belong on the proxy. Remove these logins (opt-in), and sign the account in on the proxy if it is needed.',
173
+ command: 'devfleet fleet-accounts --remove-native-logins --yes',
174
+ },
175
+ };
176
+ }
177
+
178
+ /** `proxy:reachable-from`: this node reaches the proxy, and how fast. */
179
+ export async function proxyReachableCheck(
180
+ endpoint: string,
181
+ send: typeof fetch = fetch,
182
+ clock: () => number = () => performance.now()
183
+ ): Promise<Check> {
184
+ const started = clock();
185
+ try {
186
+ // Unauthenticated: the proxy refuses it, which proves it answered.
187
+ const response = await send(endpoint + '/v1/models', {
188
+ redirect: 'error',
189
+ signal: AbortSignal.timeout(10_000),
190
+ });
191
+ await response.body?.cancel().catch(() => undefined);
192
+ const latency = Math.round(clock() - started);
193
+ const answered = response.status < 500;
194
+ return {
195
+ id: 'proxy:reachable-from',
196
+ status: !answered ? 'fail' : latency > 2000 ? 'warn' : 'pass',
197
+ summary: answered
198
+ ? `The proxy answered in ${latency} ms.`
199
+ : 'PROXY_UNREACHABLE: the route to the proxy answered, the proxy did not.',
200
+ ...(answered
201
+ ? {}
202
+ : {
203
+ remedy: {
204
+ action:
205
+ 'Agents retry for about a minute and then report "proxy unreachable". Check the proxy on the manager: devfleet cliproxy status.',
206
+ },
207
+ }),
208
+ };
209
+ } catch {
210
+ return {
211
+ id: 'proxy:reachable-from',
212
+ status: 'fail',
213
+ summary: 'PROXY_UNREACHABLE: no answer from the proxy.',
214
+ remedy: {
215
+ action:
216
+ 'Agents retry for about a minute and then report "proxy unreachable". Check this node’s tailnet and the proxy on the manager: devfleet cliproxy status.',
217
+ },
218
+ };
219
+ }
220
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { accountPrefix, discoverAccounts } from '../agents/accounts';
5
+ import { proxyKey } from '../agents/secrets';
6
+ import { registry } from '../agents/registry';
7
+ import type { AccountContext } from '../agents/native-verify';
8
+ import type { Check } from '../types';
9
+ import {
10
+ nativeLoginCheck,
11
+ nativeLogins,
12
+ proxyReachableCheck,
13
+ rosterCurrentCheck,
14
+ } from './checks';
15
+ import { liveVersion, rosterVersion } from './roster';
16
+ import { exposedInstances, type Instance } from './sessions';
17
+
18
+ /** This machine's T3 settings, where its account instances are written. */
19
+ export const t3Settings = (): string =>
20
+ join(
21
+ resolve(process.env['T3CODE_HOME'] ?? join(homedir(), '.t3')),
22
+ 'userdata/settings.json'
23
+ );
24
+
25
+ /** A short digest of the client key: its version, never its value. */
26
+ export const keyVersion = (key: string): string =>
27
+ createHash('sha256').update(key).digest('hex').slice(0, 12);
28
+
29
+ /** The roster version of the instances a node offers. */
30
+ export function exposedVersion(instances: Instance[]): string {
31
+ return rosterVersion(
32
+ instances.flatMap((instance) => {
33
+ try {
34
+ return [accountPrefix(instance.tool, instance.account)];
35
+ } catch {
36
+ return [];
37
+ }
38
+ })
39
+ );
40
+ }
41
+
42
+ /** The proxy's accounts and what this node offers, read with this node's
43
+ * own key; undefined when the proxy or the key is unavailable (the provider
44
+ * checks report that).
45
+ */
46
+ export async function accountContext(
47
+ settings = t3Settings()
48
+ ): Promise<AccountContext | undefined> {
49
+ try {
50
+ const key = await proxyKey();
51
+ const live = await discoverAccounts(key);
52
+ return {
53
+ live,
54
+ exposed: await exposedInstances(settings),
55
+ rosterVersion: liveVersion(live),
56
+ keyVersion: keyVersion(key),
57
+ };
58
+ } catch {
59
+ return undefined;
60
+ }
61
+ }
62
+
63
+ /** What a node measures about accounts: it reaches the proxy, it offers
64
+ * the proxy's roster, and it holds no provider login of its own.
65
+ */
66
+ export async function nodeAccountChecks(
67
+ context: AccountContext | undefined,
68
+ settings = t3Settings()
69
+ ): Promise<Check[]> {
70
+ const exposed = context?.exposed ?? (await exposedInstances(settings));
71
+ return [
72
+ await proxyReachableCheck(registry.proxy.endpoint),
73
+ rosterCurrentCheck(context?.rosterVersion ?? null, exposedVersion(exposed)),
74
+ nativeLoginCheck(nativeLogins()),
75
+ ];
76
+ }
@@ -0,0 +1,62 @@
1
+ import { blocking, type Check, type FleetNode, type Runner } from '../types';
2
+ import { inspectRemote } from '../transport';
3
+ import type { CommandOutcome } from '../cli/contracts';
4
+
5
+ export interface Propagation {
6
+ /** The enrolled workers. */
7
+ nodes: FleetNode[];
8
+ /** The approved revision every worker runs. */
9
+ revision: string;
10
+ /** The bundled remote entry sent to each worker. */
11
+ source: string;
12
+ /** The manager's own account instances, applied locally. */
13
+ manager: () => Promise<Check[]>;
14
+ run: Runner;
15
+ }
16
+
17
+ /** `fleet-sync --only accounts`: the account-derived configuration and
18
+ * nothing else, on the manager and on every worker at once. Each node
19
+ * re-reads the proxy's roster and rewrites only its per-account T3
20
+ * instances; T3 watches its settings, so nothing restarts and no running
21
+ * turn ends. Instance ids derive from the account's routing prefix, so they
22
+ * stay the same and existing threads keep working. A node that cannot be
23
+ * reached is reported; it catches up on the next propagation or sync, and
24
+ * reports the roster version it has (`accounts:roster-current`) until then.
25
+ */
26
+ export async function propagateAccounts(
27
+ input: Propagation
28
+ ): Promise<CommandOutcome> {
29
+ const started = Date.now();
30
+ const [manager, ...workers] = await Promise.all([
31
+ input.manager().then((checks) => ({ node: 'manager', checks })),
32
+ ...input.nodes.map((node) =>
33
+ inspectRemote(
34
+ node,
35
+ {
36
+ source: input.source,
37
+ timeoutMs: 180_000,
38
+ action: 'accounts',
39
+ expected: { head: input.revision, files: {} },
40
+ },
41
+ input.run
42
+ )
43
+ ),
44
+ ]);
45
+ const nodes = [manager!, ...workers].map((report) => ({
46
+ node: report.node,
47
+ checks: report.checks,
48
+ }));
49
+ const ok = nodes.every((node) => !node.checks.some(blocking));
50
+ return {
51
+ exitCode: ok ? 0 : 1,
52
+ data: {
53
+ version: 1,
54
+ command: 'fleet-sync',
55
+ only: 'accounts',
56
+ ok,
57
+ revision: input.revision,
58
+ durationMs: Date.now() - started,
59
+ nodes,
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type { Account } from '../agents/accounts';
3
+
4
+ /** Provider accounts exist only as the CLI proxy's credential files. Every
5
+ * node and IDE gets a view derived from them; nothing on a node holds a
6
+ * provider login. See docs/accounts.md.
7
+ */
8
+ export type AccountState =
9
+ 'usable' | 'cooling-down' | 'needs-login' | 'refresh-failing' | 'removed';
10
+
11
+ const STATES: AccountState[] = [
12
+ 'usable',
13
+ 'cooling-down',
14
+ 'needs-login',
15
+ 'refresh-failing',
16
+ 'removed',
17
+ ];
18
+
19
+ /** One account as the manager reports it. `index` is opaque: the position of
20
+ * the account among its provider's, the only way checks name it.
21
+ */
22
+ export interface RosterAccount {
23
+ provider: string;
24
+ index: number;
25
+ label: string;
26
+ /** The routing prefix every node offers it under; null if not routed. */
27
+ prefix: string | null;
28
+ state: AccountState;
29
+ /** While cooling down: when it clears (ISO 8601), when known. */
30
+ until: string | null;
31
+ }
32
+
33
+ /** What `devfleet cliproxy accounts --json` prints on the manager. */
34
+ export interface ProxyRoster {
35
+ version: 1;
36
+ rosterVersion: string;
37
+ /** Whether the proxy's management API answered: without it, cooldowns and
38
+ * refresh failures are not known.
39
+ */
40
+ managementApi: boolean;
41
+ accounts: RosterAccount[];
42
+ }
43
+
44
+ export function parseRoster(text: string): ProxyRoster {
45
+ const value = JSON.parse(text) as Partial<ProxyRoster>;
46
+ if (
47
+ value.version !== 1 ||
48
+ typeof value.rosterVersion !== 'string' ||
49
+ !Array.isArray(value.accounts) ||
50
+ !value.accounts.every(
51
+ (account) =>
52
+ typeof account.provider === 'string' &&
53
+ Number.isInteger(account.index) &&
54
+ typeof account.label === 'string' &&
55
+ STATES.includes(account.state)
56
+ )
57
+ )
58
+ throw new Error('The proxy reported an unreadable account roster.');
59
+ return {
60
+ version: 1,
61
+ rosterVersion: value.rosterVersion,
62
+ managementApi: value.managementApi === true,
63
+ accounts: value.accounts.map((account) => ({
64
+ ...account,
65
+ prefix: account.prefix ?? null,
66
+ until: account.until ?? null,
67
+ })),
68
+ };
69
+ }
70
+
71
+ /** The roster version: a digest of the routing prefixes of the accounts
72
+ * every node offers. The manager computes it from the credential files
73
+ * (cliproxy/scripts/accounts.py) and every node from the proxy's model list;
74
+ * the two agree exactly when the node offers what the proxy holds.
75
+ */
76
+ export function rosterVersion(prefixes: Iterable<string>): string {
77
+ return createHash('sha256')
78
+ .update([...new Set(prefixes)].sort().join('\n'))
79
+ .digest('hex')
80
+ .slice(0, 16);
81
+ }
82
+
83
+ /** The version of the accounts a node reads from the proxy's model list. */
84
+ export const liveVersion = (accounts: Account[]): string =>
85
+ rosterVersion(accounts.map((account) => account.prefix));
@@ -0,0 +1,67 @@
1
+ import type { AccountSelection } from '../definition';
2
+
3
+ /** Which account an unpinned request goes to first, per the policy the fleet
4
+ * declares for the provider. The order is deterministic: the accounts the
5
+ * policy names, in its order, then the others in the order of their routing
6
+ * prefix. Accounts cooling down are skipped while any other is left.
7
+ */
8
+ export class AccountSelector {
9
+ private readonly cooling = new Map<string, number>();
10
+ private readonly limitedAt = new Map<string, number>();
11
+ private turn = 0;
12
+ readonly order: string[];
13
+
14
+ constructor(
15
+ prefixes: string[],
16
+ private readonly policy: AccountSelection,
17
+ private readonly now: () => number = Date.now
18
+ ) {
19
+ const label = (prefix: string) => prefix.slice(prefix.indexOf('-') + 1);
20
+ const named = policy.order ?? [];
21
+ const rank = (prefix: string) => {
22
+ const position = named.indexOf(label(prefix));
23
+ return position < 0 ? named.length : position;
24
+ };
25
+ this.order = [...new Set(prefixes)].sort(
26
+ (a, b) => rank(a) - rank(b) || a.localeCompare(b)
27
+ );
28
+ }
29
+
30
+ /** The accounts to try for the next request, in order. */
31
+ candidates(): string[] {
32
+ const now = this.now();
33
+ let order = [...this.order];
34
+ if (this.policy.strategy === 'round-robin' && order.length) {
35
+ const start = this.turn++ % order.length;
36
+ order = [...order.slice(start), ...order.slice(0, start)];
37
+ }
38
+ if (this.policy.strategy === 'least-recently-limited')
39
+ order.sort(
40
+ (a, b) => (this.limitedAt.get(a) ?? 0) - (this.limitedAt.get(b) ?? 0)
41
+ );
42
+ return order.filter((prefix) => (this.cooling.get(prefix) ?? 0) <= now);
43
+ }
44
+
45
+ /** The provider limited this account; `seconds` from its Retry-After. */
46
+ limited(prefix: string, seconds = 60): void {
47
+ const now = this.now();
48
+ this.limitedAt.set(prefix, now);
49
+ this.cooling.set(prefix, now + seconds * 1000);
50
+ }
51
+
52
+ /** When this account's cooldown clears, if it is cooling down. */
53
+ coolingUntil(prefix: string): number | null {
54
+ const until = this.cooling.get(prefix) ?? 0;
55
+ return until > this.now() ? until : null;
56
+ }
57
+ }
58
+
59
+ /** A quota or authentication answer: the next account may still serve. */
60
+ export const accountRefused = (status: number): boolean =>
61
+ status === 429 || status === 401 || status === 403;
62
+
63
+ /** Seconds a refused account cools down for: its Retry-After, or a minute. */
64
+ export function retryAfter(headers: Headers): number {
65
+ const value = Number(headers.get('retry-after'));
66
+ return Number.isFinite(value) && value > 0 ? Math.min(value, 3600) : 60;
67
+ }