@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.
- package/package.json +5 -5
- package/packages/agent/src/api/model-provider-helpers.js +10 -10
- package/packages/agent/src/api/provider-switch-config.js +2 -2
- package/packages/agent/src/auth/index.d.ts +2 -0
- package/packages/agent/src/auth/index.d.ts.map +1 -1
- package/packages/agent/src/auth/index.js +2 -0
- package/packages/agent/src/auth/oauth-flow.d.ts +106 -0
- package/packages/agent/src/auth/oauth-flow.d.ts.map +1 -0
- package/packages/agent/src/auth/oauth-flow.js +349 -0
- package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.d.ts +32 -0
- package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.d.ts.map +1 -1
- package/packages/agent/src/auth/vendor/pi-oauth/anthropic-login.js +63 -28
- package/packages/agent/src/config/schema.js +1 -1
- package/packages/agent/src/config/types.messages.d.ts +1 -1
- package/packages/agent/src/config/types.tools.d.ts +1 -1
- package/packages/agent/src/runtime/eliza.js +3 -3
- package/packages/app-core/src/components/conversations/conversation-utils.js +4 -4
- package/packages/app-core/src/components/settings/ProviderSwitcher.js +1 -1
- package/packages/app-core/src/services/account-pool.d.ts +91 -0
- package/packages/app-core/src/services/account-pool.d.ts.map +1 -0
- package/packages/app-core/src/services/account-pool.js +376 -0
- package/packages/app-core/src/services/account-usage.d.ts +73 -0
- package/packages/app-core/src/services/account-usage.d.ts.map +1 -0
- package/packages/app-core/src/services/account-usage.js +179 -0
- 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
|
-
*
|
|
14
|
-
*
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
468
|
-
* `gpt-5.
|
|
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.
|
|
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.
|
|
108
|
-
"gpt-5.
|
|
109
|
-
"gpt-5.
|
|
110
|
-
"gpt-5.
|
|
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.
|
|
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
|
+
}
|