takomi 2.0.7 → 2.1.1
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/.pi/README.md +124 -0
- package/.pi/agents/architect.md +16 -0
- package/.pi/agents/coder.md +15 -0
- package/.pi/agents/designer.md +18 -0
- package/.pi/agents/orchestrator.md +23 -0
- package/.pi/agents/reviewer.md +17 -0
- package/.pi/extensions/oauth-router/README.md +125 -0
- package/.pi/extensions/oauth-router/commands.ts +380 -0
- package/.pi/extensions/oauth-router/config.ts +200 -0
- package/.pi/extensions/oauth-router/index.ts +41 -0
- package/.pi/extensions/oauth-router/oauth-flow.ts +154 -0
- package/.pi/extensions/oauth-router/oauth-store.ts +121 -0
- package/.pi/extensions/oauth-router/package.json +14 -0
- package/.pi/extensions/oauth-router/policies.ts +27 -0
- package/.pi/extensions/oauth-router/provider.ts +492 -0
- package/.pi/extensions/oauth-router/scripts/vibe-verify.py +98 -0
- package/.pi/extensions/oauth-router/state.ts +174 -0
- package/.pi/extensions/oauth-router/types.ts +153 -0
- package/.pi/extensions/takomi-runtime/command-text.ts +130 -0
- package/.pi/extensions/takomi-runtime/commands.ts +179 -0
- package/.pi/extensions/takomi-runtime/context-panel.ts +282 -0
- package/.pi/extensions/takomi-runtime/index.ts +1288 -0
- package/.pi/extensions/takomi-runtime/profile.ts +114 -0
- package/.pi/extensions/takomi-runtime/routing-policy.ts +105 -0
- package/.pi/extensions/takomi-runtime/shared.ts +492 -0
- package/.pi/extensions/takomi-runtime/subagent-controller.ts +364 -0
- package/.pi/extensions/takomi-runtime/subagent-render.ts +501 -0
- package/.pi/extensions/takomi-runtime/subagent-types.ts +83 -0
- package/.pi/extensions/takomi-runtime/ui.ts +133 -0
- package/.pi/extensions/takomi-subagents/agent-aliases.ts +18 -0
- package/.pi/extensions/takomi-subagents/agents.ts +113 -0
- package/.pi/extensions/takomi-subagents/delegation-plan.ts +95 -0
- package/.pi/extensions/takomi-subagents/dispatch-helpers.ts +26 -0
- package/.pi/extensions/takomi-subagents/dispatch.ts +215 -0
- package/.pi/extensions/takomi-subagents/index.ts +75 -0
- package/.pi/extensions/takomi-subagents/live-updates.ts +83 -0
- package/.pi/extensions/takomi-subagents/native-render.ts +174 -0
- package/.pi/extensions/takomi-subagents/tool-runner.ts +209 -0
- package/.pi/prompts/build-prompt.md +199 -0
- package/.pi/prompts/design-prompt.md +134 -0
- package/.pi/prompts/genesis-prompt.md +133 -0
- package/.pi/prompts/orch-prompt.md +144 -0
- package/.pi/prompts/prime-prompt.md +80 -0
- package/.pi/prompts/takomi-prompt.md +96 -0
- package/.pi/prompts/vibe-primeAgent.md +97 -0
- package/.pi/prompts/vibe-spawnTask.md +133 -0
- package/.pi/prompts/vibe-syncDocs.md +100 -0
- package/.pi/themes/takomi-noir.json +81 -0
- package/README.md +28 -2
- package/assets/.agent/skills/pr-comment-fix/SKILL.md +182 -0
- package/assets/.agent/skills/takomi/SKILL.md +59 -59
- package/package.json +59 -46
- package/src/cli.js +158 -8
- package/src/doctor.js +84 -0
- package/src/pi-harness.js +351 -0
- package/src/pi-installer.js +171 -0
- package/src/pi-takomi-core/index.ts +4 -0
- package/src/pi-takomi-core/orchestration.ts +402 -0
- package/src/pi-takomi-core/routing.ts +93 -0
- package/src/pi-takomi-core/types.ts +173 -0
- package/src/pi-takomi-core/workflows.ts +299 -0
- package/src/skills-installer.js +101 -0
- package/src/utils.js +479 -447
- package/assets/.agent/skills/skill-creator/scripts/__pycache__/quick_validate.cpython-311.pyc +0 -0
- package/assets/.agent/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-311.pyc +0 -0
- package/assets/.agent/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-311.pyc +0 -0
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
REQUIRED = [
|
|
13
|
+
ROOT / "index.ts",
|
|
14
|
+
ROOT / "provider.ts",
|
|
15
|
+
ROOT / "commands.ts",
|
|
16
|
+
ROOT / "oauth-flow.ts",
|
|
17
|
+
ROOT / "oauth-store.ts",
|
|
18
|
+
ROOT / "state.ts",
|
|
19
|
+
ROOT / "policies.ts",
|
|
20
|
+
ROOT / "config.ts",
|
|
21
|
+
ROOT / "types.ts",
|
|
22
|
+
ROOT / "README.md",
|
|
23
|
+
ROOT / "docs" / "Project_Requirements.md",
|
|
24
|
+
ROOT / "docs" / "Coding_Guidelines.md",
|
|
25
|
+
ROOT / "docs" / "Builder_Prompt.md",
|
|
26
|
+
ROOT / "docs" / "issues" / "FR-001.md",
|
|
27
|
+
]
|
|
28
|
+
EXPECTED_MODELS = ["oauth-router", "gpt-4o", "gpt-4.1", "o4-mini", "gpt-5.4"]
|
|
29
|
+
PI_CANDIDATES = [
|
|
30
|
+
os.environ.get("PI_BIN"),
|
|
31
|
+
shutil.which("pi"),
|
|
32
|
+
shutil.which("pi.cmd"),
|
|
33
|
+
str(Path.home() / "AppData" / "Roaming" / "npm" / "pi"),
|
|
34
|
+
str(Path.home() / "AppData" / "Roaming" / "npm" / "pi.cmd"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_pi() -> str | None:
|
|
39
|
+
for candidate in PI_CANDIDATES:
|
|
40
|
+
if not candidate:
|
|
41
|
+
continue
|
|
42
|
+
path = Path(candidate)
|
|
43
|
+
if path.exists():
|
|
44
|
+
return str(path)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(cmd: list[str], timeout: int = 90) -> tuple[int, str, str]:
|
|
49
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
50
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
parser = argparse.ArgumentParser()
|
|
55
|
+
parser.add_argument("--quick", action="store_true")
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
failed = False
|
|
59
|
+
|
|
60
|
+
for path in REQUIRED:
|
|
61
|
+
if not path.exists():
|
|
62
|
+
print(f"[FAIL] missing: {path}")
|
|
63
|
+
failed = True
|
|
64
|
+
else:
|
|
65
|
+
print(f"[ OK ] found: {path.relative_to(ROOT)}")
|
|
66
|
+
|
|
67
|
+
if args.quick:
|
|
68
|
+
return 1 if failed else 0
|
|
69
|
+
|
|
70
|
+
pi_bin = resolve_pi()
|
|
71
|
+
if not pi_bin:
|
|
72
|
+
print("[FAIL] unable to locate `pi` binary")
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
code, stdout, stderr = run([pi_bin, "--list-models"])
|
|
77
|
+
output = f"{stdout}\n{stderr}"
|
|
78
|
+
if code != 0:
|
|
79
|
+
print("[FAIL] `pi --list-models` failed")
|
|
80
|
+
print(output.strip())
|
|
81
|
+
failed = True
|
|
82
|
+
else:
|
|
83
|
+
print("[ OK ] `pi --list-models` executed")
|
|
84
|
+
for token in EXPECTED_MODELS:
|
|
85
|
+
if token not in output:
|
|
86
|
+
print(f"[FAIL] model token not found in list output: {token}")
|
|
87
|
+
failed = True
|
|
88
|
+
else:
|
|
89
|
+
print(f"[ OK ] model token present: {token}")
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
print(f"[FAIL] unable to execute `pi --list-models`: {exc}")
|
|
92
|
+
failed = True
|
|
93
|
+
|
|
94
|
+
return 1 if failed else 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|