@kevin5251984/guild 0.2.12
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/LICENSE +21 -0
- package/bin/guildd.mjs +20 -0
- package/cordis.yml +24 -0
- package/package.json +52 -0
- package/src/agent-file.ts +125 -0
- package/src/browser.ts +668 -0
- package/src/catalog/default-bots.ts +263 -0
- package/src/catalog/skills.ts +128 -0
- package/src/catalog/subagents.ts +70 -0
- package/src/chat-parts.ts +71 -0
- package/src/cli-args.ts +75 -0
- package/src/cli.ts +60 -0
- package/src/compact.ts +355 -0
- package/src/cordis.d.ts +40 -0
- package/src/db.ts +653 -0
- package/src/generate.ts +673 -0
- package/src/handlers.ts +1623 -0
- package/src/harness.ts +326 -0
- package/src/host-agents.ts +137 -0
- package/src/host-browse.ts +199 -0
- package/src/host-skills.ts +150 -0
- package/src/image-gen.ts +270 -0
- package/src/index.ts +12 -0
- package/src/llm.ts +993 -0
- package/src/mcp.ts +563 -0
- package/src/memory.ts +159 -0
- package/src/mention.ts +176 -0
- package/src/oauth.ts +1474 -0
- package/src/plugins/api.ts +8 -0
- package/src/plugins/chat.ts +31 -0
- package/src/plugins/harness.ts +77 -0
- package/src/plugins/llm.ts +50 -0
- package/src/plugins/mcp.ts +58 -0
- package/src/plugins/memory.ts +42 -0
- package/src/plugins/oauth.ts +47 -0
- package/src/plugins/server.ts +126 -0
- package/src/plugins/store.ts +29 -0
- package/src/plugins/tools.ts +79 -0
- package/src/public/buddy.js +432 -0
- package/src/public/chat.css +3045 -0
- package/src/public/chat.html +5834 -0
- package/src/public/favicon-16.png +0 -0
- package/src/public/favicon-16.svg +10 -0
- package/src/public/favicon-32.png +0 -0
- package/src/public/favicon.ico +0 -0
- package/src/public/favicon.svg +13 -0
- package/src/public/i18n.js +663 -0
- package/src/public/index.html +143 -0
- package/src/public/library.html +678 -0
- package/src/public/mcp-add.html +126 -0
- package/src/public/md.js +332 -0
- package/src/public/rpg/inn-street.jpg +0 -0
- package/src/public/settings.html +795 -0
- package/src/public/skills-add.html +212 -0
- package/src/public/studio.html +1181 -0
- package/src/public/style.css +1678 -0
- package/src/public/subagents-add.html +152 -0
- package/src/router.ts +978 -0
- package/src/send-budget.ts +52 -0
- package/src/server.ts +1 -0
- package/src/skill-import.ts +250 -0
- package/src/slash.ts +15 -0
- package/src/start.ts +103 -0
- package/src/store.ts +1208 -0
- package/src/subagent.ts +355 -0
- package/src/tools.ts +818 -0
- package/src/trajectory.ts +339 -0
- package/src/usage.ts +111 -0
- package/vendor/protocol/package.json +19 -0
- package/vendor/protocol/src/index.ts +159 -0
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,1474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription login + completion, same path as
|
|
3
|
+
* https://github.com/ziyou979/dsh-llm-oauth: `@earendil-works/pi-ai`
|
|
4
|
+
* with a durable CredentialStore so tokens refresh on the request.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
contentText,
|
|
11
|
+
createModels,
|
|
12
|
+
type AssistantMessage,
|
|
13
|
+
type AuthEvent,
|
|
14
|
+
type AuthInteraction,
|
|
15
|
+
type AuthPrompt,
|
|
16
|
+
type Credential,
|
|
17
|
+
type CredentialInfo,
|
|
18
|
+
type CredentialStore,
|
|
19
|
+
type Message,
|
|
20
|
+
type Model,
|
|
21
|
+
type MutableModels,
|
|
22
|
+
type OAuthCredential,
|
|
23
|
+
} from "@earendil-works/pi-ai";
|
|
24
|
+
import {
|
|
25
|
+
emitProgress,
|
|
26
|
+
guildTools,
|
|
27
|
+
roundSignal,
|
|
28
|
+
TOOL_LOOP_EXHAUSTED,
|
|
29
|
+
TOOL_LOOP_WRAP,
|
|
30
|
+
type SkillRef,
|
|
31
|
+
type ToolContext,
|
|
32
|
+
type ToolTrace,
|
|
33
|
+
} from "./tools.ts";
|
|
34
|
+
import { estimateSendTokens, trimSendMessages } from "./send-budget.ts";
|
|
35
|
+
import { runAgentLoop } from "./harness.ts";
|
|
36
|
+
import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
37
|
+
import { defaultDataDir, StoreError } from "./store.ts";
|
|
38
|
+
import {
|
|
39
|
+
addUsage,
|
|
40
|
+
blankUsage,
|
|
41
|
+
fromPiUsage,
|
|
42
|
+
withDuration,
|
|
43
|
+
} from "./usage.ts";
|
|
44
|
+
import type { ChatUsage } from "@guild/protocol";
|
|
45
|
+
|
|
46
|
+
export type ModelEntryLite = { id: string; name: string };
|
|
47
|
+
export type SubscriptionFlow = "device" | "pkce";
|
|
48
|
+
|
|
49
|
+
export type SubscriptionDef = {
|
|
50
|
+
id: string;
|
|
51
|
+
pickerId: string;
|
|
52
|
+
name: string;
|
|
53
|
+
hint: string;
|
|
54
|
+
loginHint?: string;
|
|
55
|
+
flow: SubscriptionFlow;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const SUBSCRIPTIONS: SubscriptionDef[] = [
|
|
59
|
+
{
|
|
60
|
+
id: "xai",
|
|
61
|
+
pickerId: "xai-oauth",
|
|
62
|
+
name: "xAI Grok",
|
|
63
|
+
hint: "SuperGrok / X Premium+ 訂閱。裝置碼登入,不必貼 API key。",
|
|
64
|
+
flow: "device",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "openai-codex",
|
|
68
|
+
pickerId: "openai-codex",
|
|
69
|
+
name: "ChatGPT Codex",
|
|
70
|
+
hint: "ChatGPT Plus/Pro 訂閱。Web 走裝置碼(與 dsh-llm-oauth 相同)。",
|
|
71
|
+
loginHint:
|
|
72
|
+
"先到 ChatGPT → Settings → Apps & connectors,開啟 Codex 的 device code authorization。非正式客戶端有帳號風險。",
|
|
73
|
+
flow: "device",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "anthropic",
|
|
77
|
+
pickerId: "anthropic-oauth",
|
|
78
|
+
name: "Claude Pro/Max",
|
|
79
|
+
hint: "Claude 訂閱。瀏覽器 PKCE,完成後自動回到 Guild。",
|
|
80
|
+
flow: "pkce",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "github-copilot",
|
|
84
|
+
pickerId: "github-copilot",
|
|
85
|
+
name: "GitHub Copilot",
|
|
86
|
+
hint: "GitHub Copilot 訂閱。裝置碼登入 github.com。",
|
|
87
|
+
flow: "device",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "openrouter",
|
|
91
|
+
pickerId: "openrouter-oauth",
|
|
92
|
+
name: "OpenRouter",
|
|
93
|
+
hint: "OpenRouter OAuth 會換成你帳號下的 API key,從點數扣款。",
|
|
94
|
+
flow: "pkce",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "kimi-coding",
|
|
98
|
+
pickerId: "kimi-coding-oauth",
|
|
99
|
+
name: "Kimi Code",
|
|
100
|
+
hint: "Kimi Code 訂閱。裝置碼登入 kimi.com,不必貼 API key。",
|
|
101
|
+
flow: "device",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "radius",
|
|
105
|
+
pickerId: "radius-oauth",
|
|
106
|
+
name: "Pi Radius",
|
|
107
|
+
hint: "Pi 的 Radius 閘道(預設 radius.pi.dev)。裝置碼登入,模型清單登入後從閘道拉取。",
|
|
108
|
+
flow: "device",
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
export const OAUTH_PICKER_IDS = new Set(SUBSCRIPTIONS.map((s) => s.pickerId));
|
|
113
|
+
const CATALOG_IDS = SUBSCRIPTIONS.map((s) => s.id);
|
|
114
|
+
|
|
115
|
+
export function subscriptionById(id: string): SubscriptionDef | undefined {
|
|
116
|
+
return SUBSCRIPTIONS.find((s) => s.id === id);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function subscriptionByPicker(pickerId: string): SubscriptionDef | undefined {
|
|
120
|
+
return SUBSCRIPTIONS.find((s) => s.pickerId === pickerId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function oauthPath(dataDir: string): string {
|
|
124
|
+
return join(dataDir, "oauth.json");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class FileCredentialStore implements CredentialStore {
|
|
128
|
+
private cache: Record<string, Credential> | undefined;
|
|
129
|
+
private readonly chains = new Map<string, Promise<unknown>>();
|
|
130
|
+
|
|
131
|
+
constructor(
|
|
132
|
+
readonly path: string,
|
|
133
|
+
private readonly seedPaths: string[],
|
|
134
|
+
) {}
|
|
135
|
+
|
|
136
|
+
private load(): Record<string, Credential> {
|
|
137
|
+
const cached = this.cache;
|
|
138
|
+
if (cached) {
|
|
139
|
+
const xai = cached.xai;
|
|
140
|
+
if (xai?.type !== "oauth" || Date.now() + 60_000 < xai.expires) {
|
|
141
|
+
return cached;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
this.cache = hydrateAuthFile(this.path, this.seedPaths);
|
|
145
|
+
return this.cache;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private save(next: Record<string, Credential>): void {
|
|
149
|
+
this.cache = next;
|
|
150
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
151
|
+
writeFileSync(this.path, `${JSON.stringify(next, null, 2)}\n`, {
|
|
152
|
+
mode: 0o600,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private enqueue<T>(providerId: string, task: () => Promise<T>): Promise<T> {
|
|
157
|
+
const previous = this.chains.get(providerId) ?? Promise.resolve();
|
|
158
|
+
const run = previous.catch(() => undefined).then(task);
|
|
159
|
+
this.chains.set(providerId, run.then(() => undefined, () => undefined));
|
|
160
|
+
return run;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async read(providerId: string): Promise<Credential | undefined> {
|
|
164
|
+
return this.load()[providerId];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async list(): Promise<readonly CredentialInfo[]> {
|
|
168
|
+
return Object.entries(this.load()).map(([providerId, credential]) => ({
|
|
169
|
+
providerId,
|
|
170
|
+
type: credential.type,
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
modify(
|
|
175
|
+
providerId: string,
|
|
176
|
+
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
|
|
177
|
+
): Promise<Credential | undefined> {
|
|
178
|
+
return this.enqueue(providerId, async () => {
|
|
179
|
+
const file = { ...this.load() };
|
|
180
|
+
const next = await fn(file[providerId]);
|
|
181
|
+
if (next === undefined) return file[providerId];
|
|
182
|
+
file[providerId] = next;
|
|
183
|
+
this.save(file);
|
|
184
|
+
return next;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
delete(providerId: string): Promise<void> {
|
|
189
|
+
return this.enqueue(providerId, async () => {
|
|
190
|
+
const file = { ...this.load() };
|
|
191
|
+
if (file[providerId] === undefined) return;
|
|
192
|
+
delete file[providerId];
|
|
193
|
+
this.save(file);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
peek(providerId: string): Credential | undefined {
|
|
198
|
+
return this.load()[providerId];
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readJsonObject(path: string): Record<string, unknown> {
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
205
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
206
|
+
return parsed as Record<string, unknown>;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
/* missing or invalid */
|
|
210
|
+
}
|
|
211
|
+
return {};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseExpiry(rec: Record<string, unknown>): number {
|
|
215
|
+
for (const n of [rec.expires, rec.expiresAt, rec.expires_at]) {
|
|
216
|
+
if (typeof n === "number" && Number.isFinite(n) && n > 0) {
|
|
217
|
+
return n > 1e12 ? n : n * 1000;
|
|
218
|
+
}
|
|
219
|
+
if (typeof n === "string" && n.trim()) {
|
|
220
|
+
const t = Date.parse(n);
|
|
221
|
+
if (!Number.isNaN(t)) return t;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return Date.now() + 3600_000;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function asCredential(raw: unknown): Credential | undefined {
|
|
228
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
229
|
+
const rec = raw as Record<string, unknown>;
|
|
230
|
+
if (rec.type === "api_key" && typeof rec.key === "string") {
|
|
231
|
+
return rec as Credential;
|
|
232
|
+
}
|
|
233
|
+
if (rec.type === "oauth" && typeof rec.access === "string") {
|
|
234
|
+
const typed = rec as OAuthCredential;
|
|
235
|
+
const expires = parseExpiry(rec);
|
|
236
|
+
if (expires !== typed.expires) {
|
|
237
|
+
return { ...typed, expires };
|
|
238
|
+
}
|
|
239
|
+
return typed;
|
|
240
|
+
}
|
|
241
|
+
const grokCliKey =
|
|
242
|
+
typeof rec.key === "string" &&
|
|
243
|
+
rec.key &&
|
|
244
|
+
(typeof rec.refresh_token === "string" ||
|
|
245
|
+
typeof rec.refresh === "string" ||
|
|
246
|
+
rec.auth_mode === "oidc");
|
|
247
|
+
const access =
|
|
248
|
+
(typeof rec.access === "string" && rec.access) ||
|
|
249
|
+
(typeof rec.accessToken === "string" && rec.accessToken) ||
|
|
250
|
+
(typeof rec.access_token === "string" && rec.access_token) ||
|
|
251
|
+
(grokCliKey ? rec.key : "") ||
|
|
252
|
+
"";
|
|
253
|
+
if (!access) return undefined;
|
|
254
|
+
const refresh =
|
|
255
|
+
(typeof rec.refresh === "string" && rec.refresh) ||
|
|
256
|
+
(typeof rec.refreshToken === "string" && rec.refreshToken) ||
|
|
257
|
+
(typeof rec.refresh_token === "string" && rec.refresh_token) ||
|
|
258
|
+
"";
|
|
259
|
+
const cred: OAuthCredential = {
|
|
260
|
+
type: "oauth",
|
|
261
|
+
access,
|
|
262
|
+
refresh,
|
|
263
|
+
expires: parseExpiry(rec),
|
|
264
|
+
};
|
|
265
|
+
if (typeof rec.accountId === "string") cred.accountId = rec.accountId;
|
|
266
|
+
if (Array.isArray(rec.availableModelIds)) {
|
|
267
|
+
cred.availableModelIds = rec.availableModelIds.filter(
|
|
268
|
+
(item): item is string => typeof item === "string",
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return cred;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function oauthCredentialFromUnknown(raw: unknown): Credential | undefined {
|
|
275
|
+
return asCredential(raw);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function xaiFromGrokAuthFile(
|
|
279
|
+
raw: unknown,
|
|
280
|
+
): OAuthCredential | undefined {
|
|
281
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
282
|
+
const rec = raw as Record<string, unknown>;
|
|
283
|
+
const direct = asCredential(rec);
|
|
284
|
+
if (direct?.type === "oauth") return direct;
|
|
285
|
+
for (const [key, value] of Object.entries(rec)) {
|
|
286
|
+
if (
|
|
287
|
+
!key.includes("auth.x.ai") &&
|
|
288
|
+
!(
|
|
289
|
+
value &&
|
|
290
|
+
typeof value === "object" &&
|
|
291
|
+
!Array.isArray(value) &&
|
|
292
|
+
("refresh_token" in value || "key" in value)
|
|
293
|
+
)
|
|
294
|
+
) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const cred = asCredential(value);
|
|
298
|
+
if (cred?.type === "oauth") return cred;
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function takeBetterOAuth(
|
|
304
|
+
current: Credential | undefined,
|
|
305
|
+
incoming: Credential | undefined,
|
|
306
|
+
): Credential | undefined {
|
|
307
|
+
if (!incoming || incoming.type !== "oauth") return current;
|
|
308
|
+
if (!current || current.type !== "oauth") return incoming;
|
|
309
|
+
const now = Date.now();
|
|
310
|
+
const incomingOk = incoming.expires > now;
|
|
311
|
+
const currentOk = current.expires > now;
|
|
312
|
+
if (incomingOk && !currentOk) return incoming;
|
|
313
|
+
if (incoming.expires > current.expires + 5000) return incoming;
|
|
314
|
+
return current;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function hydrateAuthFile(
|
|
318
|
+
path: string,
|
|
319
|
+
seedPaths: string[],
|
|
320
|
+
): Record<string, Credential> {
|
|
321
|
+
const file: Record<string, Credential> = {};
|
|
322
|
+
const incoming = readJsonObject(path);
|
|
323
|
+
let dirty = false;
|
|
324
|
+
for (const [id, raw] of Object.entries(incoming)) {
|
|
325
|
+
const cred = asCredential(raw);
|
|
326
|
+
if (!cred) continue;
|
|
327
|
+
file[id] = cred;
|
|
328
|
+
if (!(raw && typeof raw === "object" && (raw as { type?: string }).type)) {
|
|
329
|
+
dirty = true;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const seed of seedPaths) {
|
|
333
|
+
const extra = readJsonObject(seed);
|
|
334
|
+
for (const id of CATALOG_IDS) {
|
|
335
|
+
const cred = asCredential(extra[id]);
|
|
336
|
+
if (!cred || cred.type !== "oauth") continue;
|
|
337
|
+
const better = takeBetterOAuth(file[id], cred);
|
|
338
|
+
if (better && better !== file[id]) {
|
|
339
|
+
file[id] = better;
|
|
340
|
+
dirty = true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
const grokXai = xaiFromGrokAuthFile(extra);
|
|
344
|
+
if (grokXai) {
|
|
345
|
+
const better = takeBetterOAuth(file.xai, grokXai);
|
|
346
|
+
if (better && better !== file.xai) {
|
|
347
|
+
file.xai = better;
|
|
348
|
+
dirty = true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (dirty) {
|
|
353
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
354
|
+
writeFileSync(path, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
355
|
+
}
|
|
356
|
+
return file;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const modelsByDir = new Map<string, MutableModels>();
|
|
360
|
+
const storesByDir = new Map<string, FileCredentialStore>();
|
|
361
|
+
let providerCache: ReturnType<typeof builtinProviders> | undefined;
|
|
362
|
+
|
|
363
|
+
/** SuperGrok / X Premium+ is billed on the Grok CLI proxy, not api.x.ai. */
|
|
364
|
+
export const GROK_CLI_PROXY = "https://cli-chat-proxy.grok.com/v1";
|
|
365
|
+
|
|
366
|
+
function grokCliVersion(): string {
|
|
367
|
+
try {
|
|
368
|
+
const raw = JSON.parse(
|
|
369
|
+
readFileSync(join(homedir(), ".grok", "version.json"), "utf8"),
|
|
370
|
+
) as { version?: string };
|
|
371
|
+
if (typeof raw.version === "string" && raw.version.trim()) {
|
|
372
|
+
return raw.version.trim();
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
/* ignore */
|
|
376
|
+
}
|
|
377
|
+
return "1.0.5";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function grokCliHeaders(): Record<string, string> {
|
|
381
|
+
const version = grokCliVersion();
|
|
382
|
+
return {
|
|
383
|
+
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
384
|
+
"x-grok-client-identifier": "grok-shell",
|
|
385
|
+
"x-grok-client-version": version,
|
|
386
|
+
"User-Agent": `xai-grok-cli/${version}`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function withGrokSubscriptionProxy<T extends { id: string }>(
|
|
391
|
+
provider: T,
|
|
392
|
+
): T {
|
|
393
|
+
if (provider.id !== "xai") return provider;
|
|
394
|
+
const stock = provider as T & {
|
|
395
|
+
baseUrl?: string;
|
|
396
|
+
headers?: Record<string, string>;
|
|
397
|
+
getModels?: () => readonly { baseUrl?: string; headers?: Record<string, string> }[];
|
|
398
|
+
};
|
|
399
|
+
const headers = grokCliHeaders();
|
|
400
|
+
const originalGetModels = stock.getModels?.bind(stock);
|
|
401
|
+
return {
|
|
402
|
+
...stock,
|
|
403
|
+
name: "xAI Grok",
|
|
404
|
+
baseUrl: GROK_CLI_PROXY,
|
|
405
|
+
headers: { ...stock.headers, ...headers },
|
|
406
|
+
getModels() {
|
|
407
|
+
const models = originalGetModels ? originalGetModels() : [];
|
|
408
|
+
return models.map((model) => ({
|
|
409
|
+
...model,
|
|
410
|
+
baseUrl: GROK_CLI_PROXY,
|
|
411
|
+
headers: { ...headers, ...(model.headers ?? {}) },
|
|
412
|
+
}));
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function catalogProviders() {
|
|
418
|
+
if (!providerCache) providerCache = builtinProviders();
|
|
419
|
+
return CATALOG_IDS.map((id) => providerCache!.find((p) => p.id === id))
|
|
420
|
+
.filter((p): p is NonNullable<typeof p> => Boolean(p))
|
|
421
|
+
.map((p) => withGrokSubscriptionProxy(p));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function oauthSeedPaths(dataDir: string): string[] {
|
|
425
|
+
if (resolve(dataDir) !== resolve(defaultDataDir())) return [];
|
|
426
|
+
return [
|
|
427
|
+
join(homedir(), ".pi", "agent", "auth.json"),
|
|
428
|
+
join(homedir(), ".dsh", "pi-ai-oauth.json"),
|
|
429
|
+
join(homedir(), ".grok", "auth.json"),
|
|
430
|
+
];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function getStore(dataDir: string): FileCredentialStore {
|
|
434
|
+
const existing = storesByDir.get(dataDir);
|
|
435
|
+
if (existing) return existing;
|
|
436
|
+
const store = new FileCredentialStore(oauthPath(dataDir), oauthSeedPaths(dataDir));
|
|
437
|
+
storesByDir.set(dataDir, store);
|
|
438
|
+
return store;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function piModels(dataDir: string): MutableModels {
|
|
442
|
+
const hit = modelsByDir.get(dataDir);
|
|
443
|
+
if (hit) return hit;
|
|
444
|
+
const models = createModels({ credentials: getStore(dataDir) });
|
|
445
|
+
for (const provider of catalogProviders()) models.setProvider(provider);
|
|
446
|
+
modelsByDir.set(dataDir, models);
|
|
447
|
+
return models;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function copilotAllowedIds(dataDir: string): string[] | undefined {
|
|
451
|
+
const cred = getStore(dataDir).peek("github-copilot");
|
|
452
|
+
if (cred?.type !== "oauth") return undefined;
|
|
453
|
+
if (!Array.isArray(cred.availableModelIds)) return undefined;
|
|
454
|
+
return cred.availableModelIds.filter((item) => typeof item === "string");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function storedModelIds(dataDir: string, id: string): string[] | undefined {
|
|
458
|
+
const cred = getStore(dataDir).peek(id);
|
|
459
|
+
if (cred?.type !== "oauth" || !Array.isArray(cred.availableModelIds)) {
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
return cred.availableModelIds.filter((item) => typeof item === "string");
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function catalogModels(id: string, dataDir: string): ModelEntryLite[] {
|
|
466
|
+
if (id === "github-copilot" && copilotAutoOnly(dataDir)) {
|
|
467
|
+
return [{ id: "auto", name: "Auto" }];
|
|
468
|
+
}
|
|
469
|
+
if (id === "radius") {
|
|
470
|
+
try {
|
|
471
|
+
const live = piModels(dataDir).getModels("radius");
|
|
472
|
+
if (live.length) {
|
|
473
|
+
return live.map((model) => ({
|
|
474
|
+
id: model.id,
|
|
475
|
+
name: model.name || model.id,
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
} catch {
|
|
479
|
+
/* catalog not ready */
|
|
480
|
+
}
|
|
481
|
+
const stored = storedModelIds(dataDir, "radius");
|
|
482
|
+
if (stored?.length) {
|
|
483
|
+
return stored.map((modelId) => ({ id: modelId, name: modelId }));
|
|
484
|
+
}
|
|
485
|
+
if (oauthUsable(dataDir, "radius")) void refreshRadiusCatalog(dataDir);
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
const models = piModels(dataDir).getModels(id);
|
|
490
|
+
const allowed = id === "github-copilot" ? copilotAllowedIds(dataDir) : undefined;
|
|
491
|
+
const kept = allowed ? models.filter((model) => allowed.includes(model.id)) : models;
|
|
492
|
+
if (kept.length) {
|
|
493
|
+
return kept.map((model) => ({
|
|
494
|
+
id: model.id,
|
|
495
|
+
name: model.name || model.id,
|
|
496
|
+
}));
|
|
497
|
+
}
|
|
498
|
+
} catch {
|
|
499
|
+
/* catalog not ready */
|
|
500
|
+
}
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
505
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
506
|
+
? (value as Record<string, unknown>)
|
|
507
|
+
: undefined;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function copilotSkuFromToken(token: string): string | undefined {
|
|
511
|
+
const match = token.match(/(?:^|;)sku=([^;]+)/);
|
|
512
|
+
return match?.[1];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Free / Student Copilot may only use auto model selection. */
|
|
516
|
+
export function isCopilotAutoOnlySku(sku?: string): boolean {
|
|
517
|
+
if (!sku) return false;
|
|
518
|
+
const key = sku.toLowerCase();
|
|
519
|
+
if (/(?:^|_)(?:pro|business|enterprise|max)(?:_|$)/.test(key) && !/free/.test(key)) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
return /free|student|edu/.test(key);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function copilotPeekSku(dataDir: string): string | undefined {
|
|
526
|
+
const cred = getStore(dataDir).peek("github-copilot");
|
|
527
|
+
if (cred?.type !== "oauth") return undefined;
|
|
528
|
+
return copilotSkuFromToken(cred.access);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function copilotAutoOnly(dataDir: string): boolean {
|
|
532
|
+
return isCopilotAutoOnlySku(copilotPeekSku(dataDir));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function copilotSkuAllowsModel(
|
|
536
|
+
item: Record<string, unknown>,
|
|
537
|
+
sku?: string,
|
|
538
|
+
): boolean {
|
|
539
|
+
const billing = asRecord(item.billing);
|
|
540
|
+
const restricted = billing?.restricted_to;
|
|
541
|
+
if (!Array.isArray(restricted) || !restricted.length) return true;
|
|
542
|
+
if (!sku) return true;
|
|
543
|
+
return restricted.includes(sku);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function parseCopilotPickerIds(
|
|
547
|
+
raw: unknown,
|
|
548
|
+
allowPolicyFallback: boolean,
|
|
549
|
+
sku?: string,
|
|
550
|
+
): string[] {
|
|
551
|
+
const data = asRecord(raw)?.data;
|
|
552
|
+
if (!Array.isArray(data)) return [];
|
|
553
|
+
const pickerIds: string[] = [];
|
|
554
|
+
const policyEnabledIds: string[] = [];
|
|
555
|
+
for (const rawItem of data) {
|
|
556
|
+
const item = asRecord(rawItem);
|
|
557
|
+
const id = item?.id;
|
|
558
|
+
if (!item || typeof id !== "string") continue;
|
|
559
|
+
const capabilities = asRecord(item.capabilities);
|
|
560
|
+
const supports = asRecord(capabilities?.supports);
|
|
561
|
+
if (supports?.tool_calls === false) continue;
|
|
562
|
+
if (!copilotSkuAllowsModel(item, sku)) continue;
|
|
563
|
+
const policy = asRecord(item.policy);
|
|
564
|
+
if (item.model_picker_enabled === true && policy?.state !== "disabled") {
|
|
565
|
+
pickerIds.push(id);
|
|
566
|
+
}
|
|
567
|
+
if (policy?.state === "enabled") policyEnabledIds.push(id);
|
|
568
|
+
}
|
|
569
|
+
return pickerIds.length > 0 || !allowPolicyFallback ? pickerIds : policyEnabledIds;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const copilotCatalogAt = new Map<string, number>();
|
|
573
|
+
const COPILOT_CATALOG_TTL_MS = 30_000;
|
|
574
|
+
const COPILOT_API_VERSION = "2026-06-01";
|
|
575
|
+
|
|
576
|
+
/** IDE headers Copilot requires. Missing X-GitHub-Api-Version looks like a named-model 400. */
|
|
577
|
+
export function copilotIdeHeaders(sessionToken?: string): Record<string, string> {
|
|
578
|
+
return {
|
|
579
|
+
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
580
|
+
"Editor-Version": "vscode/1.107.0",
|
|
581
|
+
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
582
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
583
|
+
"X-GitHub-Api-Version": COPILOT_API_VERSION,
|
|
584
|
+
...(sessionToken ? { "Copilot-Session-Token": sessionToken } : {}),
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function copilotAuthHeaders(token: string, sessionToken?: string): Record<string, string> {
|
|
589
|
+
return {
|
|
590
|
+
Accept: "application/json",
|
|
591
|
+
Authorization: `Bearer ${token}`,
|
|
592
|
+
...copilotIdeHeaders(sessionToken),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Refresh github-copilot availableModelIds from Copilot's picker, not the full catalog. */
|
|
597
|
+
export async function refreshCopilotCatalog(dataDir: string): Promise<void> {
|
|
598
|
+
const store = getStore(dataDir);
|
|
599
|
+
const cred = store.peek("github-copilot");
|
|
600
|
+
if (cred?.type !== "oauth") return;
|
|
601
|
+
const prev = copilotCatalogAt.get(dataDir) ?? 0;
|
|
602
|
+
if (
|
|
603
|
+
Date.now() - prev < COPILOT_CATALOG_TTL_MS &&
|
|
604
|
+
Array.isArray(cred.availableModelIds)
|
|
605
|
+
) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
const resolved = await piModels(dataDir).getAuth("github-copilot");
|
|
610
|
+
const token = resolved?.auth.apiKey;
|
|
611
|
+
const baseUrl = (resolved?.auth.baseUrl || "").replace(/\/+$/, "");
|
|
612
|
+
if (!token || !baseUrl) return;
|
|
613
|
+
const response = await fetch(`${baseUrl}/models`, {
|
|
614
|
+
headers: copilotAuthHeaders(token),
|
|
615
|
+
signal: AbortSignal.timeout(5000),
|
|
616
|
+
});
|
|
617
|
+
if (!response.ok) return;
|
|
618
|
+
const ids = parseCopilotPickerIds(
|
|
619
|
+
await response.json(),
|
|
620
|
+
baseUrl.includes("api.individual.githubcopilot.com"),
|
|
621
|
+
copilotSkuFromToken(token),
|
|
622
|
+
);
|
|
623
|
+
await store.modify("github-copilot", async (current) => {
|
|
624
|
+
if (current?.type !== "oauth") return current;
|
|
625
|
+
return { ...current, availableModelIds: ids };
|
|
626
|
+
});
|
|
627
|
+
copilotCatalogAt.set(dataDir, Date.now());
|
|
628
|
+
} catch {
|
|
629
|
+
/* keep the last stored list */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const radiusCatalogAt = new Map<string, number>();
|
|
634
|
+
const RADIUS_CATALOG_TTL_MS = 60_000;
|
|
635
|
+
|
|
636
|
+
async function refreshRadiusCatalog(
|
|
637
|
+
dataDir: string,
|
|
638
|
+
force = false,
|
|
639
|
+
): Promise<void> {
|
|
640
|
+
const store = getStore(dataDir);
|
|
641
|
+
const cred = store.peek("radius");
|
|
642
|
+
if (cred?.type !== "oauth") return;
|
|
643
|
+
const prev = radiusCatalogAt.get(dataDir) ?? 0;
|
|
644
|
+
const live = piModels(dataDir).getModels("radius");
|
|
645
|
+
if (
|
|
646
|
+
!force &&
|
|
647
|
+
Date.now() - prev < RADIUS_CATALOG_TTL_MS &&
|
|
648
|
+
live.length
|
|
649
|
+
) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
const models = piModels(dataDir);
|
|
654
|
+
await models.refresh({
|
|
655
|
+
providers: ["radius"],
|
|
656
|
+
allowNetwork: true,
|
|
657
|
+
force: true,
|
|
658
|
+
});
|
|
659
|
+
const ids = models
|
|
660
|
+
.getModels("radius")
|
|
661
|
+
.map((model) => model.id)
|
|
662
|
+
.filter((item) => item.length > 0);
|
|
663
|
+
if (ids.length) {
|
|
664
|
+
await store.modify("radius", async (current) => {
|
|
665
|
+
if (current?.type !== "oauth") return current;
|
|
666
|
+
return { ...current, availableModelIds: ids };
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
radiusCatalogAt.set(dataDir, Date.now());
|
|
670
|
+
} catch {
|
|
671
|
+
/* keep the last stored list */
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
type CopilotAutoSession = {
|
|
676
|
+
selectedModel: string;
|
|
677
|
+
sessionToken: string;
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
async function openCopilotAutoSession(dataDir: string): Promise<CopilotAutoSession> {
|
|
681
|
+
const resolved = await piModels(dataDir).getAuth("github-copilot");
|
|
682
|
+
const token = resolved?.auth.apiKey;
|
|
683
|
+
const baseUrl = (resolved?.auth.baseUrl || "").replace(/\/+$/, "");
|
|
684
|
+
if (!token || !baseUrl) {
|
|
685
|
+
throw new Error("GitHub Copilot is not logged in");
|
|
686
|
+
}
|
|
687
|
+
const response = await fetch(`${baseUrl}/models/session`, {
|
|
688
|
+
method: "POST",
|
|
689
|
+
headers: {
|
|
690
|
+
...copilotAuthHeaders(token),
|
|
691
|
+
"Content-Type": "application/json",
|
|
692
|
+
},
|
|
693
|
+
body: JSON.stringify({ auto_mode: { enabled: true } }),
|
|
694
|
+
signal: AbortSignal.timeout(8000),
|
|
695
|
+
});
|
|
696
|
+
if (!response.ok) {
|
|
697
|
+
const text = await response.text().catch(() => "");
|
|
698
|
+
throw new Error(
|
|
699
|
+
text || "GitHub Copilot Auto 無法開 session。免費/學生方案只能走 Auto。",
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
const body = asRecord(await response.json());
|
|
703
|
+
const selected =
|
|
704
|
+
typeof body?.selected_model === "string" ? body.selected_model : "";
|
|
705
|
+
const sessionToken =
|
|
706
|
+
typeof body?.session_token === "string" ? body.session_token : "";
|
|
707
|
+
if (!selected || !sessionToken) {
|
|
708
|
+
throw new Error("GitHub Copilot Auto 沒有可用模型。");
|
|
709
|
+
}
|
|
710
|
+
return { selectedModel: selected, sessionToken };
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function copilotModelForId(
|
|
714
|
+
models: MutableModels,
|
|
715
|
+
id: string,
|
|
716
|
+
): ReturnType<MutableModels["getModel"]> {
|
|
717
|
+
const exact = models.getModel("github-copilot", id);
|
|
718
|
+
if (exact) return exact;
|
|
719
|
+
const template = models
|
|
720
|
+
.getModels("github-copilot")
|
|
721
|
+
.find((model) => model.api === "openai-responses");
|
|
722
|
+
if (!template) return undefined;
|
|
723
|
+
return { ...template, id, name: id };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export type OAuthStatus = {
|
|
727
|
+
id: string;
|
|
728
|
+
pickerId: string;
|
|
729
|
+
name: string;
|
|
730
|
+
hint: string;
|
|
731
|
+
loginHint?: string;
|
|
732
|
+
flow: SubscriptionFlow;
|
|
733
|
+
connected: boolean;
|
|
734
|
+
pending: boolean;
|
|
735
|
+
ready: boolean;
|
|
736
|
+
kind: "oauth";
|
|
737
|
+
models: ModelEntryLite[];
|
|
738
|
+
userCode?: string;
|
|
739
|
+
verificationUri?: string;
|
|
740
|
+
importHint?: string | null;
|
|
741
|
+
error?: string;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
type LoginWatch = {
|
|
745
|
+
provider: string;
|
|
746
|
+
status: "waiting" | "ok" | "error";
|
|
747
|
+
detail?: string;
|
|
748
|
+
openUrl?: string;
|
|
749
|
+
userCode?: string;
|
|
750
|
+
flow: SubscriptionFlow;
|
|
751
|
+
resolvePrompt?: (value: string) => void;
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const watches = new Map<string, LoginWatch>();
|
|
755
|
+
|
|
756
|
+
function importHintFor(id: string): string | null {
|
|
757
|
+
const pi = join(homedir(), ".pi", "agent", "auth.json");
|
|
758
|
+
const grok = join(homedir(), ".grok", "auth.json");
|
|
759
|
+
const dsh = join(homedir(), ".dsh", "pi-ai-oauth.json");
|
|
760
|
+
try {
|
|
761
|
+
if (asCredential(readJsonObject(pi)[id])) return "偵測到 ~/.pi/agent/auth.json";
|
|
762
|
+
} catch {
|
|
763
|
+
/* ignore */
|
|
764
|
+
}
|
|
765
|
+
if (id === "xai") {
|
|
766
|
+
try {
|
|
767
|
+
if (xaiFromGrokAuthFile(readJsonObject(grok))) {
|
|
768
|
+
return "偵測到 ~/.grok/auth.json";
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
/* ignore */
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
try {
|
|
775
|
+
if (asCredential(readJsonObject(dsh)[id])) return "偵測到 ~/.dsh/pi-ai-oauth.json";
|
|
776
|
+
} catch {
|
|
777
|
+
/* ignore */
|
|
778
|
+
}
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function hasOAuth(dataDir: string, id: string): boolean {
|
|
783
|
+
const cred = getStore(dataDir).peek(id);
|
|
784
|
+
return Boolean(cred && cred.type === "oauth" && cred.access);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function oauthUsable(dataDir: string, id: string): boolean {
|
|
788
|
+
const cred = getStore(dataDir).peek(id);
|
|
789
|
+
if (!cred || cred.type !== "oauth" || !cred.access) return false;
|
|
790
|
+
if (Date.now() < cred.expires) return true;
|
|
791
|
+
return Boolean(cred.refresh);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function storedAccessToken(dataDir: string, id: string): string | null {
|
|
795
|
+
const cred = getStore(dataDir).peek(id);
|
|
796
|
+
if (cred?.type === "oauth" && cred.access) return cred.access;
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function oauthStatus(dataDir: string, id: string): OAuthStatus {
|
|
801
|
+
const def = subscriptionById(id);
|
|
802
|
+
if (!def) throw new StoreError(400, `unknown subscription ${id}`);
|
|
803
|
+
const watch = watches.get(id);
|
|
804
|
+
const connected = hasOAuth(dataDir, id);
|
|
805
|
+
const ready = oauthUsable(dataDir, id);
|
|
806
|
+
const provider = catalogProviders().find((p) => p.id === id);
|
|
807
|
+
return {
|
|
808
|
+
id: def.id,
|
|
809
|
+
pickerId: def.pickerId,
|
|
810
|
+
name: provider?.name || def.name,
|
|
811
|
+
hint: def.hint,
|
|
812
|
+
loginHint: def.loginHint,
|
|
813
|
+
flow: def.flow,
|
|
814
|
+
kind: "oauth",
|
|
815
|
+
connected,
|
|
816
|
+
pending: watch?.status === "waiting",
|
|
817
|
+
ready,
|
|
818
|
+
models: catalogModels(id, dataDir),
|
|
819
|
+
userCode: watch?.userCode,
|
|
820
|
+
verificationUri: watch?.openUrl,
|
|
821
|
+
importHint: importHintFor(id),
|
|
822
|
+
error: watch?.status === "error" ? watch.detail : undefined,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function listSubscriptions(dataDir: string): OAuthStatus[] {
|
|
827
|
+
return SUBSCRIPTIONS.map((def) => oauthStatus(dataDir, def.id));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function pickSelectOption(
|
|
831
|
+
provider: string,
|
|
832
|
+
options: readonly { id: string; label: string; description?: string }[],
|
|
833
|
+
): { id: string; label: string } {
|
|
834
|
+
const byId = (id: string) => options.find((option) => option.id === id);
|
|
835
|
+
if (provider === "openai-codex") {
|
|
836
|
+
const device = byId("device_code");
|
|
837
|
+
if (device) return device;
|
|
838
|
+
}
|
|
839
|
+
const headless = options.find((option) =>
|
|
840
|
+
/device[_-]?code|headless|cli/i.test(
|
|
841
|
+
`${option.id} ${option.label} ${option.description ?? ""}`,
|
|
842
|
+
),
|
|
843
|
+
);
|
|
844
|
+
if (headless) return headless;
|
|
845
|
+
return options[0]!;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function answerOptionalText(
|
|
849
|
+
provider: string,
|
|
850
|
+
prompt: AuthPrompt,
|
|
851
|
+
): string | undefined {
|
|
852
|
+
if (prompt.type !== "text") return undefined;
|
|
853
|
+
const blob = `${prompt.message} ${prompt.placeholder ?? ""}`.toLowerCase();
|
|
854
|
+
if (
|
|
855
|
+
provider === "github-copilot" ||
|
|
856
|
+
/enterprise|blank for github\.com|github\.com/i.test(blob)
|
|
857
|
+
) {
|
|
858
|
+
return "";
|
|
859
|
+
}
|
|
860
|
+
if (/\bblank\b|\boptional\b|\bleave empty\b|\(empty\)/i.test(blob)) {
|
|
861
|
+
return "";
|
|
862
|
+
}
|
|
863
|
+
return undefined;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function waitForPaste(watch: LoginWatch, signal?: AbortSignal): Promise<string> {
|
|
867
|
+
return new Promise((resolve, reject) => {
|
|
868
|
+
const finish = (value: string) => {
|
|
869
|
+
watch.resolvePrompt = undefined;
|
|
870
|
+
signal?.removeEventListener("abort", onAbort);
|
|
871
|
+
resolve(value);
|
|
872
|
+
};
|
|
873
|
+
const onAbort = () => {
|
|
874
|
+
watch.resolvePrompt = undefined;
|
|
875
|
+
reject(new Error("Login cancelled"));
|
|
876
|
+
};
|
|
877
|
+
watch.resolvePrompt = finish;
|
|
878
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
879
|
+
if (signal?.aborted) onAbort();
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export async function startLogin(dataDir: string, id: string): Promise<OAuthStatus> {
|
|
884
|
+
const def = subscriptionById(id);
|
|
885
|
+
if (!def) throw new StoreError(400, `unknown subscription ${id}`);
|
|
886
|
+
const existing = watches.get(id);
|
|
887
|
+
if (existing?.status === "waiting") return oauthStatus(dataDir, id);
|
|
888
|
+
|
|
889
|
+
const models = piModels(dataDir);
|
|
890
|
+
if (!models.getProvider(id)) {
|
|
891
|
+
throw new StoreError(400, `unknown subscription ${id}`);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const watch: LoginWatch = {
|
|
895
|
+
provider: id,
|
|
896
|
+
status: "waiting",
|
|
897
|
+
flow: def.flow,
|
|
898
|
+
};
|
|
899
|
+
watches.set(id, watch);
|
|
900
|
+
|
|
901
|
+
let released = false;
|
|
902
|
+
let release!: (error?: Error) => void;
|
|
903
|
+
const firstNotice = new Promise<void>((resolve, reject) => {
|
|
904
|
+
release = (error?: Error) => {
|
|
905
|
+
if (released) return;
|
|
906
|
+
released = true;
|
|
907
|
+
if (error) reject(error);
|
|
908
|
+
else resolve();
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const interaction: AuthInteraction = {
|
|
913
|
+
async prompt(prompt) {
|
|
914
|
+
if (prompt.type === "select" && prompt.options.length > 0) {
|
|
915
|
+
return pickSelectOption(id, prompt.options).id;
|
|
916
|
+
}
|
|
917
|
+
const optional = answerOptionalText(id, prompt);
|
|
918
|
+
if (optional !== undefined) return optional;
|
|
919
|
+
if (prompt.type === "manual_code" || prompt.type === "text") {
|
|
920
|
+
return waitForPaste(watch, prompt.signal);
|
|
921
|
+
}
|
|
922
|
+
throw new Error(
|
|
923
|
+
`Interactive prompt required (${prompt.type}: ${prompt.message}). ` +
|
|
924
|
+
"Paste the redirect URL on the settings page.",
|
|
925
|
+
);
|
|
926
|
+
},
|
|
927
|
+
notify(event: AuthEvent) {
|
|
928
|
+
if (event.type === "auth_url") watch.openUrl = event.url;
|
|
929
|
+
if (event.type === "device_code") {
|
|
930
|
+
watch.openUrl = event.verificationUri;
|
|
931
|
+
watch.userCode = event.userCode;
|
|
932
|
+
}
|
|
933
|
+
if (event.type === "device_code" || event.type === "auth_url") release();
|
|
934
|
+
},
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const finished = models.login(id, "oauth", interaction).then(
|
|
938
|
+
async () => {
|
|
939
|
+
if (id === "radius") await refreshRadiusCatalog(dataDir, true);
|
|
940
|
+
watch.status = "ok";
|
|
941
|
+
watch.detail = `Logged in to ${id}`;
|
|
942
|
+
},
|
|
943
|
+
(error: unknown) => {
|
|
944
|
+
watch.status = "error";
|
|
945
|
+
watch.detail = error instanceof Error ? error.message : String(error);
|
|
946
|
+
release(error instanceof Error ? error : new Error(watch.detail));
|
|
947
|
+
},
|
|
948
|
+
);
|
|
949
|
+
void finished;
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
await firstNotice;
|
|
953
|
+
} catch (error) {
|
|
954
|
+
throw new StoreError(
|
|
955
|
+
502,
|
|
956
|
+
error instanceof Error ? error.message : String(error),
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
return oauthStatus(dataDir, id);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export async function pollLogin(dataDir: string, id: string): Promise<OAuthStatus> {
|
|
963
|
+
const def = subscriptionById(id);
|
|
964
|
+
if (!def) throw new StoreError(400, `unknown subscription ${id}`);
|
|
965
|
+
const watch = watches.get(id);
|
|
966
|
+
if (watch?.status === "error") {
|
|
967
|
+
return { ...oauthStatus(dataDir, id), error: watch.detail };
|
|
968
|
+
}
|
|
969
|
+
return oauthStatus(dataDir, id);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export async function completeLogin(
|
|
973
|
+
dataDir: string,
|
|
974
|
+
id: string,
|
|
975
|
+
input: { code?: string; url?: string },
|
|
976
|
+
): Promise<OAuthStatus> {
|
|
977
|
+
const watch = watches.get(id);
|
|
978
|
+
const value = (input.url || input.code || "").trim();
|
|
979
|
+
if (!watch?.resolvePrompt) {
|
|
980
|
+
throw new StoreError(400, "no pending browser login");
|
|
981
|
+
}
|
|
982
|
+
if (!value) throw new StoreError(400, "missing authorization code");
|
|
983
|
+
watch.resolvePrompt(value);
|
|
984
|
+
for (let i = 0; i < 20; i++) {
|
|
985
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
986
|
+
if (watch.status !== "waiting") break;
|
|
987
|
+
}
|
|
988
|
+
return oauthStatus(dataDir, id);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
export async function logoutOAuth(dataDir: string, id: string): Promise<OAuthStatus> {
|
|
992
|
+
const def = subscriptionById(id);
|
|
993
|
+
if (!def) throw new StoreError(400, `unknown subscription ${id}`);
|
|
994
|
+
watches.delete(id);
|
|
995
|
+
await piModels(dataDir).logout(id);
|
|
996
|
+
return oauthStatus(dataDir, id);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
export function formatOAuthError(provider: string, message?: string): string {
|
|
1000
|
+
const text = (message ?? "").trim();
|
|
1001
|
+
if (
|
|
1002
|
+
/^模型請求/.test(text) ||
|
|
1003
|
+
text.includes("不是訂閱失效") ||
|
|
1004
|
+
text.includes("登入已失效") ||
|
|
1005
|
+
text.includes("這個 GitHub Copilot 帳號不支援")
|
|
1006
|
+
) {
|
|
1007
|
+
return text.replace(/^模型請求失敗:/, "");
|
|
1008
|
+
}
|
|
1009
|
+
if (/terminated|timed?\s*out|timeout|aborted/i.test(text)) {
|
|
1010
|
+
return "模型請求逾時,多半是思考或工具跑太久,不是訂閱失效。再送一次即可。";
|
|
1011
|
+
}
|
|
1012
|
+
if (/401|unauthorized|invalid.?token|not logged in/i.test(text)) {
|
|
1013
|
+
return "登入已失效,請到模型頁重新連接。";
|
|
1014
|
+
}
|
|
1015
|
+
if (
|
|
1016
|
+
(provider === "github-copilot" || /github/.test(provider)) &&
|
|
1017
|
+
/model_not_supported|not supported/i.test(text)
|
|
1018
|
+
) {
|
|
1019
|
+
return "這個 GitHub Copilot 帳號不支援指定模型。免費/學生方案只能用 Auto,到模型頁選 Auto。";
|
|
1020
|
+
}
|
|
1021
|
+
if (provider === "xai" && /426|outdated/i.test(text)) {
|
|
1022
|
+
return "Grok CLI 被判定過舊。請執行 grok update。";
|
|
1023
|
+
}
|
|
1024
|
+
if (
|
|
1025
|
+
provider === "xai" &&
|
|
1026
|
+
/402|credits|Grok subscription|spending-limit/i.test(text)
|
|
1027
|
+
) {
|
|
1028
|
+
return (
|
|
1029
|
+
"Grok 訂閱走 grok.com CLI(SuperGrok / X Premium+),不是 console.x.ai 的 API 點數。" +
|
|
1030
|
+
(text ? ` 原文:${text}` : "")
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
return text ? `模型請求失敗:${text}` : "模型請求失敗";
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** ChatGPT Codex and Copilot reasoning/Auto reject `temperature`. */
|
|
1037
|
+
export function oauthOmitsTemperature(input: {
|
|
1038
|
+
provider: string;
|
|
1039
|
+
modelReasoning?: boolean;
|
|
1040
|
+
copilotSession?: boolean;
|
|
1041
|
+
}): boolean {
|
|
1042
|
+
if (input.copilotSession) return true;
|
|
1043
|
+
if (input.provider === "openai-codex") return true;
|
|
1044
|
+
if (input.provider === "github-copilot" && input.modelReasoning) return true;
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function thinkingText(message: AssistantMessage | undefined): string {
|
|
1049
|
+
if (!message) return "";
|
|
1050
|
+
return message.content
|
|
1051
|
+
.filter(
|
|
1052
|
+
(part): part is Extract<typeof part, { type: "thinking" }> =>
|
|
1053
|
+
part.type === "thinking",
|
|
1054
|
+
)
|
|
1055
|
+
.map((part) => part.thinking)
|
|
1056
|
+
.join("\n")
|
|
1057
|
+
.trim();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Codex `DEFAULT_STREAM_IDLE_TIMEOUT_MS`. No tokens on the stream — not a
|
|
1062
|
+
* wall clock on the whole turn. Do not pass this as Pi `timeoutMs` for xAI:
|
|
1063
|
+
* OpenAI-completions maps that to the SDK request timeout and kills thinking.
|
|
1064
|
+
*/
|
|
1065
|
+
export const STREAM_IDLE_TIMEOUT_MS = 300_000;
|
|
1066
|
+
|
|
1067
|
+
export class StreamIdleError extends Error {
|
|
1068
|
+
readonly idleMs: number;
|
|
1069
|
+
constructor(idleMs: number) {
|
|
1070
|
+
super(
|
|
1071
|
+
`stream idle: no tokens for ${Math.round(idleMs / 1000)}s (Codex-style; not a turn wall clock). Resend or switch models.`,
|
|
1072
|
+
);
|
|
1073
|
+
this.name = "StreamIdleError";
|
|
1074
|
+
this.idleMs = idleMs;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export function startStreamIdle(
|
|
1079
|
+
idleMs: number,
|
|
1080
|
+
parent?: AbortSignal,
|
|
1081
|
+
): {
|
|
1082
|
+
signal: AbortSignal;
|
|
1083
|
+
bump: () => void;
|
|
1084
|
+
dispose: () => void;
|
|
1085
|
+
timedOut: () => boolean;
|
|
1086
|
+
} {
|
|
1087
|
+
const ctrl = new AbortController();
|
|
1088
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1089
|
+
let timedOut = false;
|
|
1090
|
+
const fire = () => {
|
|
1091
|
+
if (timedOut || ctrl.signal.aborted) return;
|
|
1092
|
+
timedOut = true;
|
|
1093
|
+
ctrl.abort();
|
|
1094
|
+
};
|
|
1095
|
+
const bump = () => {
|
|
1096
|
+
if (timedOut || parent?.aborted) return;
|
|
1097
|
+
if (timer) clearTimeout(timer);
|
|
1098
|
+
timer = setTimeout(fire, idleMs);
|
|
1099
|
+
};
|
|
1100
|
+
const onParent = () => {
|
|
1101
|
+
if (timer) clearTimeout(timer);
|
|
1102
|
+
ctrl.abort();
|
|
1103
|
+
};
|
|
1104
|
+
if (parent?.aborted) {
|
|
1105
|
+
ctrl.abort();
|
|
1106
|
+
} else {
|
|
1107
|
+
parent?.addEventListener("abort", onParent, { once: true });
|
|
1108
|
+
}
|
|
1109
|
+
bump();
|
|
1110
|
+
return {
|
|
1111
|
+
signal: ctrl.signal,
|
|
1112
|
+
bump,
|
|
1113
|
+
dispose: () => {
|
|
1114
|
+
if (timer) clearTimeout(timer);
|
|
1115
|
+
parent?.removeEventListener("abort", onParent);
|
|
1116
|
+
},
|
|
1117
|
+
timedOut: () => timedOut,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function userAborted(signal?: AbortSignal): Error {
|
|
1122
|
+
const err = new Error("aborted");
|
|
1123
|
+
err.name = "AbortError";
|
|
1124
|
+
return err;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/** Pi streamSimple: Think chips only from thinking_delta, not a planted placeholder. */
|
|
1128
|
+
async function streamOAuthMessage(
|
|
1129
|
+
models: MutableModels,
|
|
1130
|
+
model: Model,
|
|
1131
|
+
context: Parameters<MutableModels["streamSimple"]>[1],
|
|
1132
|
+
options: Parameters<MutableModels["streamSimple"]>[2],
|
|
1133
|
+
toolCtx: ToolContext,
|
|
1134
|
+
traces: ToolTrace[],
|
|
1135
|
+
): Promise<AssistantMessage> {
|
|
1136
|
+
const idle = startStreamIdle(STREAM_IDLE_TIMEOUT_MS, options?.signal);
|
|
1137
|
+
const stream = models.streamSimple(model, context, {
|
|
1138
|
+
...options,
|
|
1139
|
+
signal: idle.signal,
|
|
1140
|
+
});
|
|
1141
|
+
let lastEmit = 0;
|
|
1142
|
+
const flush = (partial: AssistantMessage | undefined, force: boolean) => {
|
|
1143
|
+
const think = thinkingText(partial);
|
|
1144
|
+
if (!think) return;
|
|
1145
|
+
const now = Date.now();
|
|
1146
|
+
if (!force && now - lastEmit < 120) return;
|
|
1147
|
+
lastEmit = now;
|
|
1148
|
+
emitProgress(toolCtx, traces, think);
|
|
1149
|
+
};
|
|
1150
|
+
const failIfIdle = (): never => {
|
|
1151
|
+
if (options?.signal?.aborted) throw userAborted(options.signal);
|
|
1152
|
+
throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1153
|
+
};
|
|
1154
|
+
try {
|
|
1155
|
+
for await (const event of stream) {
|
|
1156
|
+
idle.bump();
|
|
1157
|
+
if (event.type === "thinking_start" || event.type === "thinking_delta") {
|
|
1158
|
+
flush(event.partial, event.type === "thinking_start");
|
|
1159
|
+
} else if (event.type === "thinking_end") {
|
|
1160
|
+
flush(event.partial, true);
|
|
1161
|
+
} else if (event.type === "done") {
|
|
1162
|
+
flush(event.message, true);
|
|
1163
|
+
return event.message;
|
|
1164
|
+
} else if (event.type === "error") {
|
|
1165
|
+
if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
|
|
1166
|
+
return event.error;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (idle.timedOut() || options?.signal?.aborted) failIfIdle();
|
|
1170
|
+
return stream.result();
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
if (err instanceof StreamIdleError) throw err;
|
|
1173
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
1174
|
+
if (options?.signal?.aborted) throw err;
|
|
1175
|
+
if (idle.timedOut()) throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1176
|
+
}
|
|
1177
|
+
if (idle.timedOut() && !(options?.signal?.aborted)) {
|
|
1178
|
+
throw new StreamIdleError(STREAM_IDLE_TIMEOUT_MS);
|
|
1179
|
+
}
|
|
1180
|
+
throw err;
|
|
1181
|
+
} finally {
|
|
1182
|
+
idle.dispose();
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function abortWhen(signal?: AbortSignal): Promise<never> {
|
|
1187
|
+
return new Promise((_, reject) => {
|
|
1188
|
+
const fail = () => {
|
|
1189
|
+
const err = new Error("aborted");
|
|
1190
|
+
err.name = "AbortError";
|
|
1191
|
+
reject(err);
|
|
1192
|
+
};
|
|
1193
|
+
if (!signal) return;
|
|
1194
|
+
if (signal.aborted) {
|
|
1195
|
+
fail();
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
signal.addEventListener("abort", fail, { once: true });
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export async function completeOAuth(input: {
|
|
1203
|
+
dataDir: string;
|
|
1204
|
+
pickerId: string;
|
|
1205
|
+
model: string;
|
|
1206
|
+
system: string;
|
|
1207
|
+
messages: { role: "user" | "assistant"; content: string }[];
|
|
1208
|
+
temperature?: number;
|
|
1209
|
+
reasoning?: "minimal" | "low" | "medium" | "high";
|
|
1210
|
+
tools?: boolean;
|
|
1211
|
+
skills?: SkillRef[];
|
|
1212
|
+
toolCtx?: ToolContext;
|
|
1213
|
+
}): Promise<{
|
|
1214
|
+
text: string;
|
|
1215
|
+
provider: string;
|
|
1216
|
+
model: string;
|
|
1217
|
+
traces: ToolTrace[];
|
|
1218
|
+
thinking: string;
|
|
1219
|
+
usage: ChatUsage;
|
|
1220
|
+
}> {
|
|
1221
|
+
const sub = subscriptionByPicker(input.pickerId);
|
|
1222
|
+
if (!sub) throw new Error(`unknown oauth provider ${input.pickerId}`);
|
|
1223
|
+
const models = piModels(input.dataDir);
|
|
1224
|
+
let modelId = input.model;
|
|
1225
|
+
let copilotSessionToken = "";
|
|
1226
|
+
if (sub.id === "radius") {
|
|
1227
|
+
await refreshRadiusCatalog(input.dataDir, true);
|
|
1228
|
+
}
|
|
1229
|
+
if (sub.id === "github-copilot") {
|
|
1230
|
+
await refreshCopilotCatalog(input.dataDir);
|
|
1231
|
+
const autoOnly = copilotAutoOnly(input.dataDir);
|
|
1232
|
+
if (autoOnly || modelId === "auto") {
|
|
1233
|
+
const session = await openCopilotAutoSession(input.dataDir);
|
|
1234
|
+
modelId = session.selectedModel;
|
|
1235
|
+
copilotSessionToken = session.sessionToken;
|
|
1236
|
+
} else {
|
|
1237
|
+
const allowed = copilotAllowedIds(input.dataDir);
|
|
1238
|
+
if (allowed && !allowed.includes(modelId)) {
|
|
1239
|
+
throw new Error(
|
|
1240
|
+
`這個 GitHub Copilot 帳號不支援 ${modelId}。免費/學生方案請選 Auto。`,
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
const model =
|
|
1246
|
+
sub.id === "github-copilot"
|
|
1247
|
+
? copilotModelForId(models, modelId)
|
|
1248
|
+
: models.getModel(sub.id, modelId);
|
|
1249
|
+
if (!model) throw new Error(`${sub.id} has no model ${modelId}`);
|
|
1250
|
+
const auth = await models.checkAuth(sub.id);
|
|
1251
|
+
if (!auth) {
|
|
1252
|
+
throw new Error(`${sub.name} is not logged in`);
|
|
1253
|
+
}
|
|
1254
|
+
const now = Date.now();
|
|
1255
|
+
const transcript: Message[] = input.messages.map((item) =>
|
|
1256
|
+
item.role === "user"
|
|
1257
|
+
? { role: "user" as const, content: item.content, timestamp: now }
|
|
1258
|
+
: stubAssistant(model, item.content, now),
|
|
1259
|
+
);
|
|
1260
|
+
const useTools = Boolean(input.tools);
|
|
1261
|
+
const omitTemperature = oauthOmitsTemperature({
|
|
1262
|
+
provider: sub.id,
|
|
1263
|
+
modelReasoning: Boolean(model.reasoning),
|
|
1264
|
+
copilotSession: Boolean(copilotSessionToken),
|
|
1265
|
+
});
|
|
1266
|
+
const options: {
|
|
1267
|
+
temperature?: number;
|
|
1268
|
+
reasoning?: "minimal" | "low" | "medium" | "high";
|
|
1269
|
+
signal?: AbortSignal;
|
|
1270
|
+
transformHeaders?: (
|
|
1271
|
+
headers: Record<string, string | null>,
|
|
1272
|
+
) => Record<string, string | null>;
|
|
1273
|
+
onPayload?: (payload: unknown) => unknown;
|
|
1274
|
+
} = {
|
|
1275
|
+
temperature: omitTemperature ? undefined : (input.temperature ?? 0.4),
|
|
1276
|
+
reasoning: input.reasoning,
|
|
1277
|
+
};
|
|
1278
|
+
if (sub.id === "xai" || sub.id === "github-copilot") {
|
|
1279
|
+
options.transformHeaders = (headers) => ({
|
|
1280
|
+
...headers,
|
|
1281
|
+
...(sub.id === "xai" ? grokCliHeaders() : {}),
|
|
1282
|
+
...(sub.id === "github-copilot"
|
|
1283
|
+
? copilotIdeHeaders(copilotSessionToken || undefined)
|
|
1284
|
+
: {}),
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
if (omitTemperature) {
|
|
1288
|
+
options.onPayload = (payload) => {
|
|
1289
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
1290
|
+
return payload;
|
|
1291
|
+
}
|
|
1292
|
+
const next = { ...(payload as Record<string, unknown>) };
|
|
1293
|
+
delete next.temperature;
|
|
1294
|
+
return next;
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
const traces: ToolTrace[] = [];
|
|
1298
|
+
const thinkingChunks: string[] = [];
|
|
1299
|
+
const usage = blankUsage();
|
|
1300
|
+
usage.provider = input.pickerId;
|
|
1301
|
+
usage.model = modelId;
|
|
1302
|
+
const started = Date.now();
|
|
1303
|
+
const finish = (text: string) => ({
|
|
1304
|
+
text,
|
|
1305
|
+
provider: input.pickerId,
|
|
1306
|
+
model: modelId,
|
|
1307
|
+
traces,
|
|
1308
|
+
thinking: thinkingChunks.join("\n\n"),
|
|
1309
|
+
usage: withDuration(usage, started),
|
|
1310
|
+
});
|
|
1311
|
+
const toolCtx: ToolContext = input.toolCtx ?? {
|
|
1312
|
+
skills: input.skills,
|
|
1313
|
+
dataDir: input.dataDir,
|
|
1314
|
+
spawnDepth: 0,
|
|
1315
|
+
allowWrite: true,
|
|
1316
|
+
};
|
|
1317
|
+
options.signal = roundSignal(toolCtx);
|
|
1318
|
+
const tools = guildTools(input.skills ?? [], toolCtx);
|
|
1319
|
+
let lastResult: AssistantMessage | null = null;
|
|
1320
|
+
const looped = await runAgentLoop({
|
|
1321
|
+
toolCtx,
|
|
1322
|
+
traces,
|
|
1323
|
+
thinkingChunks,
|
|
1324
|
+
ask: async ({ wrap, steer }) => {
|
|
1325
|
+
if (wrap) {
|
|
1326
|
+
transcript.push({
|
|
1327
|
+
role: "user",
|
|
1328
|
+
content: TOOL_LOOP_WRAP,
|
|
1329
|
+
timestamp: Date.now(),
|
|
1330
|
+
});
|
|
1331
|
+
}
|
|
1332
|
+
if (steer) {
|
|
1333
|
+
transcript.push({
|
|
1334
|
+
role: "user",
|
|
1335
|
+
content: steer,
|
|
1336
|
+
timestamp: Date.now(),
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
const extra =
|
|
1340
|
+
estimateSendTokens(input.system) +
|
|
1341
|
+
estimateSendTokens(JSON.stringify(useTools ? tools : [])) +
|
|
1342
|
+
2048;
|
|
1343
|
+
const fitted = trimSendMessages(transcript, extra);
|
|
1344
|
+
if (fitted.length < transcript.length) {
|
|
1345
|
+
transcript.splice(0, transcript.length, ...fitted);
|
|
1346
|
+
}
|
|
1347
|
+
let result: AssistantMessage;
|
|
1348
|
+
try {
|
|
1349
|
+
result = await Promise.race([
|
|
1350
|
+
streamOAuthMessage(
|
|
1351
|
+
models,
|
|
1352
|
+
model,
|
|
1353
|
+
{
|
|
1354
|
+
systemPrompt: input.system,
|
|
1355
|
+
messages: transcript,
|
|
1356
|
+
...(useTools ? { tools } : {}),
|
|
1357
|
+
},
|
|
1358
|
+
options,
|
|
1359
|
+
toolCtx,
|
|
1360
|
+
traces,
|
|
1361
|
+
),
|
|
1362
|
+
abortWhen(options.signal),
|
|
1363
|
+
]);
|
|
1364
|
+
} catch (err) {
|
|
1365
|
+
if (err instanceof StreamIdleError) {
|
|
1366
|
+
return {
|
|
1367
|
+
calls: [],
|
|
1368
|
+
text: err.message,
|
|
1369
|
+
thinking: thinkingChunks.join("\n\n"),
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
throw err;
|
|
1373
|
+
}
|
|
1374
|
+
if (result.stopReason === "aborted" || toolCtx.signal?.aborted || options.signal?.aborted) {
|
|
1375
|
+
const err = new Error("aborted");
|
|
1376
|
+
err.name = "AbortError";
|
|
1377
|
+
throw err;
|
|
1378
|
+
}
|
|
1379
|
+
if (result.stopReason === "error") {
|
|
1380
|
+
throw new Error(
|
|
1381
|
+
formatOAuthError(sub.id, result.errorMessage) ||
|
|
1382
|
+
`${sub.id} request failed`,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
addUsage(usage, fromPiUsage(result.usage));
|
|
1386
|
+
lastResult = result;
|
|
1387
|
+
const think = thinkingText(result);
|
|
1388
|
+
const calls =
|
|
1389
|
+
result.stopReason === "toolUse"
|
|
1390
|
+
? result.content.filter(
|
|
1391
|
+
(part): part is Extract<typeof part, { type: "toolCall" }> =>
|
|
1392
|
+
part.type === "toolCall",
|
|
1393
|
+
)
|
|
1394
|
+
: [];
|
|
1395
|
+
const text = contentText(result.content).trim();
|
|
1396
|
+
if (!calls.length && !text && traces.length === 0) {
|
|
1397
|
+
throw new Error(`${sub.id} returned an empty reply`);
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
calls: calls.map((call) => ({
|
|
1401
|
+
id: call.id,
|
|
1402
|
+
name: call.name,
|
|
1403
|
+
args: (call.arguments ?? {}) as Record<string, unknown>,
|
|
1404
|
+
})),
|
|
1405
|
+
text,
|
|
1406
|
+
thinking: think,
|
|
1407
|
+
};
|
|
1408
|
+
},
|
|
1409
|
+
onRetry: (late) => {
|
|
1410
|
+
if (lastResult) transcript.push(lastResult);
|
|
1411
|
+
transcript.push({
|
|
1412
|
+
role: "user",
|
|
1413
|
+
content: late,
|
|
1414
|
+
timestamp: Date.now(),
|
|
1415
|
+
});
|
|
1416
|
+
},
|
|
1417
|
+
onTools: (calls, outcomes) => {
|
|
1418
|
+
if (lastResult) transcript.push(lastResult);
|
|
1419
|
+
for (let i = 0; i < calls.length; i++) {
|
|
1420
|
+
transcript.push({
|
|
1421
|
+
role: "toolResult",
|
|
1422
|
+
toolCallId: calls[i].id,
|
|
1423
|
+
toolName: calls[i].name,
|
|
1424
|
+
content: [{ type: "text", text: outcomes[i]?.text ?? "" }],
|
|
1425
|
+
isError: outcomes[i]?.isError,
|
|
1426
|
+
timestamp: Date.now(),
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
},
|
|
1430
|
+
});
|
|
1431
|
+
if (!looped) return finish(TOOL_LOOP_EXHAUSTED);
|
|
1432
|
+
return finish(looped.text);
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function stubAssistant(
|
|
1436
|
+
model: { api: AssistantMessage["api"]; provider: string; id: string },
|
|
1437
|
+
text: string,
|
|
1438
|
+
timestamp: number,
|
|
1439
|
+
): AssistantMessage {
|
|
1440
|
+
return {
|
|
1441
|
+
role: "assistant",
|
|
1442
|
+
content: [{ type: "text", text }],
|
|
1443
|
+
api: model.api,
|
|
1444
|
+
provider: model.provider,
|
|
1445
|
+
model: model.id,
|
|
1446
|
+
usage: {
|
|
1447
|
+
input: 0,
|
|
1448
|
+
output: 0,
|
|
1449
|
+
cacheRead: 0,
|
|
1450
|
+
cacheWrite: 0,
|
|
1451
|
+
totalTokens: 0,
|
|
1452
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
1453
|
+
},
|
|
1454
|
+
stopReason: "stop",
|
|
1455
|
+
timestamp,
|
|
1456
|
+
};
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
export async function accessToken(
|
|
1460
|
+
dataDir: string,
|
|
1461
|
+
id: string,
|
|
1462
|
+
): Promise<{ accessToken: string } | null> {
|
|
1463
|
+
const auth = storedAccessToken(dataDir, id);
|
|
1464
|
+
if (!auth) return null;
|
|
1465
|
+
const resolved = await piModels(dataDir).getAuth(id);
|
|
1466
|
+
const key = resolved?.auth.apiKey;
|
|
1467
|
+
if (!key) return { accessToken: auth };
|
|
1468
|
+
return { accessToken: key };
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
export async function xaiAccessToken(dataDir: string): Promise<string | null> {
|
|
1472
|
+
const tokens = await accessToken(dataDir, "xai");
|
|
1473
|
+
return tokens?.accessToken ?? null;
|
|
1474
|
+
}
|