@elizaos/app-core 2.0.0-alpha.413 → 2.0.0-alpha.414

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 (25) hide show
  1. package/package.json +5 -5
  2. package/packages/agent/src/api/model-provider-helpers.js +10 -10
  3. package/packages/agent/src/api/provider-switch-config.js +2 -2
  4. package/packages/agent/src/auth/index.d.ts +2 -0
  5. package/packages/agent/src/auth/index.d.ts.map +1 -1
  6. package/packages/agent/src/auth/index.js +2 -0
  7. package/packages/agent/src/auth/oauth-flow.d.ts +106 -0
  8. package/packages/agent/src/auth/oauth-flow.d.ts.map +1 -0
  9. package/packages/agent/src/auth/oauth-flow.js +349 -0
  10. package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.d.ts +32 -0
  11. package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.d.ts.map +1 -1
  12. package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.js +63 -28
  13. package/packages/agent/src/config/schema.js +1 -1
  14. package/packages/agent/src/config/types.messages.d.ts +1 -1
  15. package/packages/agent/src/config/types.tools.d.ts +1 -1
  16. package/packages/agent/src/runtime/eliza.js +3 -3
  17. package/packages/app-core/src/components/conversations/conversation-utils.js +4 -4
  18. package/packages/app-core/src/components/settings/ProviderSwitcher.js +1 -1
  19. package/packages/app-core/src/services/account-pool.d.ts +91 -0
  20. package/packages/app-core/src/services/account-pool.d.ts.map +1 -0
  21. package/packages/app-core/src/services/account-pool.js +376 -0
  22. package/packages/app-core/src/services/account-usage.d.ts +73 -0
  23. package/packages/app-core/src/services/account-usage.d.ts.map +1 -0
  24. package/packages/app-core/src/services/account-usage.js +179 -0
  25. package/packages/app-core/src/state/useOnboardingState.js +1 -1
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Anthropic OAuth (Claude Pro/Max) — authorization code + PKCE.
3
3
  * Inlined OAuth flow (MIT) — vendored to avoid a runtime dependency.
4
+ *
5
+ * Anthropic's OAuth uses a fixed redirect URI on `console.anthropic.com`
6
+ * that displays the auth code on completion — there's no loopback
7
+ * listener. The flow surfaces an `authUrl` plus a `submitCode()` hook
8
+ * the UI / CLI calls once the user has copied the code.
4
9
  */
5
10
  import { generatePKCE } from "./pkce.js";
6
11
  const decode = (s) => atob(s);
@@ -10,10 +15,11 @@ const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
10
15
  const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
11
16
  const SCOPES = "org:create_api_key user:profile user:inference";
12
17
  /**
13
- * @param onAuthUrl - Receives the browser authorization URL
14
- * @param onPromptCode - Resolves with pasted code (format: code#state)
18
+ * Start an Anthropic OAuth flow. Returns immediately with the auth
19
+ * URL and a `submitCode` hook. The token exchange happens inside
20
+ * `completion` once the caller submits the code.
15
21
  */
16
- export async function loginAnthropic(onAuthUrl, onPromptCode) {
22
+ export async function startAnthropicOAuthFlowRaw() {
17
23
  const { verifier, challenge } = await generatePKCE();
18
24
  const authParams = new URLSearchParams({
19
25
  code: "true",
@@ -26,35 +32,64 @@ export async function loginAnthropic(onAuthUrl, onPromptCode) {
26
32
  state: verifier,
27
33
  });
28
34
  const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
29
- onAuthUrl(authUrl);
30
- const authCode = await onPromptCode();
31
- const splits = authCode.split("#");
32
- const code = splits[0];
33
- const state = splits[1];
34
- const tokenResponse = await fetch(TOKEN_URL, {
35
- method: "POST",
36
- headers: { "Content-Type": "application/json" },
37
- body: JSON.stringify({
38
- grant_type: "authorization_code",
39
- client_id: CLIENT_ID,
40
- code,
41
- state,
42
- redirect_uri: REDIRECT_URI,
43
- code_verifier: verifier,
44
- }),
35
+ let resolveCode = null;
36
+ let rejectCode = null;
37
+ const codePromise = new Promise((resolve, reject) => {
38
+ resolveCode = resolve;
39
+ rejectCode = reject;
45
40
  });
46
- if (!tokenResponse.ok) {
47
- const errText = await tokenResponse.text();
48
- throw new Error(`Token exchange failed: ${errText}`);
49
- }
50
- const tokenData = (await tokenResponse.json());
51
- const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
41
+ const completion = (async () => {
42
+ const authCode = await codePromise;
43
+ const splits = authCode.split("#");
44
+ const code = splits[0];
45
+ const state = splits[1];
46
+ const tokenResponse = await fetch(TOKEN_URL, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ grant_type: "authorization_code",
51
+ client_id: CLIENT_ID,
52
+ code,
53
+ state,
54
+ redirect_uri: REDIRECT_URI,
55
+ code_verifier: verifier,
56
+ }),
57
+ });
58
+ if (!tokenResponse.ok) {
59
+ const errText = await tokenResponse.text();
60
+ throw new Error(`Token exchange failed: ${errText}`);
61
+ }
62
+ const tokenData = (await tokenResponse.json());
63
+ const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
64
+ return {
65
+ refresh: tokenData.refresh_token,
66
+ access: tokenData.access_token,
67
+ expires: expiresAt,
68
+ };
69
+ })();
52
70
  return {
53
- refresh: tokenData.refresh_token,
54
- access: tokenData.access_token,
55
- expires: expiresAt,
71
+ authUrl,
72
+ submitCode: (code) => resolveCode?.(code),
73
+ completion,
74
+ cancel: (reason = "Cancelled") => rejectCode?.(new Error(reason)),
56
75
  };
57
76
  }
77
+ /**
78
+ * @param onAuthUrl - Receives the browser authorization URL
79
+ * @param onPromptCode - Resolves with pasted code (format: code#state)
80
+ *
81
+ * Thin compatibility wrapper around `startAnthropicOAuthFlowRaw` for
82
+ * the CLI entrypoint and any older callers. New code should use
83
+ * `startAnthropicOAuthFlowRaw` (or the higher-level
84
+ * `startAnthropicOAuthFlow` in `auth/oauth-flow.ts`) directly.
85
+ */
86
+ export async function loginAnthropic(onAuthUrl, onPromptCode) {
87
+ const handle = await startAnthropicOAuthFlowRaw();
88
+ onAuthUrl(handle.authUrl);
89
+ const code = await onPromptCode();
90
+ handle.submitCode(code);
91
+ return handle.completion;
92
+ }
58
93
  export async function refreshAnthropicToken(refreshToken) {
59
94
  const response = await fetch(TOKEN_URL, {
60
95
  method: "POST",
@@ -413,7 +413,7 @@ const FIELD_HELP = {
413
413
  "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).",
414
414
  "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).",
415
415
  "tools.exec.applyPatch.enabled": "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.",
416
- "tools.exec.applyPatch.allowModels": 'Optional allowlist of model ids (e.g. "gpt-5.4" or "openai/gpt-5.4").',
416
+ "tools.exec.applyPatch.allowModels": 'Optional allowlist of model ids (e.g. "gpt-5.5" or "openai/gpt-5.5").',
417
417
  "tools.exec.notifyOnExit": "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
418
418
  "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
419
419
  "tools.exec.safeBins": "Allow stdin-only safe binaries to run without explicit allowlist entries.",
@@ -123,7 +123,7 @@ export type MessagesConfig = {
123
123
  * - special value: `"auto"` derives `[{agents.list[].identity.name}]` for the routed agent (when set)
124
124
  *
125
125
  * Supported template variables (case-insensitive):
126
- * - `{model}` - short model name (e.g., `claude-opus-4-7`, `gpt-5.4`)
126
+ * - `{model}` - short model name (e.g., `claude-opus-4-7`, `gpt-5.5`)
127
127
  * - `{modelFull}` - full model identifier (e.g., `anthropic/claude-opus-4.7`)
128
128
  * - `{provider}` - provider name (e.g., `anthropic`, `openai`)
129
129
  * - `{thinkingLevel}` or `{think}` - current thinking level (`high`, `low`, `off`)
@@ -141,7 +141,7 @@ export type ExecToolConfig = {
141
141
  enabled?: boolean;
142
142
  /**
143
143
  * Optional allowlist of model ids that can use apply_patch.
144
- * Accepts either raw ids (e.g. "gpt-5.4") or full ids (e.g. "openai/gpt-5.4").
144
+ * Accepts either raw ids (e.g. "gpt-5.5") or full ids (e.g. "openai/gpt-5.5").
145
145
  */
146
146
  allowModels?: string[];
147
147
  };
@@ -464,8 +464,8 @@ function isLikelyOpenAiTextModel(value) {
464
464
  * Normalize known-bad provider compatibility shims before plugin resolution.
465
465
  *
466
466
  * A common failure mode is routing the OpenAI plugin through Groq's
467
- * OpenAI-compatible base URL while leaving OpenAI defaults (`gpt-5.4`,
468
- * `gpt-5.4-mini`) in place. Structured XML/object generation then fails during
467
+ * OpenAI-compatible base URL while leaving OpenAI defaults (`gpt-5.5`,
468
+ * `gpt-5.5-mini`) in place. Structured XML/object generation then fails during
469
469
  * message handling because Groq does not serve those model IDs.
470
470
  *
471
471
  * When we can confidently detect that state, rewrite the effective runtime
@@ -1036,7 +1036,7 @@ export function applyCloudConfigToEnv(config) {
1036
1036
  const llmText = resolveServiceRoutingInConfig(config)?.llmText;
1037
1037
  const models = config.models;
1038
1038
  if (effectivelyEnabled) {
1039
- const nano = llmText?.nanoModel || models?.nano || "openai/gpt-5.4-nano";
1039
+ const nano = llmText?.nanoModel || models?.nano || "openai/gpt-5.5-nano";
1040
1040
  const small = llmText?.smallModel || models?.small || "minimax/minimax-m2.7";
1041
1041
  const medium = llmText?.mediumModel || models?.medium || small;
1042
1042
  const large = llmText?.largeModel || models?.large || "anthropic/claude-opus-4-7";
@@ -104,10 +104,10 @@ export function isNonChatModelLabel(model) {
104
104
  export function estimateTokenCost(promptTokens, completionTokens, model) {
105
105
  const normalizedModel = (model ?? "").toLowerCase();
106
106
  const pricingByMillion = {
107
- "gpt-5.4-pro": [30.0, 180.0],
108
- "gpt-5.4-mini": [0.75, 4.5],
109
- "gpt-5.4-nano": [0.2, 1.25],
110
- "gpt-5.4": [2.5, 15.0],
107
+ "gpt-5.5-pro": [30.0, 180.0],
108
+ "gpt-5.5-mini": [0.75, 4.5],
109
+ "gpt-5.5-nano": [0.2, 1.25],
110
+ "gpt-5.5": [2.5, 15.0],
111
111
  "gpt-4.1": [2.0, 8.0],
112
112
  "gpt-4o": [2.5, 10.0],
113
113
  "gpt-4": [30.0, 60.0],
@@ -168,7 +168,7 @@ export function ProviderSwitcher(props = {}) {
168
168
  const providerId = getOnboardingProviderOption(llmText?.backend)?.id;
169
169
  const elizaCloudEnabledCfg = llmText?.transport === "cloud-proxy" && providerId === "elizacloud";
170
170
  const defaults = {
171
- nano: "openai/gpt-5.4-nano",
171
+ nano: "openai/gpt-5.5-nano",
172
172
  small: "minimax/minimax-m2.7",
173
173
  medium: "anthropic/claude-sonnet-4.6",
174
174
  large: "moonshotai/kimi-k2.5",
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Multi-account selection brain.
3
+ *
4
+ * Owns the runtime decision "which `LinkedAccountConfig` should serve this
5
+ * request?" given a strategy (priority / round-robin / least-used /
6
+ * quota-aware), session affinity, and per-account health state.
7
+ *
8
+ * The pool never reads OAuth credentials directly — callers resolve them
9
+ * via `getAccessToken(providerId, accountId)` from `@elizaos/agent` once
10
+ * the pool returns an account. Health, priority, and usage live in this
11
+ * layer; the OAuth blob lives under `~/.eliza/auth/` (see WS1's
12
+ * `account-storage.ts`).
13
+ *
14
+ * Persistence: the pool layers rich metadata (priority, enabled, health,
15
+ * usage) on top of WS1's credential records. The metadata is written to
16
+ * `<ELIZA_HOME>/auth/_pool-metadata.json` atomically so it survives
17
+ * process restarts and is independent of WS3's eventual `milady.json`
18
+ * field — when WS3 lands its CRUD API on top of `LinkedAccountsConfig`
19
+ * we can swap `createDefaultAccountPool()`'s deps without touching the
20
+ * pool itself.
21
+ */
22
+ import type { LinkedAccountConfig, LinkedAccountProviderId, LinkedAccountsConfig } from "@elizaos/shared";
23
+ export type Strategy = "priority" | "round-robin" | "least-used" | "quota-aware";
24
+ export type PoolProviderId = LinkedAccountProviderId | "anthropic-api" | "openai-api";
25
+ export interface AccountPoolDeps {
26
+ /** Read the current `LinkedAccountsConfig` (live). */
27
+ readAccounts: () => Record<string, LinkedAccountConfig>;
28
+ /** Persist a single account's mutated fields. */
29
+ writeAccount: (account: LinkedAccountConfig) => Promise<void>;
30
+ }
31
+ export interface SelectInput {
32
+ providerId: PoolProviderId;
33
+ /** Stable session key for affinity (e.g. agent id + run id). */
34
+ sessionKey?: string;
35
+ /** Defaults to `"priority"`. */
36
+ strategy?: Strategy;
37
+ /** Explicit pool; defaults to all enabled accounts for `providerId`. */
38
+ accountIds?: string[];
39
+ /** Account IDs to skip (e.g. just-failed accounts). */
40
+ exclude?: string[];
41
+ }
42
+ export declare class AccountPool {
43
+ private readonly deps;
44
+ private readonly affinity;
45
+ private readonly roundRobinCursor;
46
+ constructor(deps: AccountPoolDeps);
47
+ select(input: SelectInput): Promise<LinkedAccountConfig | null>;
48
+ private filterEligible;
49
+ private applyStrategy;
50
+ recordCall(accountId: string, result: {
51
+ tokens?: number;
52
+ latencyMs?: number;
53
+ ok: boolean;
54
+ errorCode?: string;
55
+ model?: string;
56
+ }): Promise<void>;
57
+ refreshUsage(accountId: string, accessToken: string, opts?: {
58
+ codexAccountId?: string;
59
+ }): Promise<void>;
60
+ markRateLimited(accountId: string, untilMs: number, detail?: string): Promise<void>;
61
+ markNeedsReauth(accountId: string, detail?: string): Promise<void>;
62
+ markInvalid(accountId: string, detail?: string): Promise<void>;
63
+ markHealthy(accountId: string): Promise<void>;
64
+ /**
65
+ * Re-probe accounts whose `health` is non-OK and whose `healthDetail.until`
66
+ * has passed (or is absent). Used by background sweepers to recover
67
+ * temporarily flagged accounts. We don't load access tokens here — the
68
+ * caller probes via `refreshUsage` separately.
69
+ */
70
+ reprobeFlagged(): Promise<string[]>;
71
+ }
72
+ /**
73
+ * Module-level singleton for the default pool wired against WS1's
74
+ * `account-storage` and the pool-owned metadata file. Plugins / runtime
75
+ * resolvers should import `getDefaultAccountPool()` rather than building
76
+ * a new pool. WS3 may later swap the default deps to read/write the
77
+ * `LinkedAccountsConfig` field directly out of `milady.json`; consumers
78
+ * keep the same accessor.
79
+ */
80
+ export declare function getDefaultAccountPool(): AccountPool;
81
+ /**
82
+ * @deprecated kept for compatibility with the WS2 spec naming. Use
83
+ * {@link getDefaultAccountPool}.
84
+ */
85
+ export declare function createDefaultAccountPool(): AccountPool;
86
+ /**
87
+ * Resets the cached singleton. Test-only.
88
+ */
89
+ export declare function __resetDefaultAccountPoolForTests(): void;
90
+ export type { LinkedAccountsConfig };
91
+ //# sourceMappingURL=account-pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-pool.d.ts","sourceRoot":"","sources":["../../../../../src/services/account-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAgBH,OAAO,KAAK,EACV,mBAAmB,EAGnB,uBAAuB,EAEvB,oBAAoB,EACrB,MAAM,iBAAiB,CAAC;AAOzB,MAAM,MAAM,QAAQ,GAChB,UAAU,GACV,aAAa,GACb,YAAY,GACZ,aAAa,CAAC;AAElB,MAAM,MAAM,cAAc,GACtB,uBAAuB,GACvB,eAAe,GACf,YAAY,CAAC;AAEjB,MAAM,WAAW,eAAe;IAC9B,sDAAsD;IACtD,YAAY,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IACxD,iDAAiD;IACjD,YAAY,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,cAAc,CAAC;IAC3B,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gCAAgC;IAChC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,wEAAwE;IACxE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAWD,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAkB;IACvC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAoC;IAC7D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqC;gBAE1D,IAAI,EAAE,eAAe;IAM3B,MAAM,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC;IA+BrE,OAAO,CAAC,cAAc;IA4BtB,OAAO,CAAC,aAAa;IAiCf,UAAU,CACd,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE;QACN,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,EAAE,EAAE,OAAO,CAAC;QACZ,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,GACA,OAAO,CAAC,IAAI,CAAC;IAWV,YAAY,CAChB,SAAS,EAAE,MAAM,EACjB,WAAW,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE;QAAE,cAAc,CAAC,EAAE,MAAM,CAAA;KAAE,GACjC,OAAO,CAAC,IAAI,CAAC;IA2BV,eAAe,CACnB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC;IAkBV,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAalE,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa9D,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWnD;;;;;OAKG;IACG,cAAc,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;CAc1C;AAiKD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,WAAW,CAQnD;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,WAAW,CAEtD;AAED;;GAEG;AACH,wBAAgB,iCAAiC,IAAI,IAAI,CAExD;AAED,YAAY,EAAE,oBAAoB,EAAE,CAAC"}
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Multi-account selection brain.
3
+ *
4
+ * Owns the runtime decision "which `LinkedAccountConfig` should serve this
5
+ * request?" given a strategy (priority / round-robin / least-used /
6
+ * quota-aware), session affinity, and per-account health state.
7
+ *
8
+ * The pool never reads OAuth credentials directly — callers resolve them
9
+ * via `getAccessToken(providerId, accountId)` from `@elizaos/agent` once
10
+ * the pool returns an account. Health, priority, and usage live in this
11
+ * layer; the OAuth blob lives under `~/.eliza/auth/` (see WS1's
12
+ * `account-storage.ts`).
13
+ *
14
+ * Persistence: the pool layers rich metadata (priority, enabled, health,
15
+ * usage) on top of WS1's credential records. The metadata is written to
16
+ * `<ELIZA_HOME>/auth/_pool-metadata.json` atomically so it survives
17
+ * process restarts and is independent of WS3's eventual `milady.json`
18
+ * field — when WS3 lands its CRUD API on top of `LinkedAccountsConfig`
19
+ * we can swap `createDefaultAccountPool()`'s deps without touching the
20
+ * pool itself.
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { listProviderAccounts, } from "@elizaos/agent";
26
+ import { pollAnthropicUsage, pollCodexUsage, recordCall as recordUsageEntry, } from "./account-usage.js";
27
+ const DEFAULT_RATE_LIMIT_BACKOFF_MS = 60_000;
28
+ const QUOTA_AWARE_SKIP_PCT = 85;
29
+ const SESSION_AFFINITY_MAX_ATTEMPTS = 3;
30
+ export class AccountPool {
31
+ deps;
32
+ affinity = new Map();
33
+ roundRobinCursor = new Map();
34
+ constructor(deps) {
35
+ this.deps = deps;
36
+ }
37
+ // Selection.
38
+ async select(input) {
39
+ const all = this.deps.readAccounts();
40
+ const eligible = this.filterEligible(all, input);
41
+ if (eligible.length === 0)
42
+ return null;
43
+ if (input.sessionKey) {
44
+ const cached = this.affinity.get(input.sessionKey);
45
+ if (cached &&
46
+ cached.attempts < SESSION_AFFINITY_MAX_ATTEMPTS &&
47
+ eligible.some((a) => a.id === cached.accountId)) {
48
+ cached.attempts += 1;
49
+ const account = eligible.find((a) => a.id === cached.accountId);
50
+ if (account)
51
+ return account;
52
+ }
53
+ }
54
+ const strategy = input.strategy ?? "priority";
55
+ const picked = this.applyStrategy(strategy, eligible, input.providerId);
56
+ if (!picked)
57
+ return null;
58
+ if (input.sessionKey) {
59
+ this.affinity.set(input.sessionKey, {
60
+ accountId: picked.id,
61
+ attempts: 1,
62
+ });
63
+ }
64
+ return picked;
65
+ }
66
+ filterEligible(all, input) {
67
+ const exclude = new Set(input.exclude ?? []);
68
+ const explicit = input.accountIds && input.accountIds.length > 0
69
+ ? new Set(input.accountIds)
70
+ : null;
71
+ const now = Date.now();
72
+ return Object.values(all).filter((account) => {
73
+ if (account.providerId !== input.providerId)
74
+ return false;
75
+ if (!account.enabled)
76
+ return false;
77
+ if (exclude.has(account.id))
78
+ return false;
79
+ if (explicit && !explicit.has(account.id))
80
+ return false;
81
+ if (account.health === "ok")
82
+ return true;
83
+ // Allow rate-limited accounts back in once their reset has passed.
84
+ if (account.health === "rate-limited" &&
85
+ typeof account.healthDetail?.until === "number" &&
86
+ account.healthDetail.until < now) {
87
+ return true;
88
+ }
89
+ return false;
90
+ });
91
+ }
92
+ applyStrategy(strategy, eligible, providerId) {
93
+ if (eligible.length === 0)
94
+ return null;
95
+ if (eligible.length === 1)
96
+ return eligible[0] ?? null;
97
+ switch (strategy) {
98
+ case "round-robin": {
99
+ const sorted = [...eligible].sort(byPriorityThenAge);
100
+ const cursor = (this.roundRobinCursor.get(providerId) ?? -1) + 1;
101
+ const index = cursor % sorted.length;
102
+ this.roundRobinCursor.set(providerId, index);
103
+ return sorted[index] ?? null;
104
+ }
105
+ case "least-used": {
106
+ return [...eligible].sort(byLeastUsedThenPriority)[0] ?? null;
107
+ }
108
+ case "quota-aware": {
109
+ const underQuota = eligible.filter((a) => (a.usage?.sessionPct ?? 0) < QUOTA_AWARE_SKIP_PCT);
110
+ const pool = underQuota.length > 0 ? underQuota : eligible;
111
+ return [...pool].sort(byPriorityThenAge)[0] ?? null;
112
+ }
113
+ default:
114
+ return [...eligible].sort(byPriorityThenAge)[0] ?? null;
115
+ }
116
+ }
117
+ // Mutations.
118
+ async recordCall(accountId, result) {
119
+ const account = this.deps.readAccounts()[accountId];
120
+ if (!account)
121
+ return;
122
+ recordUsageEntry(account.providerId, account.id, result);
123
+ const next = {
124
+ ...account,
125
+ lastUsedAt: Date.now(),
126
+ };
127
+ await this.deps.writeAccount(next);
128
+ }
129
+ async refreshUsage(accountId, accessToken, opts) {
130
+ const account = this.deps.readAccounts()[accountId];
131
+ if (!account)
132
+ return;
133
+ let usage;
134
+ if (account.providerId === "anthropic-subscription") {
135
+ usage = await pollAnthropicUsage(accessToken);
136
+ }
137
+ else if (account.providerId === "openai-codex") {
138
+ const codexAccountId = opts?.codexAccountId ?? account.organizationId;
139
+ if (!codexAccountId) {
140
+ throw new Error(`[AccountPool] Codex usage probe needs the OpenAI account_id (account ${accountId} has no organizationId).`);
141
+ }
142
+ usage = await pollCodexUsage(accessToken, codexAccountId);
143
+ }
144
+ else {
145
+ // No probe defined for direct API providers.
146
+ return;
147
+ }
148
+ await this.deps.writeAccount({
149
+ ...account,
150
+ health: "ok",
151
+ usage,
152
+ });
153
+ }
154
+ async markRateLimited(accountId, untilMs, detail) {
155
+ const account = this.deps.readAccounts()[accountId];
156
+ if (!account)
157
+ return;
158
+ const healthDetail = {
159
+ until: Number.isFinite(untilMs) && untilMs > Date.now()
160
+ ? untilMs
161
+ : Date.now() + DEFAULT_RATE_LIMIT_BACKOFF_MS,
162
+ lastChecked: Date.now(),
163
+ ...(detail ? { lastError: detail } : {}),
164
+ };
165
+ await this.deps.writeAccount({
166
+ ...account,
167
+ health: "rate-limited",
168
+ healthDetail,
169
+ });
170
+ }
171
+ async markNeedsReauth(accountId, detail) {
172
+ const account = this.deps.readAccounts()[accountId];
173
+ if (!account)
174
+ return;
175
+ await this.deps.writeAccount({
176
+ ...account,
177
+ health: "needs-reauth",
178
+ healthDetail: {
179
+ lastChecked: Date.now(),
180
+ ...(detail ? { lastError: detail } : {}),
181
+ },
182
+ });
183
+ }
184
+ async markInvalid(accountId, detail) {
185
+ const account = this.deps.readAccounts()[accountId];
186
+ if (!account)
187
+ return;
188
+ await this.deps.writeAccount({
189
+ ...account,
190
+ health: "invalid",
191
+ healthDetail: {
192
+ lastChecked: Date.now(),
193
+ ...(detail ? { lastError: detail } : {}),
194
+ },
195
+ });
196
+ }
197
+ async markHealthy(accountId) {
198
+ const account = this.deps.readAccounts()[accountId];
199
+ if (!account)
200
+ return;
201
+ if (account.health === "ok")
202
+ return;
203
+ await this.deps.writeAccount({
204
+ ...account,
205
+ health: "ok",
206
+ ...(account.healthDetail ? { healthDetail: undefined } : {}),
207
+ });
208
+ }
209
+ /**
210
+ * Re-probe accounts whose `health` is non-OK and whose `healthDetail.until`
211
+ * has passed (or is absent). Used by background sweepers to recover
212
+ * temporarily flagged accounts. We don't load access tokens here — the
213
+ * caller probes via `refreshUsage` separately.
214
+ */
215
+ async reprobeFlagged() {
216
+ const all = this.deps.readAccounts();
217
+ const now = Date.now();
218
+ const ready = [];
219
+ for (const account of Object.values(all)) {
220
+ if (account.health === "ok")
221
+ continue;
222
+ if (account.health === "rate-limited") {
223
+ const until = account.healthDetail?.until;
224
+ if (typeof until === "number" && until > now)
225
+ continue;
226
+ }
227
+ ready.push(account.id);
228
+ }
229
+ return ready;
230
+ }
231
+ }
232
+ function byPriorityThenAge(a, b) {
233
+ if (a.priority !== b.priority)
234
+ return a.priority - b.priority;
235
+ const aLast = a.lastUsedAt ?? 0;
236
+ const bLast = b.lastUsedAt ?? 0;
237
+ return aLast - bLast; // older first
238
+ }
239
+ function byLeastUsedThenPriority(a, b) {
240
+ const aPct = a.usage?.sessionPct ?? 0;
241
+ const bPct = b.usage?.sessionPct ?? 0;
242
+ if (aPct !== bPct)
243
+ return aPct - bPct;
244
+ return byPriorityThenAge(a, b);
245
+ }
246
+ function authRoot() {
247
+ return path.join(process.env.ELIZA_HOME || path.join(os.homedir(), ".eliza"), "auth");
248
+ }
249
+ function metadataFile() {
250
+ return path.join(authRoot(), "_pool-metadata.json");
251
+ }
252
+ function isPoolProviderId(value) {
253
+ return (value === "anthropic-subscription" ||
254
+ value === "openai-codex" ||
255
+ value === "anthropic-api" ||
256
+ value === "openai-api");
257
+ }
258
+ function readMetaStore() {
259
+ const file = metadataFile();
260
+ if (!existsSync(file)) {
261
+ return {};
262
+ }
263
+ try {
264
+ const raw = readFileSync(file, "utf-8");
265
+ const parsed = JSON.parse(raw);
266
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
267
+ return parsed;
268
+ }
269
+ }
270
+ catch {
271
+ // Corrupt file — fall through to empty store. Next write rewrites it.
272
+ }
273
+ return {};
274
+ }
275
+ function writeMetaStore(store) {
276
+ const file = metadataFile();
277
+ const dir = path.dirname(file);
278
+ if (!existsSync(dir)) {
279
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
280
+ }
281
+ const tmp = `${file}.tmp`;
282
+ writeFileSync(tmp, JSON.stringify(store, null, 2), {
283
+ encoding: "utf-8",
284
+ mode: 0o600,
285
+ });
286
+ renameSync(tmp, file);
287
+ }
288
+ function recordToLinked(record, meta, providerId, defaultPriority) {
289
+ return {
290
+ id: record.id,
291
+ providerId,
292
+ label: meta?.label ?? record.label,
293
+ source: record.source,
294
+ enabled: meta?.enabled ?? true,
295
+ priority: meta?.priority ?? defaultPriority,
296
+ createdAt: record.createdAt,
297
+ health: meta?.health ?? "ok",
298
+ ...(record.lastUsedAt !== undefined
299
+ ? { lastUsedAt: record.lastUsedAt }
300
+ : {}),
301
+ ...(meta?.healthDetail ? { healthDetail: meta.healthDetail } : {}),
302
+ ...(meta?.usage ? { usage: meta.usage } : {}),
303
+ ...(record.organizationId
304
+ ? { organizationId: record.organizationId }
305
+ : {}),
306
+ ...(record.userId ? { userId: record.userId } : {}),
307
+ ...(record.email ? { email: record.email } : {}),
308
+ };
309
+ }
310
+ function loadAllAccounts() {
311
+ const subscriptionProviders = [
312
+ "anthropic-subscription",
313
+ "openai-codex",
314
+ ];
315
+ const meta = readMetaStore();
316
+ const out = {};
317
+ for (const provider of subscriptionProviders) {
318
+ const records = listProviderAccounts(provider);
319
+ let priorityCounter = 0;
320
+ const sorted = [...records].sort((a, b) => a.createdAt - b.createdAt);
321
+ for (const record of sorted) {
322
+ const providerMeta = meta[provider]?.[record.id];
323
+ out[record.id] = recordToLinked(record, providerMeta, provider, priorityCounter);
324
+ priorityCounter += 1;
325
+ }
326
+ }
327
+ return out;
328
+ }
329
+ async function persistAccount(account) {
330
+ if (!isPoolProviderId(account.providerId))
331
+ return;
332
+ const store = readMetaStore();
333
+ if (!store[account.providerId]) {
334
+ store[account.providerId] = {};
335
+ }
336
+ store[account.providerId][account.id] = {
337
+ label: account.label,
338
+ enabled: account.enabled,
339
+ priority: account.priority,
340
+ health: account.health,
341
+ ...(account.healthDetail ? { healthDetail: account.healthDetail } : {}),
342
+ ...(account.usage ? { usage: account.usage } : {}),
343
+ };
344
+ writeMetaStore(store);
345
+ }
346
+ let cachedDefaultPool = null;
347
+ /**
348
+ * Module-level singleton for the default pool wired against WS1's
349
+ * `account-storage` and the pool-owned metadata file. Plugins / runtime
350
+ * resolvers should import `getDefaultAccountPool()` rather than building
351
+ * a new pool. WS3 may later swap the default deps to read/write the
352
+ * `LinkedAccountsConfig` field directly out of `milady.json`; consumers
353
+ * keep the same accessor.
354
+ */
355
+ export function getDefaultAccountPool() {
356
+ if (!cachedDefaultPool) {
357
+ cachedDefaultPool = new AccountPool({
358
+ readAccounts: () => loadAllAccounts(),
359
+ writeAccount: persistAccount,
360
+ });
361
+ }
362
+ return cachedDefaultPool;
363
+ }
364
+ /**
365
+ * @deprecated kept for compatibility with the WS2 spec naming. Use
366
+ * {@link getDefaultAccountPool}.
367
+ */
368
+ export function createDefaultAccountPool() {
369
+ return getDefaultAccountPool();
370
+ }
371
+ /**
372
+ * Resets the cached singleton. Test-only.
373
+ */
374
+ export function __resetDefaultAccountPoolForTests() {
375
+ cachedDefaultPool = null;
376
+ }