@elizaos/agent 2.0.0-alpha.414 → 2.0.0-alpha.415
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/apps/app-lifeops/src/lifeops/service-mixin-whatsapp.js +1 -90
- package/package.json +4 -4
- package/packages/agent/src/actions/index.d.ts +0 -1
- package/packages/agent/src/actions/index.d.ts.map +1 -1
- package/packages/agent/src/actions/index.js +0 -1
- package/packages/agent/src/api/accounts-routes.d.ts +31 -0
- package/packages/agent/src/api/accounts-routes.d.ts.map +1 -0
- package/packages/agent/src/api/accounts-routes.js +745 -0
- package/packages/agent/src/api/index.d.ts +1 -0
- package/packages/agent/src/api/index.d.ts.map +1 -1
- package/packages/agent/src/api/index.js +1 -0
- package/packages/agent/src/api/server.d.ts.map +1 -1
- package/packages/agent/src/api/server.js +14 -0
- package/packages/agent/src/api/subscription-routes.d.ts.map +1 -1
- package/packages/agent/src/api/subscription-routes.js +48 -1
- package/packages/agent/src/auth/credentials.d.ts.map +1 -1
- package/packages/agent/src/auth/credentials.js +14 -3
- package/packages/agent/src/providers/page-scoped-context.js +1 -1
- package/packages/agent/src/runtime/eliza-plugin.d.ts.map +1 -1
- package/packages/agent/src/runtime/eliza-plugin.js +0 -3
- package/packages/agent/src/runtime/plugin-collector.js +3 -4
- package/packages/app-core/src/components/pages/page-scoped-conversations.js +1 -1
- package/packages/app-core/src/services/auth-store.js +1 -1
- package/packages/shared/src/contracts/lifeops.d.ts +5 -4
- package/packages/shared/src/contracts/lifeops.d.ts.map +1 -1
- package/packages/typescript/src/utils/context-catalog.d.ts.map +1 -1
- package/packages/typescript/src/utils/context-catalog.js +2 -3
- package/packages/agent/src/actions/app-control.d.ts +0 -23
- package/packages/agent/src/actions/app-control.d.ts.map +0 -1
- package/packages/agent/src/actions/app-control.js +0 -775
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-account credentials CRUD + OAuth-from-UI routes.
|
|
3
|
+
*
|
|
4
|
+
* The HTTP surface this exposes (under `/api/accounts/...`) is the
|
|
5
|
+
* source of truth for the React settings page. It joins three sources:
|
|
6
|
+
*
|
|
7
|
+
* - the on-disk credential records under `~/.eliza/auth/...`
|
|
8
|
+
* (`account-storage.ts`),
|
|
9
|
+
* - the live `LinkedAccountConfig` rows in `milady.json` (which own
|
|
10
|
+
* `label`, `enabled`, `priority`, `health`, etc.),
|
|
11
|
+
* - the in-flight OAuth flow registry (`auth/oauth-flow.ts`) used by
|
|
12
|
+
* the `oauth/start` + SSE `oauth/status` + `oauth/cancel` trio.
|
|
13
|
+
*
|
|
14
|
+
* Provider-level account selection strategy lives in a dedicated
|
|
15
|
+
* top-level config key, `accountStrategies` (see `applyStrategyPatch`
|
|
16
|
+
* below). It's a separate slot from the per-capability
|
|
17
|
+
* `serviceRouting[capability].strategy` so the UI can express
|
|
18
|
+
* "always prefer my Pro Anthropic account before falling back to my
|
|
19
|
+
* Max one" without having to know which capability each provider
|
|
20
|
+
* powers.
|
|
21
|
+
*/
|
|
22
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
23
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
24
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
25
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return path;
|
|
29
|
+
};
|
|
30
|
+
import nodeCrypto from "node:crypto";
|
|
31
|
+
import { logger } from "@elizaos/core";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { deleteAccount, listAccounts, loadAccount, saveAccount, } from "../auth/account-storage.js";
|
|
34
|
+
import { getAccessToken } from "../auth/credentials.js";
|
|
35
|
+
import { cancelFlow, getFlowState, startAnthropicOAuthFlow, startCodexOAuthFlow, submitFlowCode, subscribeFlow, } from "../auth/oauth-flow.js";
|
|
36
|
+
// ─── Provider id mapping ────────────────────────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Provider IDs the multi-account API accepts. The on-disk credential
|
|
39
|
+
* store currently only handles the two subscription providers
|
|
40
|
+
* (`SubscriptionProvider`); plain API keys are accepted by the API
|
|
41
|
+
* but writing them is out of scope until the storage layer grows
|
|
42
|
+
* support for them — for now we reject `anthropic-api` / `openai-api`
|
|
43
|
+
* with 501.
|
|
44
|
+
*/
|
|
45
|
+
const SUPPORTED_PROVIDER_IDS = [
|
|
46
|
+
"anthropic-subscription",
|
|
47
|
+
"openai-codex",
|
|
48
|
+
"anthropic-api",
|
|
49
|
+
"openai-api",
|
|
50
|
+
];
|
|
51
|
+
const SUBSCRIPTION_PROVIDER_IDS = new Set([
|
|
52
|
+
"anthropic-subscription",
|
|
53
|
+
"openai-codex",
|
|
54
|
+
]);
|
|
55
|
+
function isLinkedAccountProviderId(value) {
|
|
56
|
+
return SUPPORTED_PROVIDER_IDS.includes(value);
|
|
57
|
+
}
|
|
58
|
+
function asSubscriptionProvider(providerId) {
|
|
59
|
+
return SUBSCRIPTION_PROVIDER_IDS.has(providerId)
|
|
60
|
+
? providerId
|
|
61
|
+
: null;
|
|
62
|
+
}
|
|
63
|
+
// ─── Validation schemas ─────────────────────────────────────────────
|
|
64
|
+
const apiKeyAccountSchema = z.object({
|
|
65
|
+
source: z.literal("api-key"),
|
|
66
|
+
label: z.string().trim().min(1).max(120),
|
|
67
|
+
apiKey: z.string().min(8).max(2048),
|
|
68
|
+
});
|
|
69
|
+
const oauthStartSchema = z.object({
|
|
70
|
+
label: z.string().trim().min(1).max(120),
|
|
71
|
+
});
|
|
72
|
+
const oauthSubmitCodeSchema = z.object({
|
|
73
|
+
sessionId: z.string().min(1),
|
|
74
|
+
code: z.string().min(1),
|
|
75
|
+
});
|
|
76
|
+
const oauthCancelSchema = z.object({
|
|
77
|
+
sessionId: z.string().min(1),
|
|
78
|
+
});
|
|
79
|
+
const accountPatchSchema = z
|
|
80
|
+
.object({
|
|
81
|
+
label: z.string().trim().min(1).max(120).optional(),
|
|
82
|
+
enabled: z.boolean().optional(),
|
|
83
|
+
priority: z.number().int().min(0).max(10_000).optional(),
|
|
84
|
+
})
|
|
85
|
+
.refine((v) => v.label !== undefined ||
|
|
86
|
+
v.enabled !== undefined ||
|
|
87
|
+
v.priority !== undefined, { message: "PATCH body must set at least one of: label, enabled, priority" });
|
|
88
|
+
const STRATEGY_VALUES = [
|
|
89
|
+
"priority",
|
|
90
|
+
"round-robin",
|
|
91
|
+
"least-used",
|
|
92
|
+
"quota-aware",
|
|
93
|
+
];
|
|
94
|
+
const strategyPatchSchema = z.object({
|
|
95
|
+
strategy: z.enum(STRATEGY_VALUES),
|
|
96
|
+
});
|
|
97
|
+
// ─── Config helpers ─────────────────────────────────────────────────
|
|
98
|
+
/**
|
|
99
|
+
* Rich linked-account record map, stored at `config.linkedAccounts[id]`.
|
|
100
|
+
*
|
|
101
|
+
* Note on the dual-shape: the `linkedAccounts` field in the on-disk
|
|
102
|
+
* `milady.json` ALSO holds legacy `LinkedAccountFlagConfig` entries for
|
|
103
|
+
* providers like `elizacloud` / `cloud`. The legacy keys are
|
|
104
|
+
* provider-name strings, while the multi-account keys are uuid v4s, so
|
|
105
|
+
* they don't collide at runtime. We treat the dict as a union and only
|
|
106
|
+
* touch entries we own (those whose value is a `LinkedAccountConfig`).
|
|
107
|
+
*/
|
|
108
|
+
function readLinkedAccountsRecord(config) {
|
|
109
|
+
return (config.linkedAccounts ?? {});
|
|
110
|
+
}
|
|
111
|
+
function isRichLinkedAccount(value) {
|
|
112
|
+
if (!value || typeof value !== "object")
|
|
113
|
+
return false;
|
|
114
|
+
const v = value;
|
|
115
|
+
return (typeof v.id === "string" &&
|
|
116
|
+
typeof v.providerId === "string" &&
|
|
117
|
+
isLinkedAccountProviderId(v.providerId) &&
|
|
118
|
+
typeof v.label === "string" &&
|
|
119
|
+
(v.source === "oauth" || v.source === "api-key") &&
|
|
120
|
+
typeof v.enabled === "boolean" &&
|
|
121
|
+
typeof v.priority === "number" &&
|
|
122
|
+
typeof v.createdAt === "number" &&
|
|
123
|
+
typeof v.health === "string");
|
|
124
|
+
}
|
|
125
|
+
function listLinkedAccountsForProvider(config, providerId) {
|
|
126
|
+
const record = readLinkedAccountsRecord(config);
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const value of Object.values(record)) {
|
|
129
|
+
if (!isRichLinkedAccount(value))
|
|
130
|
+
continue;
|
|
131
|
+
if (value.providerId !== providerId)
|
|
132
|
+
continue;
|
|
133
|
+
out.push(value);
|
|
134
|
+
}
|
|
135
|
+
out.sort((a, b) => a.priority - b.priority);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function nextPriority(config, providerId) {
|
|
139
|
+
const existing = listLinkedAccountsForProvider(config, providerId);
|
|
140
|
+
if (existing.length === 0)
|
|
141
|
+
return 0;
|
|
142
|
+
return Math.max(...existing.map((a) => a.priority)) + 1;
|
|
143
|
+
}
|
|
144
|
+
function ensureLinkedAccountsBag(config) {
|
|
145
|
+
if (!config.linkedAccounts) {
|
|
146
|
+
config
|
|
147
|
+
.linkedAccounts = {};
|
|
148
|
+
}
|
|
149
|
+
return config.linkedAccounts;
|
|
150
|
+
}
|
|
151
|
+
function writeLinkedAccount(config, account) {
|
|
152
|
+
const bag = ensureLinkedAccountsBag(config);
|
|
153
|
+
bag[account.id] = account;
|
|
154
|
+
}
|
|
155
|
+
function removeLinkedAccount(config, accountId) {
|
|
156
|
+
const bag = config.linkedAccounts;
|
|
157
|
+
if (!bag)
|
|
158
|
+
return;
|
|
159
|
+
if (Object.hasOwn(bag, accountId)) {
|
|
160
|
+
delete bag[accountId];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function readAccountStrategy(config, providerId) {
|
|
164
|
+
const strategies = config
|
|
165
|
+
.accountStrategies;
|
|
166
|
+
return strategies?.[providerId] ?? "priority";
|
|
167
|
+
}
|
|
168
|
+
function writeAccountStrategy(config, providerId, strategy) {
|
|
169
|
+
const cfg = config;
|
|
170
|
+
if (!cfg.accountStrategies)
|
|
171
|
+
cfg.accountStrategies = {};
|
|
172
|
+
cfg.accountStrategies[providerId] = strategy;
|
|
173
|
+
}
|
|
174
|
+
// ─── Account ↔ config sync ──────────────────────────────────────────
|
|
175
|
+
function buildLinkedAccountConfigFromRecord(record, priority) {
|
|
176
|
+
if (!isLinkedAccountProviderId(record.providerId)) {
|
|
177
|
+
throw new Error(`Internal error: provider "${record.providerId}" cannot back a LinkedAccountConfig`);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
id: record.id,
|
|
181
|
+
providerId: record.providerId,
|
|
182
|
+
label: record.label,
|
|
183
|
+
source: record.source,
|
|
184
|
+
enabled: true,
|
|
185
|
+
priority,
|
|
186
|
+
createdAt: record.createdAt,
|
|
187
|
+
health: "ok",
|
|
188
|
+
...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}),
|
|
189
|
+
...(record.organizationId
|
|
190
|
+
? { organizationId: record.organizationId }
|
|
191
|
+
: {}),
|
|
192
|
+
...(record.userId ? { userId: record.userId } : {}),
|
|
193
|
+
...(record.email ? { email: record.email } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
// ─── Inline usage probes (WS2 fallback) ─────────────────────────────
|
|
197
|
+
/**
|
|
198
|
+
* The full WS2 `accountPool.refreshUsage` provides a richer signal
|
|
199
|
+
* (it also updates the in-memory pool's health/cooldown state). When
|
|
200
|
+
* it isn't loaded yet we still want the UI to surface SOMETHING after
|
|
201
|
+
* a "Refresh usage" click, so we issue a 1-token probe and fold the
|
|
202
|
+
* `anthropic-ratelimit-*` (Anthropic) / `x-ratelimit-*` (Codex)
|
|
203
|
+
* response headers into a `LinkedAccountUsage`. Numbers are
|
|
204
|
+
* conservative — anything we can't read becomes `undefined`, never
|
|
205
|
+
* `0`.
|
|
206
|
+
*/
|
|
207
|
+
async function probeAnthropicUsage(accessToken) {
|
|
208
|
+
const start = Date.now();
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
213
|
+
method: "POST",
|
|
214
|
+
signal: controller.signal,
|
|
215
|
+
headers: {
|
|
216
|
+
"Content-Type": "application/json",
|
|
217
|
+
"anthropic-version": "2023-06-01",
|
|
218
|
+
Authorization: `Bearer ${accessToken}`,
|
|
219
|
+
},
|
|
220
|
+
body: JSON.stringify({
|
|
221
|
+
model: "claude-haiku-4-5-20251001",
|
|
222
|
+
max_tokens: 1,
|
|
223
|
+
messages: [{ role: "user", content: "hi" }],
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
const latencyMs = Date.now() - start;
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
const text = await response.text().catch(() => "");
|
|
229
|
+
return {
|
|
230
|
+
ok: false,
|
|
231
|
+
status: response.status,
|
|
232
|
+
error: `Anthropic ${response.status}: ${text.slice(0, 200)}`,
|
|
233
|
+
latencyMs,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
status: response.status,
|
|
239
|
+
usage: { refreshedAt: Date.now() },
|
|
240
|
+
latencyMs,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
status: 0,
|
|
247
|
+
error: err instanceof Error ? err.message : String(err),
|
|
248
|
+
latencyMs: Date.now() - start,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async function probeCodexUsage(accessToken, codexAccountId) {
|
|
256
|
+
const start = Date.now();
|
|
257
|
+
const controller = new AbortController();
|
|
258
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
259
|
+
try {
|
|
260
|
+
const headers = {
|
|
261
|
+
"Content-Type": "application/json",
|
|
262
|
+
Authorization: `Bearer ${accessToken}`,
|
|
263
|
+
};
|
|
264
|
+
if (codexAccountId)
|
|
265
|
+
headers["ChatGPT-Account-Id"] = codexAccountId;
|
|
266
|
+
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
267
|
+
method: "POST",
|
|
268
|
+
signal: controller.signal,
|
|
269
|
+
headers,
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
model: "gpt-5.5-mini",
|
|
272
|
+
max_tokens: 1,
|
|
273
|
+
messages: [{ role: "user", content: "hi" }],
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
const latencyMs = Date.now() - start;
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
const text = await response.text().catch(() => "");
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
status: response.status,
|
|
282
|
+
error: `OpenAI ${response.status}: ${text.slice(0, 200)}`,
|
|
283
|
+
latencyMs,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
status: response.status,
|
|
289
|
+
usage: { refreshedAt: Date.now() },
|
|
290
|
+
latencyMs,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
ok: false,
|
|
296
|
+
status: 0,
|
|
297
|
+
error: err instanceof Error ? err.message : String(err),
|
|
298
|
+
latencyMs: Date.now() - start,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const ACCOUNTS_PREFIX = "/api/accounts";
|
|
306
|
+
const PROVIDERS_PREFIX = "/api/providers";
|
|
307
|
+
export async function handleAccountsRoutes(ctx) {
|
|
308
|
+
const { req, res, method, pathname, json, error, readJsonBody } = ctx;
|
|
309
|
+
if (!pathname.startsWith(ACCOUNTS_PREFIX) &&
|
|
310
|
+
!pathname.startsWith(PROVIDERS_PREFIX)) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
// ── PATCH /api/providers/:providerId/strategy ─────────────────────
|
|
314
|
+
if (method === "PATCH" &&
|
|
315
|
+
pathname.startsWith(`${PROVIDERS_PREFIX}/`) &&
|
|
316
|
+
pathname.endsWith("/strategy")) {
|
|
317
|
+
const providerId = pathname
|
|
318
|
+
.slice(PROVIDERS_PREFIX.length + 1)
|
|
319
|
+
.replace(/\/strategy$/, "");
|
|
320
|
+
if (!isLinkedAccountProviderId(providerId)) {
|
|
321
|
+
error(res, `Unknown providerId: ${providerId}`, 400);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
const body = await readJsonBody(req, res);
|
|
325
|
+
if (!body)
|
|
326
|
+
return true;
|
|
327
|
+
const parsed = strategyPatchSchema.safeParse(body);
|
|
328
|
+
if (!parsed.success) {
|
|
329
|
+
error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
writeAccountStrategy(ctx.state.config, providerId, parsed.data.strategy);
|
|
333
|
+
ctx.saveConfig(ctx.state.config);
|
|
334
|
+
json(res, { providerId, strategy: parsed.data.strategy });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (pathname === ACCOUNTS_PREFIX && method === "GET") {
|
|
338
|
+
return await handleListAllAccounts(ctx);
|
|
339
|
+
}
|
|
340
|
+
// ── /api/accounts/:providerId... ──────────────────────────────────
|
|
341
|
+
if (!pathname.startsWith(`${ACCOUNTS_PREFIX}/`))
|
|
342
|
+
return false;
|
|
343
|
+
const remainder = pathname.slice(ACCOUNTS_PREFIX.length + 1);
|
|
344
|
+
const segments = remainder.split("/").filter((s) => s.length > 0);
|
|
345
|
+
if (segments.length === 0)
|
|
346
|
+
return false;
|
|
347
|
+
const providerId = segments[0];
|
|
348
|
+
if (!isLinkedAccountProviderId(providerId)) {
|
|
349
|
+
error(res, `Unknown providerId: ${providerId}`, 400);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
// ── POST /api/accounts/:providerId (api-key add) ──────────────────
|
|
353
|
+
if (segments.length === 1 && method === "POST") {
|
|
354
|
+
return await handleCreateApiKeyAccount(ctx, providerId);
|
|
355
|
+
}
|
|
356
|
+
// ── OAuth flow trio ───────────────────────────────────────────────
|
|
357
|
+
if (segments[1] === "oauth") {
|
|
358
|
+
return await handleOAuthRoutes(ctx, providerId, segments.slice(2));
|
|
359
|
+
}
|
|
360
|
+
// ── /:accountId actions ───────────────────────────────────────────
|
|
361
|
+
if (segments.length >= 2) {
|
|
362
|
+
const accountId = segments[1];
|
|
363
|
+
if (segments.length === 2) {
|
|
364
|
+
if (method === "PATCH") {
|
|
365
|
+
return await handlePatchAccount(ctx, providerId, accountId);
|
|
366
|
+
}
|
|
367
|
+
if (method === "DELETE") {
|
|
368
|
+
return await handleDeleteAccount(ctx, providerId, accountId);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (segments.length === 3 && method === "POST") {
|
|
372
|
+
if (segments[2] === "test") {
|
|
373
|
+
return await handleTestAccount(ctx, providerId, accountId);
|
|
374
|
+
}
|
|
375
|
+
if (segments[2] === "refresh-usage") {
|
|
376
|
+
return await handleRefreshUsage(ctx, providerId, accountId);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
// ─── Handlers ───────────────────────────────────────────────────────
|
|
383
|
+
async function handleListAllAccounts(ctx) {
|
|
384
|
+
const { res, json } = ctx;
|
|
385
|
+
const providers = SUPPORTED_PROVIDER_IDS.map((providerId) => {
|
|
386
|
+
const linkedConfigs = listLinkedAccountsForProvider(ctx.state.config, providerId);
|
|
387
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
388
|
+
const onDiskAccounts = subscription
|
|
389
|
+
? listAccounts(subscription).map((r) => r.id)
|
|
390
|
+
: [];
|
|
391
|
+
const onDiskSet = new Set(onDiskAccounts);
|
|
392
|
+
return {
|
|
393
|
+
providerId,
|
|
394
|
+
strategy: readAccountStrategy(ctx.state.config, providerId),
|
|
395
|
+
accounts: linkedConfigs.map((cfg) => ({
|
|
396
|
+
...cfg,
|
|
397
|
+
hasCredential: onDiskSet.has(cfg.id),
|
|
398
|
+
})),
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
json(res, { providers });
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
async function handleCreateApiKeyAccount(ctx, providerId) {
|
|
405
|
+
const { req, res, json, error, readJsonBody } = ctx;
|
|
406
|
+
const body = await readJsonBody(req, res);
|
|
407
|
+
if (!body)
|
|
408
|
+
return true;
|
|
409
|
+
const parsed = apiKeyAccountSchema.safeParse(body);
|
|
410
|
+
if (!parsed.success) {
|
|
411
|
+
error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
415
|
+
if (!subscription) {
|
|
416
|
+
error(res, `API-key accounts for ${providerId} are not yet wired up — track WS2`, 501);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
const id = nodeCrypto.randomUUID();
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
const record = {
|
|
422
|
+
id,
|
|
423
|
+
providerId: subscription,
|
|
424
|
+
label: parsed.data.label,
|
|
425
|
+
source: "api-key",
|
|
426
|
+
credentials: {
|
|
427
|
+
access: parsed.data.apiKey,
|
|
428
|
+
refresh: "",
|
|
429
|
+
// Sentinel: api-key creds never expire.
|
|
430
|
+
expires: Number.MAX_SAFE_INTEGER,
|
|
431
|
+
},
|
|
432
|
+
createdAt: now,
|
|
433
|
+
updatedAt: now,
|
|
434
|
+
};
|
|
435
|
+
saveAccount(record);
|
|
436
|
+
const priority = nextPriority(ctx.state.config, providerId);
|
|
437
|
+
const linkedConfig = buildLinkedAccountConfigFromRecord(record, priority);
|
|
438
|
+
writeLinkedAccount(ctx.state.config, linkedConfig);
|
|
439
|
+
ctx.saveConfig(ctx.state.config);
|
|
440
|
+
json(res, linkedConfig, 201);
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
async function handleOAuthRoutes(ctx, providerId, rest) {
|
|
444
|
+
const { req, res, json, error, readJsonBody, method } = ctx;
|
|
445
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
446
|
+
if (!subscription) {
|
|
447
|
+
error(res, `OAuth not supported for providerId: ${providerId}`, 400);
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
const action = rest[0];
|
|
451
|
+
if (action === "start" && method === "POST") {
|
|
452
|
+
const body = await readJsonBody(req, res);
|
|
453
|
+
if (!body)
|
|
454
|
+
return true;
|
|
455
|
+
const parsed = oauthStartSchema.safeParse(body);
|
|
456
|
+
if (!parsed.success) {
|
|
457
|
+
error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
// Reserve an accountId and the priority slot up front so the
|
|
461
|
+
// post-save hook lands at a deterministic position.
|
|
462
|
+
const accountId = nodeCrypto.randomUUID();
|
|
463
|
+
const priority = nextPriority(ctx.state.config, providerId);
|
|
464
|
+
const onAccountSaved = (record) => {
|
|
465
|
+
const linkedConfig = buildLinkedAccountConfigFromRecord(record, priority);
|
|
466
|
+
writeLinkedAccount(ctx.state.config, linkedConfig);
|
|
467
|
+
ctx.saveConfig(ctx.state.config);
|
|
468
|
+
};
|
|
469
|
+
let handle;
|
|
470
|
+
try {
|
|
471
|
+
handle =
|
|
472
|
+
subscription === "anthropic-subscription"
|
|
473
|
+
? await startAnthropicOAuthFlow({
|
|
474
|
+
label: parsed.data.label,
|
|
475
|
+
accountId,
|
|
476
|
+
onAccountSaved,
|
|
477
|
+
})
|
|
478
|
+
: await startCodexOAuthFlow({
|
|
479
|
+
label: parsed.data.label,
|
|
480
|
+
accountId,
|
|
481
|
+
onAccountSaved,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
logger.error(`[accounts] Failed to start ${providerId} OAuth flow: ${String(err)}`);
|
|
486
|
+
error(res, "Failed to start OAuth flow", 500);
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
json(res, {
|
|
490
|
+
sessionId: handle.sessionId,
|
|
491
|
+
authUrl: handle.authUrl,
|
|
492
|
+
needsCodeSubmission: handle.needsCodeSubmission,
|
|
493
|
+
});
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
if (action === "status" && method === "GET") {
|
|
497
|
+
return handleOAuthStatusSse(ctx, providerId);
|
|
498
|
+
}
|
|
499
|
+
if (action === "submit-code" && method === "POST") {
|
|
500
|
+
const body = await readJsonBody(req, res);
|
|
501
|
+
if (!body)
|
|
502
|
+
return true;
|
|
503
|
+
const parsed = oauthSubmitCodeSchema.safeParse(body);
|
|
504
|
+
if (!parsed.success) {
|
|
505
|
+
error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
const accepted = submitFlowCode(parsed.data.sessionId, parsed.data.code);
|
|
509
|
+
if (!accepted) {
|
|
510
|
+
error(res, "No active flow accepts a code submission", 400);
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
json(res, { accepted: true });
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
if (action === "cancel" && method === "POST") {
|
|
517
|
+
const body = await readJsonBody(req, res);
|
|
518
|
+
if (!body)
|
|
519
|
+
return true;
|
|
520
|
+
const parsed = oauthCancelSchema.safeParse(body);
|
|
521
|
+
if (!parsed.success) {
|
|
522
|
+
error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
const cancelled = cancelFlow(parsed.data.sessionId, "Cancelled by user");
|
|
526
|
+
json(res, { cancelled });
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
function handleOAuthStatusSse(ctx, providerId) {
|
|
532
|
+
const { req, res, error } = ctx;
|
|
533
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
534
|
+
const sessionId = url.searchParams.get("sessionId");
|
|
535
|
+
if (!sessionId) {
|
|
536
|
+
error(res, "Missing sessionId", 400);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
const initial = getFlowState(sessionId);
|
|
540
|
+
if (!initial) {
|
|
541
|
+
error(res, "Unknown sessionId", 404);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
if (initial.providerId !== providerId) {
|
|
545
|
+
error(res, "Provider mismatch for sessionId", 400);
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
res.statusCode = 200;
|
|
549
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
550
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
551
|
+
res.setHeader("Connection", "keep-alive");
|
|
552
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
553
|
+
const writeEvent = (data) => {
|
|
554
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
555
|
+
};
|
|
556
|
+
let closed = false;
|
|
557
|
+
const finish = () => {
|
|
558
|
+
if (closed)
|
|
559
|
+
return;
|
|
560
|
+
closed = true;
|
|
561
|
+
try {
|
|
562
|
+
res.end();
|
|
563
|
+
}
|
|
564
|
+
catch (err) {
|
|
565
|
+
logger.debug(`[accounts] sse end failed: ${String(err)}`);
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
const unsubscribe = subscribeFlow(sessionId, (state) => {
|
|
569
|
+
if (closed)
|
|
570
|
+
return;
|
|
571
|
+
writeEvent(state);
|
|
572
|
+
if (state.status !== "pending") {
|
|
573
|
+
unsubscribe();
|
|
574
|
+
finish();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
req.on("close", () => {
|
|
578
|
+
unsubscribe();
|
|
579
|
+
finish();
|
|
580
|
+
});
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
async function handlePatchAccount(ctx, providerId, accountId) {
|
|
584
|
+
const { req, res, json, error, readJsonBody } = ctx;
|
|
585
|
+
const body = await readJsonBody(req, res);
|
|
586
|
+
if (!body)
|
|
587
|
+
return true;
|
|
588
|
+
const parsed = accountPatchSchema.safeParse(body);
|
|
589
|
+
if (!parsed.success) {
|
|
590
|
+
error(res, parsed.error.issues[0]?.message ?? "Invalid body", 400);
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
const existing = readLinkedAccountsRecord(ctx.state.config)[accountId];
|
|
594
|
+
if (!isRichLinkedAccount(existing) || existing.providerId !== providerId) {
|
|
595
|
+
error(res, "Account not found", 404);
|
|
596
|
+
return true;
|
|
597
|
+
}
|
|
598
|
+
const next = {
|
|
599
|
+
...existing,
|
|
600
|
+
...(parsed.data.label !== undefined ? { label: parsed.data.label } : {}),
|
|
601
|
+
...(parsed.data.enabled !== undefined
|
|
602
|
+
? { enabled: parsed.data.enabled }
|
|
603
|
+
: {}),
|
|
604
|
+
...(parsed.data.priority !== undefined
|
|
605
|
+
? { priority: parsed.data.priority }
|
|
606
|
+
: {}),
|
|
607
|
+
};
|
|
608
|
+
writeLinkedAccount(ctx.state.config, next);
|
|
609
|
+
ctx.saveConfig(ctx.state.config);
|
|
610
|
+
// Mirror label changes onto the on-disk credential so listAccounts()
|
|
611
|
+
// and the runtime keep reading the same name.
|
|
612
|
+
if (parsed.data.label !== undefined) {
|
|
613
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
614
|
+
if (subscription) {
|
|
615
|
+
const record = loadAccount(subscription, accountId);
|
|
616
|
+
if (record && record.label !== parsed.data.label) {
|
|
617
|
+
saveAccount({ ...record, label: parsed.data.label });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
json(res, next);
|
|
622
|
+
return true;
|
|
623
|
+
}
|
|
624
|
+
async function handleDeleteAccount(ctx, providerId, accountId) {
|
|
625
|
+
const { res, json } = ctx;
|
|
626
|
+
removeLinkedAccount(ctx.state.config, accountId);
|
|
627
|
+
ctx.saveConfig(ctx.state.config);
|
|
628
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
629
|
+
if (subscription) {
|
|
630
|
+
deleteAccount(subscription, accountId);
|
|
631
|
+
}
|
|
632
|
+
json(res, { deleted: true });
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
async function handleTestAccount(ctx, providerId, accountId) {
|
|
636
|
+
const { res, json, error } = ctx;
|
|
637
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
638
|
+
if (!subscription) {
|
|
639
|
+
error(res, `Test not supported for ${providerId}`, 501);
|
|
640
|
+
return true;
|
|
641
|
+
}
|
|
642
|
+
const accessToken = await getAccessToken(subscription, accountId);
|
|
643
|
+
if (!accessToken) {
|
|
644
|
+
json(res, { ok: false, error: "No credential available" });
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
const linked = readLinkedAccountsRecord(ctx.state.config)[accountId];
|
|
648
|
+
const codexAccountId = isRichLinkedAccount(linked) && linked.providerId === "openai-codex"
|
|
649
|
+
? linked.organizationId
|
|
650
|
+
: undefined;
|
|
651
|
+
const probe = subscription === "anthropic-subscription"
|
|
652
|
+
? await probeAnthropicUsage(accessToken)
|
|
653
|
+
: await probeCodexUsage(accessToken, codexAccountId);
|
|
654
|
+
if (probe.ok) {
|
|
655
|
+
json(res, { ok: true, latencyMs: probe.latencyMs, status: probe.status });
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
json(res, {
|
|
659
|
+
ok: false,
|
|
660
|
+
error: probe.error ?? `HTTP ${probe.status}`,
|
|
661
|
+
status: probe.status,
|
|
662
|
+
latencyMs: probe.latencyMs,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
async function handleRefreshUsage(ctx, providerId, accountId) {
|
|
668
|
+
const { res, json, error } = ctx;
|
|
669
|
+
const subscription = asSubscriptionProvider(providerId);
|
|
670
|
+
if (!subscription) {
|
|
671
|
+
error(res, `Usage refresh not supported for ${providerId}`, 501);
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
const linked = readLinkedAccountsRecord(ctx.state.config)[accountId];
|
|
675
|
+
if (!isRichLinkedAccount(linked) || linked.providerId !== providerId) {
|
|
676
|
+
error(res, "Account not found", 404);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
const accessToken = await getAccessToken(subscription, accountId);
|
|
680
|
+
if (!accessToken) {
|
|
681
|
+
error(res, "No credential available", 400);
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
// Prefer WS2's pool when it's loaded — it owns the canonical
|
|
685
|
+
// `pollAnthropicUsage` / `pollCodexUsage` calls plus the in-memory
|
|
686
|
+
// health/cooldown cache. Falls back to an inline 1-token probe when
|
|
687
|
+
// the pool isn't reachable (e.g. tests, leaner installs).
|
|
688
|
+
const poolResult = await tryRefreshViaPool({
|
|
689
|
+
accountId,
|
|
690
|
+
accessToken,
|
|
691
|
+
codexAccountId: linked.organizationId,
|
|
692
|
+
});
|
|
693
|
+
if (poolResult.ok) {
|
|
694
|
+
const refreshed = readLinkedAccountsRecord(ctx.state.config)[accountId];
|
|
695
|
+
if (isRichLinkedAccount(refreshed)) {
|
|
696
|
+
json(res, { account: refreshed, source: "pool" });
|
|
697
|
+
return true;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const probe = subscription === "anthropic-subscription"
|
|
701
|
+
? await probeAnthropicUsage(accessToken)
|
|
702
|
+
: await probeCodexUsage(accessToken, linked.organizationId);
|
|
703
|
+
const next = {
|
|
704
|
+
...linked,
|
|
705
|
+
...(probe.usage ? { usage: probe.usage } : {}),
|
|
706
|
+
health: probe.ok ? "ok" : "rate-limited",
|
|
707
|
+
healthDetail: probe.ok
|
|
708
|
+
? { lastChecked: Date.now() }
|
|
709
|
+
: {
|
|
710
|
+
lastChecked: Date.now(),
|
|
711
|
+
...(probe.error ? { lastError: probe.error } : {}),
|
|
712
|
+
},
|
|
713
|
+
};
|
|
714
|
+
writeLinkedAccount(ctx.state.config, next);
|
|
715
|
+
ctx.saveConfig(ctx.state.config);
|
|
716
|
+
json(res, { account: next, probe, source: "inline-probe" });
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Try to drive the usage refresh through WS2's `AccountPool` singleton
|
|
721
|
+
* if it's loaded. The pool lives in `@elizaos/app-core` (which depends
|
|
722
|
+
* on `@elizaos/agent`, not the other way around), so we resolve it via
|
|
723
|
+
* a runtime dynamic import to avoid a cyclic dependency. Returns
|
|
724
|
+
* `{ ok: false }` if the pool can't be loaded — caller then falls back
|
|
725
|
+
* to the inline probe.
|
|
726
|
+
*/
|
|
727
|
+
async function tryRefreshViaPool(args) {
|
|
728
|
+
try {
|
|
729
|
+
// The dynamic import resolves at runtime only; bundlers / tsc
|
|
730
|
+
// don't follow it as a hard edge.
|
|
731
|
+
const moduleId = "@elizaos/app-core/services/account-pool";
|
|
732
|
+
const mod = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ moduleId)));
|
|
733
|
+
if (!mod.getDefaultAccountPool)
|
|
734
|
+
return { ok: false };
|
|
735
|
+
const pool = mod.getDefaultAccountPool();
|
|
736
|
+
await pool.refreshUsage(args.accountId, args.accessToken, {
|
|
737
|
+
...(args.codexAccountId ? { codexAccountId: args.codexAccountId } : {}),
|
|
738
|
+
});
|
|
739
|
+
return { ok: true };
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
logger.debug(`[accounts] pool refreshUsage unavailable: ${String(err)}`);
|
|
743
|
+
return { ok: false };
|
|
744
|
+
}
|
|
745
|
+
}
|