takomi 2.1.2 → 2.1.4

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 (52) hide show
  1. package/.pi/README.md +124 -124
  2. package/.pi/agents/architect.md +15 -15
  3. package/.pi/agents/coder.md +14 -14
  4. package/.pi/agents/designer.md +17 -17
  5. package/.pi/agents/orchestrator.md +22 -22
  6. package/.pi/agents/reviewer.md +16 -16
  7. package/.pi/extensions/oauth-router/README.md +125 -125
  8. package/.pi/extensions/oauth-router/commands.ts +380 -380
  9. package/.pi/extensions/oauth-router/config.ts +200 -200
  10. package/.pi/extensions/oauth-router/index.ts +41 -41
  11. package/.pi/extensions/oauth-router/oauth-flow.ts +154 -154
  12. package/.pi/extensions/oauth-router/oauth-store.ts +121 -121
  13. package/.pi/extensions/oauth-router/package.json +14 -14
  14. package/.pi/extensions/oauth-router/policies.ts +27 -27
  15. package/.pi/extensions/oauth-router/provider.ts +492 -492
  16. package/.pi/extensions/oauth-router/scripts/vibe-verify.py +98 -98
  17. package/.pi/extensions/oauth-router/state.ts +174 -174
  18. package/.pi/extensions/oauth-router/types.ts +153 -153
  19. package/.pi/extensions/takomi-runtime/command-text.ts +130 -130
  20. package/.pi/extensions/takomi-runtime/commands.ts +179 -179
  21. package/.pi/extensions/takomi-runtime/context-panel.ts +282 -282
  22. package/.pi/extensions/takomi-runtime/index.ts +1288 -1288
  23. package/.pi/extensions/takomi-runtime/profile.ts +114 -114
  24. package/.pi/extensions/takomi-runtime/routing-policy.ts +105 -105
  25. package/.pi/extensions/takomi-runtime/shared.ts +511 -492
  26. package/.pi/extensions/takomi-runtime/subagent-controller.ts +364 -364
  27. package/.pi/extensions/takomi-runtime/subagent-render.ts +501 -501
  28. package/.pi/extensions/takomi-runtime/subagent-types.ts +90 -83
  29. package/.pi/extensions/takomi-runtime/ui.ts +133 -133
  30. package/.pi/extensions/takomi-subagents/agent-aliases.ts +18 -18
  31. package/.pi/extensions/takomi-subagents/agents.ts +113 -113
  32. package/.pi/extensions/takomi-subagents/delegation-plan.ts +95 -95
  33. package/.pi/extensions/takomi-subagents/dispatch-helpers.ts +26 -26
  34. package/.pi/extensions/takomi-subagents/dispatch.ts +306 -215
  35. package/.pi/extensions/takomi-subagents/index.ts +76 -75
  36. package/.pi/extensions/takomi-subagents/live-updates.ts +136 -83
  37. package/.pi/extensions/takomi-subagents/native-render.ts +5 -142
  38. package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +228 -0
  39. package/.pi/extensions/takomi-subagents/tool-runner.ts +209 -209
  40. package/.pi/themes/takomi-noir.json +81 -81
  41. package/package.json +59 -59
  42. package/src/cli.js +14 -0
  43. package/src/doctor.js +87 -84
  44. package/src/pi-harness.js +355 -351
  45. package/src/pi-installer.js +193 -171
  46. package/src/pi-takomi-core/index.ts +4 -4
  47. package/src/pi-takomi-core/orchestration.ts +402 -402
  48. package/src/pi-takomi-core/routing.ts +93 -93
  49. package/src/pi-takomi-core/types.ts +173 -173
  50. package/src/pi-takomi-core/workflows.ts +299 -299
  51. package/src/skills-installer.js +101 -101
  52. package/src/update-check.js +140 -0
@@ -1,492 +1,492 @@
1
- import {
2
- createAssistantMessageEventStream,
3
- type Api,
4
- type AssistantMessage,
5
- type AssistantMessageEvent,
6
- type AssistantMessageEventStream,
7
- type Context,
8
- type Model,
9
- type SimpleStreamOptions,
10
- streamSimpleOpenAICodexResponses,
11
- streamSimpleOpenAICompletions,
12
- streamSimpleOpenAIResponses,
13
- } from "@mariozechner/pi-ai";
14
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
- import { loadRouterConfig } from "./config.ts";
16
- import { refreshAccountCredentials, getApiKeyForAccount } from "./oauth-flow.ts";
17
- import { RouterAccountStore } from "./oauth-store.ts";
18
- import { chooseEligibleAccount } from "./policies.ts";
19
- import { RouterStateStore } from "./state.ts";
20
- import type {
21
- DelegateSelection,
22
- EligibleAccount,
23
- FailureClassification,
24
- RouterConfig,
25
- RouterErrorMessageInput,
26
- RouterModelConfig,
27
- RouterStatusRow,
28
- RouterUpstreamConfig,
29
- RoutingPolicyName,
30
- StoredRouterAccount,
31
- } from "./types.ts";
32
-
33
- function now() {
34
- return Date.now();
35
- }
36
-
37
- function parseRetryAfterMs(value: string | undefined): number | undefined {
38
- if (!value) return undefined;
39
- const seconds = Number(value);
40
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
41
- const timestamp = Date.parse(value);
42
- if (Number.isFinite(timestamp)) return Math.max(0, timestamp - now());
43
- return undefined;
44
- }
45
-
46
- function isMeaningfulEvent(event: AssistantMessageEvent): boolean {
47
- switch (event.type) {
48
- case "text_delta":
49
- case "thinking_delta":
50
- case "toolcall_delta":
51
- return event.delta.length > 0;
52
- case "text_end":
53
- case "thinking_end":
54
- return event.content.length > 0;
55
- case "toolcall_end":
56
- return true;
57
- case "done":
58
- return event.message.content.length > 0;
59
- default:
60
- return false;
61
- }
62
- }
63
-
64
- function createErrorMessage({ model, message, stopReason = "error" }: RouterErrorMessageInput): AssistantMessage {
65
- return {
66
- role: "assistant",
67
- content: [],
68
- api: model.api,
69
- provider: model.provider,
70
- model: model.id,
71
- usage: {
72
- input: 0,
73
- output: 0,
74
- cacheRead: 0,
75
- cacheWrite: 0,
76
- totalTokens: 0,
77
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
78
- },
79
- stopReason,
80
- errorMessage: message,
81
- timestamp: Date.now(),
82
- };
83
- }
84
-
85
- function classifyFailure(
86
- status: number | undefined,
87
- headers: Record<string, string>,
88
- message: string,
89
- config: RouterConfig,
90
- ): FailureClassification {
91
- const retryAfterMs = parseRetryAfterMs(headers["retry-after"]) ?? config.rateLimitCooldownMs;
92
- const lower = message.toLowerCase();
93
-
94
- if (status === 429 || lower.includes("rate limit") || lower.includes("too many requests")) {
95
- return { kind: "rate-limit", status: status ?? 429, retryAfterMs, message };
96
- }
97
-
98
- if (status === 401 || status === 403 || lower.includes("unauthorized") || lower.includes("forbidden")) {
99
- return { kind: "auth", status: status ?? 401, message };
100
- }
101
-
102
- if (
103
- (status !== undefined && status >= 500) ||
104
- lower.includes("network") ||
105
- lower.includes("fetch failed") ||
106
- lower.includes("connection") ||
107
- lower.includes("timeout")
108
- ) {
109
- return { kind: "transient", status: status ?? 500, message };
110
- }
111
-
112
- return { kind: "fatal", status, message };
113
- }
114
-
115
- function delegateStream(
116
- upstream: RouterUpstreamConfig,
117
- model: Model<Api>,
118
- context: Context,
119
- options: SimpleStreamOptions | undefined,
120
- ): AssistantMessageEventStream {
121
- switch (upstream.api) {
122
- case "openai-completions":
123
- return streamSimpleOpenAICompletions(model as Model<"openai-completions">, context, options);
124
- case "openai-responses":
125
- return streamSimpleOpenAIResponses(model as Model<"openai-responses">, context, options);
126
- case "openai-codex-responses":
127
- return streamSimpleOpenAICodexResponses(model as Model<"openai-codex-responses">, context, options);
128
- default:
129
- throw new Error(`Unsupported upstream api: ${upstream.api}`);
130
- }
131
- }
132
-
133
- export class RouterRuntime {
134
- private config: RouterConfig;
135
- private accounts: RouterAccountStore;
136
- private state: RouterStateStore;
137
-
138
- constructor() {
139
- this.config = loadRouterConfig();
140
- this.accounts = new RouterAccountStore();
141
- this.state = new RouterStateStore(this.config.policy);
142
- this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
143
- }
144
-
145
- reloadConfig() {
146
- this.config = loadRouterConfig();
147
- this.accounts.reload();
148
- this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
149
- }
150
-
151
- getConfig(): RouterConfig {
152
- this.reloadConfig();
153
- return this.config;
154
- }
155
-
156
- listUpstreams(): RouterUpstreamConfig[] {
157
- return this.getConfig().upstreams;
158
- }
159
-
160
- listAccounts(): StoredRouterAccount[] {
161
- this.reloadConfig();
162
- return this.accounts.list();
163
- }
164
-
165
- getAccount(id: string): StoredRouterAccount | undefined {
166
- this.reloadConfig();
167
- return this.accounts.get(id);
168
- }
169
-
170
- addAccount(account: StoredRouterAccount) {
171
- this.accounts.add(account);
172
- this.state.ensureAccount(account.id);
173
- }
174
-
175
- updateAccount(account: StoredRouterAccount) {
176
- this.accounts.update(account);
177
- this.state.ensureAccount(account.id);
178
- }
179
-
180
- removeAccount(id: string) {
181
- this.accounts.remove(id);
182
- this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
183
- }
184
-
185
- setEnabled(id: string, enabled: boolean) {
186
- this.accounts.setEnabled(id, enabled);
187
- if (enabled) this.state.clearHealth(id);
188
- }
189
-
190
- setWeight(id: string, weight: number) {
191
- this.accounts.setWeight(id, weight);
192
- }
193
-
194
- setPolicy(policy: RoutingPolicyName) {
195
- this.state.setPolicy(policy);
196
- }
197
-
198
- getPolicy(): RoutingPolicyName {
199
- return this.state.getPolicy(this.config.policy);
200
- }
201
-
202
- async refreshAccount(id: string): Promise<StoredRouterAccount> {
203
- const account = this.accounts.get(id);
204
- if (!account) throw new Error(`Unknown account: ${id}`);
205
- const refreshed = await refreshAccountCredentials(account);
206
- this.accounts.update(refreshed);
207
- this.state.clearHealth(id);
208
- return refreshed;
209
- }
210
-
211
- getProviderModels() {
212
- return this.getConfig().models.map((model) => ({
213
- id: model.id,
214
- name: model.name,
215
- reasoning: model.reasoning,
216
- input: model.input,
217
- cost: model.cost,
218
- contextWindow: model.contextWindow,
219
- maxTokens: model.maxTokens,
220
- compat: model.compat,
221
- }));
222
- }
223
-
224
- getStatusRows(): RouterStatusRow[] {
225
- this.reloadConfig();
226
- return this.accounts.list().map((account) => {
227
- const state = this.state.ensureAccount(account.id);
228
- return {
229
- id: account.id,
230
- label: account.label,
231
- upstream: account.upstreamId,
232
- provider: account.provider,
233
- enabled: account.enabled,
234
- weight: account.weight,
235
- authHealth: state.authHealth,
236
- cooldownUntil: state.cooldownUntil,
237
- penaltyUntil: state.penaltyUntil,
238
- lastUsedAt: state.lastUsedAt,
239
- lastStatus: state.lastStatus,
240
- failures: state.failures,
241
- rateLimitCount: state.rateLimitCount,
242
- authFailureCount: state.authFailureCount,
243
- successCount: state.successCount,
244
- expires: account.expires,
245
- };
246
- });
247
- }
248
-
249
- private getModelConfig(modelId: string): RouterModelConfig {
250
- const model = this.config.models.find((entry) => entry.id === modelId);
251
- if (!model) throw new Error(`Model is not configured for oauth-router: ${modelId}`);
252
- return model;
253
- }
254
-
255
- private getUpstream(upstreamId: string): RouterUpstreamConfig | undefined {
256
- return this.config.upstreams.find((upstream) => upstream.id === upstreamId);
257
- }
258
-
259
- private getEligibleAccounts(modelId: string, excludedIds: Set<string>): EligibleAccount[] {
260
- const modelConfig = this.getModelConfig(modelId);
261
- const allowedUpstreams = new Set(modelConfig.route?.upstreamIds ?? []);
262
- const hasRouteFilter = allowedUpstreams.size > 0;
263
- const currentTime = now();
264
-
265
- return this.accounts
266
- .list()
267
- .filter((account) => !excludedIds.has(account.id))
268
- .map((account) => ({ account, upstream: this.getUpstream(account.upstreamId), state: this.state.ensureAccount(account.id) }))
269
- .filter((entry): entry is { account: StoredRouterAccount; upstream: RouterUpstreamConfig; state: ReturnType<RouterStateStore["ensureAccount"]> } => Boolean(entry.upstream))
270
- .filter(({ account, upstream, state }) => {
271
- if (!account.enabled) return false;
272
- if (!upstream.enabled) return false;
273
- if (!upstream.modelIds.includes(modelId)) return false;
274
- if (hasRouteFilter && !allowedUpstreams.has(upstream.id)) return false;
275
- if (state.authHealth === "invalid") return false;
276
- if (state.cooldownUntil && state.cooldownUntil > currentTime) return false;
277
- if (state.penaltyUntil && state.penaltyUntil > currentTime) return false;
278
- return true;
279
- })
280
- .map(({ account, upstream, state }) => ({ account, upstream, modelConfig, state }));
281
- }
282
-
283
- private async prepareSelection(
284
- eligible: EligibleAccount,
285
- model: Model<Api>,
286
- options?: SimpleStreamOptions,
287
- ): Promise<DelegateSelection> {
288
- let account = eligible.account;
289
-
290
- if (account.provider !== "api-key" && account.expires - this.config.tokenRefreshSkewMs <= now()) {
291
- account = await refreshAccountCredentials(account);
292
- this.accounts.update(account);
293
- this.state.clearHealth(account.id);
294
- }
295
-
296
- const apiKey = await getApiKeyForAccount(account);
297
- const headers = { ...(eligible.upstream.headers ?? {}) };
298
- const delegatedModel = {
299
- ...model,
300
- id: eligible.modelConfig.id,
301
- name: eligible.modelConfig.name,
302
- reasoning: eligible.modelConfig.reasoning,
303
- input: eligible.modelConfig.input,
304
- cost: eligible.modelConfig.cost,
305
- contextWindow: eligible.modelConfig.contextWindow,
306
- maxTokens: eligible.modelConfig.maxTokens,
307
- compat: eligible.modelConfig.compat,
308
- api: eligible.upstream.api,
309
- baseUrl: eligible.upstream.baseUrl,
310
- headers,
311
- } as Model<Api>;
312
-
313
- return {
314
- account,
315
- upstream: eligible.upstream,
316
- modelConfig: eligible.modelConfig,
317
- apiKey,
318
- headers,
319
- delegatedModel,
320
- };
321
- }
322
-
323
- private markFailure(accountId: string, failure: FailureClassification) {
324
- switch (failure.kind) {
325
- case "rate-limit":
326
- this.state.markRateLimit(accountId, failure.retryAfterMs ?? this.config.rateLimitCooldownMs, failure.status, failure.message);
327
- break;
328
- case "auth":
329
- this.state.markAuthFailure(accountId, failure.status, failure.message);
330
- break;
331
- case "transient":
332
- this.state.markTransientFailure(
333
- accountId,
334
- this.config.transientPenaltyMs,
335
- failure.status,
336
- failure.message,
337
- );
338
- break;
339
- case "fatal":
340
- this.state.markTransientFailure(accountId, this.config.transientPenaltyMs, failure.status ?? 500, failure.message);
341
- break;
342
- }
343
- }
344
-
345
- private async trySingleAccount(
346
- selection: DelegateSelection,
347
- outerStream: ReturnType<typeof createAssistantMessageEventStream>,
348
- context: Context,
349
- options?: SimpleStreamOptions,
350
- ): Promise<{ completed: boolean; emittedMeaningfulOutput: boolean; failure?: FailureClassification }> {
351
- let responseStatus: number | undefined;
352
- let responseHeaders: Record<string, string> = {};
353
- const buffered: AssistantMessageEvent[] = [];
354
- let emittedMeaningfulOutput = false;
355
-
356
- const streamOptions: SimpleStreamOptions = {
357
- ...options,
358
- apiKey: selection.apiKey,
359
- headers: {
360
- ...(options?.headers ?? {}),
361
- ...selection.headers,
362
- },
363
- onResponse: async (response, responseModel) => {
364
- responseStatus = response.status;
365
- responseHeaders = response.headers;
366
- await options?.onResponse?.(response, responseModel);
367
- },
368
- onPayload: options?.onPayload,
369
- };
370
-
371
- const inner = delegateStream(selection.upstream, selection.delegatedModel, context, streamOptions);
372
-
373
- for await (const event of inner) {
374
- if (event.type === "error") {
375
- const failure = classifyFailure(responseStatus, responseHeaders, event.error.errorMessage || "Upstream request failed", this.config);
376
- this.markFailure(selection.account.id, failure);
377
-
378
- if (!emittedMeaningfulOutput) {
379
- return { completed: false, emittedMeaningfulOutput: false, failure };
380
- }
381
-
382
- outerStream.push(event);
383
- return { completed: false, emittedMeaningfulOutput: true, failure };
384
- }
385
-
386
- if (!emittedMeaningfulOutput && isMeaningfulEvent(event)) {
387
- for (const pending of buffered) outerStream.push(pending);
388
- buffered.length = 0;
389
- emittedMeaningfulOutput = true;
390
- }
391
-
392
- if (emittedMeaningfulOutput) {
393
- outerStream.push(event);
394
- } else {
395
- buffered.push(event);
396
- }
397
-
398
- if (event.type === "done") {
399
- if (!emittedMeaningfulOutput) {
400
- for (const pending of buffered) outerStream.push(pending);
401
- buffered.length = 0;
402
- }
403
- this.state.markSuccess(selection.account.id, responseStatus ?? 200);
404
- return { completed: true, emittedMeaningfulOutput };
405
- }
406
- }
407
-
408
- return {
409
- completed: false,
410
- emittedMeaningfulOutput,
411
- failure: {
412
- kind: "transient",
413
- status: responseStatus,
414
- message: "Upstream stream ended unexpectedly",
415
- },
416
- };
417
- }
418
-
419
- stream(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
420
- this.reloadConfig();
421
- const outer = createAssistantMessageEventStream();
422
-
423
- (async () => {
424
- const tried = new Set<string>();
425
- let lastFailure: FailureClassification | undefined;
426
- let emittedMeaningfulOutput = false;
427
-
428
- try {
429
- while (true) {
430
- const eligible = this.getEligibleAccounts(model.id, tried);
431
- const policy = this.getPolicy();
432
- const cursor = this.state.getCursor(policy);
433
- const picked = chooseEligibleAccount(policy, eligible, cursor);
434
-
435
- if (!picked.selected) {
436
- const message = lastFailure
437
- ? `No healthy oauth-router accounts remaining after failover. Last error: ${lastFailure.message}`
438
- : `No healthy oauth-router accounts are configured for model ${model.id}. Add accounts with /router-login add.`;
439
- outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
440
- outer.end();
441
- return;
442
- }
443
-
444
- this.state.advanceCursor(policy, picked.nextCursor);
445
- tried.add(picked.selected.account.id);
446
- this.state.markAttempt(picked.selected.account.id, model.id);
447
-
448
- let selection: DelegateSelection;
449
- try {
450
- selection = await this.prepareSelection(picked.selected, model, options);
451
- } catch (error) {
452
- const message = error instanceof Error ? error.message : String(error);
453
- lastFailure = { kind: "auth", status: 401, message };
454
- this.markFailure(picked.selected.account.id, lastFailure);
455
- continue;
456
- }
457
-
458
- const result = await this.trySingleAccount(selection, outer, context, options);
459
- if (result.completed) {
460
- outer.end();
461
- return;
462
- }
463
-
464
- emittedMeaningfulOutput ||= result.emittedMeaningfulOutput;
465
- lastFailure = result.failure;
466
-
467
- if (emittedMeaningfulOutput) {
468
- outer.end();
469
- return;
470
- }
471
- }
472
- } catch (error) {
473
- const message = error instanceof Error ? error.message : String(error);
474
- outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
475
- outer.end();
476
- }
477
- })();
478
-
479
- return outer;
480
- }
481
- }
482
-
483
- export function registerRouterProvider(pi: ExtensionAPI, runtime: RouterRuntime) {
484
- const config = runtime.getConfig();
485
- pi.registerProvider(config.providerName, {
486
- baseUrl: "https://oauth-router.local",
487
- apiKey: "OAUTH_ROUTER_DISABLED",
488
- api: "oauth-router-api",
489
- models: runtime.getProviderModels(),
490
- streamSimple: (model, context, options) => runtime.stream(model, context, options),
491
- });
492
- }
1
+ import {
2
+ createAssistantMessageEventStream,
3
+ type Api,
4
+ type AssistantMessage,
5
+ type AssistantMessageEvent,
6
+ type AssistantMessageEventStream,
7
+ type Context,
8
+ type Model,
9
+ type SimpleStreamOptions,
10
+ streamSimpleOpenAICodexResponses,
11
+ streamSimpleOpenAICompletions,
12
+ streamSimpleOpenAIResponses,
13
+ } from "@mariozechner/pi-ai";
14
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
15
+ import { loadRouterConfig } from "./config.ts";
16
+ import { refreshAccountCredentials, getApiKeyForAccount } from "./oauth-flow.ts";
17
+ import { RouterAccountStore } from "./oauth-store.ts";
18
+ import { chooseEligibleAccount } from "./policies.ts";
19
+ import { RouterStateStore } from "./state.ts";
20
+ import type {
21
+ DelegateSelection,
22
+ EligibleAccount,
23
+ FailureClassification,
24
+ RouterConfig,
25
+ RouterErrorMessageInput,
26
+ RouterModelConfig,
27
+ RouterStatusRow,
28
+ RouterUpstreamConfig,
29
+ RoutingPolicyName,
30
+ StoredRouterAccount,
31
+ } from "./types.ts";
32
+
33
+ function now() {
34
+ return Date.now();
35
+ }
36
+
37
+ function parseRetryAfterMs(value: string | undefined): number | undefined {
38
+ if (!value) return undefined;
39
+ const seconds = Number(value);
40
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
41
+ const timestamp = Date.parse(value);
42
+ if (Number.isFinite(timestamp)) return Math.max(0, timestamp - now());
43
+ return undefined;
44
+ }
45
+
46
+ function isMeaningfulEvent(event: AssistantMessageEvent): boolean {
47
+ switch (event.type) {
48
+ case "text_delta":
49
+ case "thinking_delta":
50
+ case "toolcall_delta":
51
+ return event.delta.length > 0;
52
+ case "text_end":
53
+ case "thinking_end":
54
+ return event.content.length > 0;
55
+ case "toolcall_end":
56
+ return true;
57
+ case "done":
58
+ return event.message.content.length > 0;
59
+ default:
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function createErrorMessage({ model, message, stopReason = "error" }: RouterErrorMessageInput): AssistantMessage {
65
+ return {
66
+ role: "assistant",
67
+ content: [],
68
+ api: model.api,
69
+ provider: model.provider,
70
+ model: model.id,
71
+ usage: {
72
+ input: 0,
73
+ output: 0,
74
+ cacheRead: 0,
75
+ cacheWrite: 0,
76
+ totalTokens: 0,
77
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
78
+ },
79
+ stopReason,
80
+ errorMessage: message,
81
+ timestamp: Date.now(),
82
+ };
83
+ }
84
+
85
+ function classifyFailure(
86
+ status: number | undefined,
87
+ headers: Record<string, string>,
88
+ message: string,
89
+ config: RouterConfig,
90
+ ): FailureClassification {
91
+ const retryAfterMs = parseRetryAfterMs(headers["retry-after"]) ?? config.rateLimitCooldownMs;
92
+ const lower = message.toLowerCase();
93
+
94
+ if (status === 429 || lower.includes("rate limit") || lower.includes("too many requests")) {
95
+ return { kind: "rate-limit", status: status ?? 429, retryAfterMs, message };
96
+ }
97
+
98
+ if (status === 401 || status === 403 || lower.includes("unauthorized") || lower.includes("forbidden")) {
99
+ return { kind: "auth", status: status ?? 401, message };
100
+ }
101
+
102
+ if (
103
+ (status !== undefined && status >= 500) ||
104
+ lower.includes("network") ||
105
+ lower.includes("fetch failed") ||
106
+ lower.includes("connection") ||
107
+ lower.includes("timeout")
108
+ ) {
109
+ return { kind: "transient", status: status ?? 500, message };
110
+ }
111
+
112
+ return { kind: "fatal", status, message };
113
+ }
114
+
115
+ function delegateStream(
116
+ upstream: RouterUpstreamConfig,
117
+ model: Model<Api>,
118
+ context: Context,
119
+ options: SimpleStreamOptions | undefined,
120
+ ): AssistantMessageEventStream {
121
+ switch (upstream.api) {
122
+ case "openai-completions":
123
+ return streamSimpleOpenAICompletions(model as Model<"openai-completions">, context, options);
124
+ case "openai-responses":
125
+ return streamSimpleOpenAIResponses(model as Model<"openai-responses">, context, options);
126
+ case "openai-codex-responses":
127
+ return streamSimpleOpenAICodexResponses(model as Model<"openai-codex-responses">, context, options);
128
+ default:
129
+ throw new Error(`Unsupported upstream api: ${upstream.api}`);
130
+ }
131
+ }
132
+
133
+ export class RouterRuntime {
134
+ private config: RouterConfig;
135
+ private accounts: RouterAccountStore;
136
+ private state: RouterStateStore;
137
+
138
+ constructor() {
139
+ this.config = loadRouterConfig();
140
+ this.accounts = new RouterAccountStore();
141
+ this.state = new RouterStateStore(this.config.policy);
142
+ this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
143
+ }
144
+
145
+ reloadConfig() {
146
+ this.config = loadRouterConfig();
147
+ this.accounts.reload();
148
+ this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
149
+ }
150
+
151
+ getConfig(): RouterConfig {
152
+ this.reloadConfig();
153
+ return this.config;
154
+ }
155
+
156
+ listUpstreams(): RouterUpstreamConfig[] {
157
+ return this.getConfig().upstreams;
158
+ }
159
+
160
+ listAccounts(): StoredRouterAccount[] {
161
+ this.reloadConfig();
162
+ return this.accounts.list();
163
+ }
164
+
165
+ getAccount(id: string): StoredRouterAccount | undefined {
166
+ this.reloadConfig();
167
+ return this.accounts.get(id);
168
+ }
169
+
170
+ addAccount(account: StoredRouterAccount) {
171
+ this.accounts.add(account);
172
+ this.state.ensureAccount(account.id);
173
+ }
174
+
175
+ updateAccount(account: StoredRouterAccount) {
176
+ this.accounts.update(account);
177
+ this.state.ensureAccount(account.id);
178
+ }
179
+
180
+ removeAccount(id: string) {
181
+ this.accounts.remove(id);
182
+ this.state.pruneAccountIds(this.accounts.list().map((account) => account.id));
183
+ }
184
+
185
+ setEnabled(id: string, enabled: boolean) {
186
+ this.accounts.setEnabled(id, enabled);
187
+ if (enabled) this.state.clearHealth(id);
188
+ }
189
+
190
+ setWeight(id: string, weight: number) {
191
+ this.accounts.setWeight(id, weight);
192
+ }
193
+
194
+ setPolicy(policy: RoutingPolicyName) {
195
+ this.state.setPolicy(policy);
196
+ }
197
+
198
+ getPolicy(): RoutingPolicyName {
199
+ return this.state.getPolicy(this.config.policy);
200
+ }
201
+
202
+ async refreshAccount(id: string): Promise<StoredRouterAccount> {
203
+ const account = this.accounts.get(id);
204
+ if (!account) throw new Error(`Unknown account: ${id}`);
205
+ const refreshed = await refreshAccountCredentials(account);
206
+ this.accounts.update(refreshed);
207
+ this.state.clearHealth(id);
208
+ return refreshed;
209
+ }
210
+
211
+ getProviderModels() {
212
+ return this.getConfig().models.map((model) => ({
213
+ id: model.id,
214
+ name: model.name,
215
+ reasoning: model.reasoning,
216
+ input: model.input,
217
+ cost: model.cost,
218
+ contextWindow: model.contextWindow,
219
+ maxTokens: model.maxTokens,
220
+ compat: model.compat,
221
+ }));
222
+ }
223
+
224
+ getStatusRows(): RouterStatusRow[] {
225
+ this.reloadConfig();
226
+ return this.accounts.list().map((account) => {
227
+ const state = this.state.ensureAccount(account.id);
228
+ return {
229
+ id: account.id,
230
+ label: account.label,
231
+ upstream: account.upstreamId,
232
+ provider: account.provider,
233
+ enabled: account.enabled,
234
+ weight: account.weight,
235
+ authHealth: state.authHealth,
236
+ cooldownUntil: state.cooldownUntil,
237
+ penaltyUntil: state.penaltyUntil,
238
+ lastUsedAt: state.lastUsedAt,
239
+ lastStatus: state.lastStatus,
240
+ failures: state.failures,
241
+ rateLimitCount: state.rateLimitCount,
242
+ authFailureCount: state.authFailureCount,
243
+ successCount: state.successCount,
244
+ expires: account.expires,
245
+ };
246
+ });
247
+ }
248
+
249
+ private getModelConfig(modelId: string): RouterModelConfig {
250
+ const model = this.config.models.find((entry) => entry.id === modelId);
251
+ if (!model) throw new Error(`Model is not configured for oauth-router: ${modelId}`);
252
+ return model;
253
+ }
254
+
255
+ private getUpstream(upstreamId: string): RouterUpstreamConfig | undefined {
256
+ return this.config.upstreams.find((upstream) => upstream.id === upstreamId);
257
+ }
258
+
259
+ private getEligibleAccounts(modelId: string, excludedIds: Set<string>): EligibleAccount[] {
260
+ const modelConfig = this.getModelConfig(modelId);
261
+ const allowedUpstreams = new Set(modelConfig.route?.upstreamIds ?? []);
262
+ const hasRouteFilter = allowedUpstreams.size > 0;
263
+ const currentTime = now();
264
+
265
+ return this.accounts
266
+ .list()
267
+ .filter((account) => !excludedIds.has(account.id))
268
+ .map((account) => ({ account, upstream: this.getUpstream(account.upstreamId), state: this.state.ensureAccount(account.id) }))
269
+ .filter((entry): entry is { account: StoredRouterAccount; upstream: RouterUpstreamConfig; state: ReturnType<RouterStateStore["ensureAccount"]> } => Boolean(entry.upstream))
270
+ .filter(({ account, upstream, state }) => {
271
+ if (!account.enabled) return false;
272
+ if (!upstream.enabled) return false;
273
+ if (!upstream.modelIds.includes(modelId)) return false;
274
+ if (hasRouteFilter && !allowedUpstreams.has(upstream.id)) return false;
275
+ if (state.authHealth === "invalid") return false;
276
+ if (state.cooldownUntil && state.cooldownUntil > currentTime) return false;
277
+ if (state.penaltyUntil && state.penaltyUntil > currentTime) return false;
278
+ return true;
279
+ })
280
+ .map(({ account, upstream, state }) => ({ account, upstream, modelConfig, state }));
281
+ }
282
+
283
+ private async prepareSelection(
284
+ eligible: EligibleAccount,
285
+ model: Model<Api>,
286
+ options?: SimpleStreamOptions,
287
+ ): Promise<DelegateSelection> {
288
+ let account = eligible.account;
289
+
290
+ if (account.provider !== "api-key" && account.expires - this.config.tokenRefreshSkewMs <= now()) {
291
+ account = await refreshAccountCredentials(account);
292
+ this.accounts.update(account);
293
+ this.state.clearHealth(account.id);
294
+ }
295
+
296
+ const apiKey = await getApiKeyForAccount(account);
297
+ const headers = { ...(eligible.upstream.headers ?? {}) };
298
+ const delegatedModel = {
299
+ ...model,
300
+ id: eligible.modelConfig.id,
301
+ name: eligible.modelConfig.name,
302
+ reasoning: eligible.modelConfig.reasoning,
303
+ input: eligible.modelConfig.input,
304
+ cost: eligible.modelConfig.cost,
305
+ contextWindow: eligible.modelConfig.contextWindow,
306
+ maxTokens: eligible.modelConfig.maxTokens,
307
+ compat: eligible.modelConfig.compat,
308
+ api: eligible.upstream.api,
309
+ baseUrl: eligible.upstream.baseUrl,
310
+ headers,
311
+ } as Model<Api>;
312
+
313
+ return {
314
+ account,
315
+ upstream: eligible.upstream,
316
+ modelConfig: eligible.modelConfig,
317
+ apiKey,
318
+ headers,
319
+ delegatedModel,
320
+ };
321
+ }
322
+
323
+ private markFailure(accountId: string, failure: FailureClassification) {
324
+ switch (failure.kind) {
325
+ case "rate-limit":
326
+ this.state.markRateLimit(accountId, failure.retryAfterMs ?? this.config.rateLimitCooldownMs, failure.status, failure.message);
327
+ break;
328
+ case "auth":
329
+ this.state.markAuthFailure(accountId, failure.status, failure.message);
330
+ break;
331
+ case "transient":
332
+ this.state.markTransientFailure(
333
+ accountId,
334
+ this.config.transientPenaltyMs,
335
+ failure.status,
336
+ failure.message,
337
+ );
338
+ break;
339
+ case "fatal":
340
+ this.state.markTransientFailure(accountId, this.config.transientPenaltyMs, failure.status ?? 500, failure.message);
341
+ break;
342
+ }
343
+ }
344
+
345
+ private async trySingleAccount(
346
+ selection: DelegateSelection,
347
+ outerStream: ReturnType<typeof createAssistantMessageEventStream>,
348
+ context: Context,
349
+ options?: SimpleStreamOptions,
350
+ ): Promise<{ completed: boolean; emittedMeaningfulOutput: boolean; failure?: FailureClassification }> {
351
+ let responseStatus: number | undefined;
352
+ let responseHeaders: Record<string, string> = {};
353
+ const buffered: AssistantMessageEvent[] = [];
354
+ let emittedMeaningfulOutput = false;
355
+
356
+ const streamOptions: SimpleStreamOptions = {
357
+ ...options,
358
+ apiKey: selection.apiKey,
359
+ headers: {
360
+ ...(options?.headers ?? {}),
361
+ ...selection.headers,
362
+ },
363
+ onResponse: async (response, responseModel) => {
364
+ responseStatus = response.status;
365
+ responseHeaders = response.headers;
366
+ await options?.onResponse?.(response, responseModel);
367
+ },
368
+ onPayload: options?.onPayload,
369
+ };
370
+
371
+ const inner = delegateStream(selection.upstream, selection.delegatedModel, context, streamOptions);
372
+
373
+ for await (const event of inner) {
374
+ if (event.type === "error") {
375
+ const failure = classifyFailure(responseStatus, responseHeaders, event.error.errorMessage || "Upstream request failed", this.config);
376
+ this.markFailure(selection.account.id, failure);
377
+
378
+ if (!emittedMeaningfulOutput) {
379
+ return { completed: false, emittedMeaningfulOutput: false, failure };
380
+ }
381
+
382
+ outerStream.push(event);
383
+ return { completed: false, emittedMeaningfulOutput: true, failure };
384
+ }
385
+
386
+ if (!emittedMeaningfulOutput && isMeaningfulEvent(event)) {
387
+ for (const pending of buffered) outerStream.push(pending);
388
+ buffered.length = 0;
389
+ emittedMeaningfulOutput = true;
390
+ }
391
+
392
+ if (emittedMeaningfulOutput) {
393
+ outerStream.push(event);
394
+ } else {
395
+ buffered.push(event);
396
+ }
397
+
398
+ if (event.type === "done") {
399
+ if (!emittedMeaningfulOutput) {
400
+ for (const pending of buffered) outerStream.push(pending);
401
+ buffered.length = 0;
402
+ }
403
+ this.state.markSuccess(selection.account.id, responseStatus ?? 200);
404
+ return { completed: true, emittedMeaningfulOutput };
405
+ }
406
+ }
407
+
408
+ return {
409
+ completed: false,
410
+ emittedMeaningfulOutput,
411
+ failure: {
412
+ kind: "transient",
413
+ status: responseStatus,
414
+ message: "Upstream stream ended unexpectedly",
415
+ },
416
+ };
417
+ }
418
+
419
+ stream(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
420
+ this.reloadConfig();
421
+ const outer = createAssistantMessageEventStream();
422
+
423
+ (async () => {
424
+ const tried = new Set<string>();
425
+ let lastFailure: FailureClassification | undefined;
426
+ let emittedMeaningfulOutput = false;
427
+
428
+ try {
429
+ while (true) {
430
+ const eligible = this.getEligibleAccounts(model.id, tried);
431
+ const policy = this.getPolicy();
432
+ const cursor = this.state.getCursor(policy);
433
+ const picked = chooseEligibleAccount(policy, eligible, cursor);
434
+
435
+ if (!picked.selected) {
436
+ const message = lastFailure
437
+ ? `No healthy oauth-router accounts remaining after failover. Last error: ${lastFailure.message}`
438
+ : `No healthy oauth-router accounts are configured for model ${model.id}. Add accounts with /router-login add.`;
439
+ outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
440
+ outer.end();
441
+ return;
442
+ }
443
+
444
+ this.state.advanceCursor(policy, picked.nextCursor);
445
+ tried.add(picked.selected.account.id);
446
+ this.state.markAttempt(picked.selected.account.id, model.id);
447
+
448
+ let selection: DelegateSelection;
449
+ try {
450
+ selection = await this.prepareSelection(picked.selected, model, options);
451
+ } catch (error) {
452
+ const message = error instanceof Error ? error.message : String(error);
453
+ lastFailure = { kind: "auth", status: 401, message };
454
+ this.markFailure(picked.selected.account.id, lastFailure);
455
+ continue;
456
+ }
457
+
458
+ const result = await this.trySingleAccount(selection, outer, context, options);
459
+ if (result.completed) {
460
+ outer.end();
461
+ return;
462
+ }
463
+
464
+ emittedMeaningfulOutput ||= result.emittedMeaningfulOutput;
465
+ lastFailure = result.failure;
466
+
467
+ if (emittedMeaningfulOutput) {
468
+ outer.end();
469
+ return;
470
+ }
471
+ }
472
+ } catch (error) {
473
+ const message = error instanceof Error ? error.message : String(error);
474
+ outer.push({ type: "error", reason: "error", error: createErrorMessage({ model, message }) });
475
+ outer.end();
476
+ }
477
+ })();
478
+
479
+ return outer;
480
+ }
481
+ }
482
+
483
+ export function registerRouterProvider(pi: ExtensionAPI, runtime: RouterRuntime) {
484
+ const config = runtime.getConfig();
485
+ pi.registerProvider(config.providerName, {
486
+ baseUrl: "https://oauth-router.local",
487
+ apiKey: "OAUTH_ROUTER_DISABLED",
488
+ api: "oauth-router-api",
489
+ models: runtime.getProviderModels(),
490
+ streamSimple: (model, context, options) => runtime.stream(model, context, options),
491
+ });
492
+ }