dsh-plugin-subscriptions 0.5.0 → 0.5.2
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/README.md +21 -6
- package/README.zh.md +20 -6
- package/lib/auth/device-flow.d.ts +55 -0
- package/lib/auth/device-flow.js +177 -0
- package/lib/auth/oauth-flow.js +1 -1
- package/lib/auth/rpc.d.ts +18 -2
- package/lib/auth/rpc.js +98 -3
- package/lib/auth/store.d.ts +20 -2
- package/lib/auth/store.js +45 -9
- package/lib/client/SubscriptionsSection.d.ts +18 -1
- package/lib/client/SubscriptionsSection.js +216 -6
- package/lib/client/index.js +11 -0
- package/lib/client/locales.d.ts +72 -0
- package/lib/client/locales.js +72 -0
- package/lib/client.js +725 -144
- package/lib/client.js.map +1 -1
- package/lib/http.d.ts +114 -0
- package/lib/http.js +402 -0
- package/lib/index.d.ts +3 -2
- package/lib/index.js +2256 -226
- package/lib/providers/antigravity.d.ts +90 -0
- package/lib/providers/antigravity.js +392 -0
- package/lib/providers/catalog-store.js +15 -0
- package/lib/providers/claude.d.ts +20 -1
- package/lib/providers/claude.js +51 -33
- package/lib/providers/codex.js +58 -13
- package/lib/providers/common.d.ts +32 -1
- package/lib/providers/common.js +48 -1
- package/lib/providers/copilot.d.ts +315 -0
- package/lib/providers/copilot.js +787 -0
- package/lib/providers/grok.d.ts +7 -2
- package/lib/providers/grok.js +53 -24
- package/lib/tools/image-generate.js +2 -1
- package/lib/tools/video-generate.js +2 -1
- package/lib/tools/x-search.js +2 -1
- package/lib/translate/anthropic.d.ts +47 -6
- package/lib/translate/anthropic.js +135 -20
- package/lib/translate/antigravity.d.ts +110 -0
- package/lib/translate/antigravity.js +303 -0
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +363 -0
- package/lib/translate/responses.d.ts +49 -5
- package/lib/translate/responses.js +40 -7
- package/package.json +11 -7
package/lib/index.js
CHANGED
|
@@ -2,13 +2,14 @@ import z from "@deepseek-ai/schemastery";
|
|
|
2
2
|
import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, errorChain, isContextWindowExceededError, isQuotaExceededError, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { ProxyAgent, fetch as fetch$1 } from "undici";
|
|
5
6
|
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
6
7
|
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
7
9
|
import { execFileSync } from "node:child_process";
|
|
8
10
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
11
|
import { homedir } from "node:os";
|
|
10
12
|
import { AttachmentId } from "@deepseek-ai/dsh-attachment";
|
|
11
|
-
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
12
13
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
13
14
|
|
|
14
15
|
//#region src/auth/pkce.ts
|
|
@@ -155,7 +156,7 @@ var OAuthFlowManager = class {
|
|
|
155
156
|
if (this.attempts.has(provider)) throw new Error(`a ${provider} login attempt is already in progress`);
|
|
156
157
|
const input = {
|
|
157
158
|
redirectUri: "",
|
|
158
|
-
state: randomToken(
|
|
159
|
+
state: randomToken(32),
|
|
159
160
|
pkce: createPkce(),
|
|
160
161
|
nonce: randomHex(8)
|
|
161
162
|
};
|
|
@@ -250,6 +251,503 @@ var OAuthFlowManager = class {
|
|
|
250
251
|
}
|
|
251
252
|
};
|
|
252
253
|
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region src/http.ts
|
|
256
|
+
/**
|
|
257
|
+
* undici's own fetch, typed to the DOM fetch signature: its bundled types are
|
|
258
|
+
* stricter (Request requires `duplex`, `RequestInit.body` is non-null) and
|
|
259
|
+
* incompatible with the DOM shapes the provider code passes. The runtime
|
|
260
|
+
* object is the same Web-fetch implementation Node uses.
|
|
261
|
+
*/
|
|
262
|
+
const dispatchFetch = fetch$1;
|
|
263
|
+
/** Destination the `proxyTest` endpoint probes when none is given. */
|
|
264
|
+
const DEFAULT_PROXY_TEST_URL = "https://api.x.ai/v1/models";
|
|
265
|
+
/** Probe deadline; a hung proxy must not pin the Settings dialog forever. */
|
|
266
|
+
const DEFAULT_PROXY_TEST_TIMEOUT_MS = 15e3;
|
|
267
|
+
/** Disabled configuration: the module state before the first load. */
|
|
268
|
+
const DISABLED = {
|
|
269
|
+
enabled: false,
|
|
270
|
+
url: "",
|
|
271
|
+
bypass: []
|
|
272
|
+
};
|
|
273
|
+
/** Current config; updated by every load/apply/save. */
|
|
274
|
+
let current = DISABLED;
|
|
275
|
+
/** The live dispatcher, or undefined when proxies are off/errored. */
|
|
276
|
+
let agent;
|
|
277
|
+
/** Last load/apply failure, surfaced by the config view. */
|
|
278
|
+
let configError;
|
|
279
|
+
/** One lazy load of the on-disk config (module-import cheap; file read once). */
|
|
280
|
+
let ready;
|
|
281
|
+
/** Absolute path of the proxy config file. */
|
|
282
|
+
function proxyFilePath() {
|
|
283
|
+
return dshHomePath("plugins", "subscriptions", "proxy.json");
|
|
284
|
+
}
|
|
285
|
+
function errorMessage(error) {
|
|
286
|
+
return error instanceof Error ? error.message : String(error);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Flatten a fetch failure into a readable message: undici wraps the true
|
|
290
|
+
* cause (`connect ECONNREFUSED ...`) behind a bare "fetch failed", so walk
|
|
291
|
+
* the cause chain and append each distinct layer (up to four, cycle-safe).
|
|
292
|
+
* A hostname resolving to several addresses (e.g. `localhost` → ::1 and
|
|
293
|
+
* 127.0.0.1) fails as an `AggregateError` with an empty message, so its
|
|
294
|
+
* per-address `errors` entries are folded in too.
|
|
295
|
+
*/
|
|
296
|
+
function describeFetchError(error) {
|
|
297
|
+
const parts = [];
|
|
298
|
+
let node = error;
|
|
299
|
+
for (let depth = 0; depth < 4 && node !== void 0 && node !== null; depth += 1) {
|
|
300
|
+
const layer = node;
|
|
301
|
+
if (Array.isArray(layer.errors)) for (const child of layer.errors) {
|
|
302
|
+
const childText = child instanceof Error && child.message !== "" ? child.message : String(child);
|
|
303
|
+
if (childText !== "" && !parts.includes(childText)) parts.push(childText);
|
|
304
|
+
}
|
|
305
|
+
let text = layer instanceof Error ? layer.message : String(node);
|
|
306
|
+
const code = layer.code;
|
|
307
|
+
if (typeof code === "string" && code !== "") {
|
|
308
|
+
if (text === "") text = code;
|
|
309
|
+
else if (!text.includes(code)) text = `${text} (${code})`;
|
|
310
|
+
}
|
|
311
|
+
if (text !== "" && !parts.includes(text)) parts.push(text);
|
|
312
|
+
const next = layer.cause;
|
|
313
|
+
if (next === void 0 || next === null || next === node) break;
|
|
314
|
+
node = next;
|
|
315
|
+
}
|
|
316
|
+
return parts.join(" → ");
|
|
317
|
+
}
|
|
318
|
+
function withError(error) {
|
|
319
|
+
configError = errorMessage(error);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Parse and validate a proxy URL. Only HTTP(S) proxies are supported because
|
|
323
|
+
* the undici dispatcher speaks CONNECT over HTTP; socks5 is not supported.
|
|
324
|
+
* @param raw - the URL the user configured.
|
|
325
|
+
* @returns the parsed URL (credentials attached by the caller).
|
|
326
|
+
*/
|
|
327
|
+
function parseProxyUrl(raw) {
|
|
328
|
+
let url;
|
|
329
|
+
try {
|
|
330
|
+
url = new URL(raw);
|
|
331
|
+
} catch {
|
|
332
|
+
throw new Error(`proxy URL "${raw}" is not a valid URL`);
|
|
333
|
+
}
|
|
334
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`proxy URL must use the http:// or https:// scheme (got "${raw}")`);
|
|
335
|
+
if (url.hostname === "") throw new Error("proxy URL must include a host");
|
|
336
|
+
return url;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Whether a request hostname bypasses the proxy.
|
|
340
|
+
* @param hostname - the request's hostname.
|
|
341
|
+
* @param entries - configured bypass entries: exact host, plain suffix
|
|
342
|
+
* (`example.com` also matches `api.example.com`), or `*.example.com`.
|
|
343
|
+
*/
|
|
344
|
+
function matchesBypass(hostname, entries) {
|
|
345
|
+
const host = hostname.toLowerCase();
|
|
346
|
+
for (const raw of entries) {
|
|
347
|
+
let entry = raw.trim().toLowerCase();
|
|
348
|
+
if (entry === "") continue;
|
|
349
|
+
if (entry.includes("://")) try {
|
|
350
|
+
entry = new URL(entry).hostname;
|
|
351
|
+
} catch {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
entry = entry.replace(/:\d+$/, "");
|
|
355
|
+
if (entry === "" || entry === "*") continue;
|
|
356
|
+
if (entry.startsWith("*.")) {
|
|
357
|
+
if (host.endsWith(entry.slice(1))) return true;
|
|
358
|
+
} else if (host === entry || host.endsWith(`.${entry}`)) return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
/** Validate and normalize one config (throws with a user-facing message). */
|
|
363
|
+
function normalizeConfig(input) {
|
|
364
|
+
const url = input.url.trim();
|
|
365
|
+
if (input.enabled && url === "") throw new Error("a proxy URL is required when the proxy is enabled");
|
|
366
|
+
if (url !== "") parseProxyUrl(url);
|
|
367
|
+
const bypass = Array.from(new Set((input.bypass ?? []).map((entry) => entry.trim()).filter((entry) => entry !== "")));
|
|
368
|
+
return {
|
|
369
|
+
enabled: input.enabled,
|
|
370
|
+
url,
|
|
371
|
+
...input.username !== void 0 && input.username !== "" ? { username: input.username.trim() } : {},
|
|
372
|
+
...input.password !== void 0 && input.password !== "" && input.password !== null ? { password: input.password } : {},
|
|
373
|
+
bypass
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** Build the undici agent for a config (throws on an unusable URL). */
|
|
377
|
+
function buildAgent(cfg) {
|
|
378
|
+
if (!cfg.enabled || cfg.url === "") return void 0;
|
|
379
|
+
const url = parseProxyUrl(cfg.url);
|
|
380
|
+
if (cfg.username !== void 0) url.username = cfg.username;
|
|
381
|
+
if (cfg.password !== void 0) url.password = cfg.password;
|
|
382
|
+
return new ProxyAgent(url.toString());
|
|
383
|
+
}
|
|
384
|
+
/** Swap in a config and its agent; a failed agent keeps the requests direct. */
|
|
385
|
+
async function applyConfig(cfg) {
|
|
386
|
+
let next;
|
|
387
|
+
if (cfg !== void 0) {
|
|
388
|
+
configError = void 0;
|
|
389
|
+
try {
|
|
390
|
+
next = buildAgent(cfg);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
withError(error);
|
|
393
|
+
next = void 0;
|
|
394
|
+
}
|
|
395
|
+
current = cfg;
|
|
396
|
+
}
|
|
397
|
+
const previous = agent;
|
|
398
|
+
agent = next;
|
|
399
|
+
if (previous !== void 0) previous.close().catch(() => void 0);
|
|
400
|
+
}
|
|
401
|
+
/** Read the on-disk config. A missing file is the disabled default. */
|
|
402
|
+
async function loadConfigFile(path) {
|
|
403
|
+
let text;
|
|
404
|
+
try {
|
|
405
|
+
text = await readFile(path, "utf8");
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (error.code === "ENOENT") return {
|
|
408
|
+
...DISABLED,
|
|
409
|
+
bypass: []
|
|
410
|
+
};
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
let parsed;
|
|
414
|
+
try {
|
|
415
|
+
parsed = JSON.parse(text);
|
|
416
|
+
} catch {
|
|
417
|
+
throw new Error(`subscriptions proxy config at ${path} is not valid JSON; fix or delete the file`);
|
|
418
|
+
}
|
|
419
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("subscriptions proxy config must be a JSON object");
|
|
420
|
+
const record = parsed;
|
|
421
|
+
const enabled = record.enabled === true;
|
|
422
|
+
const url = typeof record.url === "string" ? record.url : "";
|
|
423
|
+
const username = typeof record.username === "string" ? record.username : void 0;
|
|
424
|
+
const password = typeof record.password === "string" ? record.password : void 0;
|
|
425
|
+
const bypass = Array.isArray(record.bypass) ? record.bypass.filter((entry) => typeof entry === "string") : [];
|
|
426
|
+
return normalizeConfig({
|
|
427
|
+
enabled,
|
|
428
|
+
url,
|
|
429
|
+
...username === void 0 ? {} : { username },
|
|
430
|
+
...password === void 0 ? {} : { password },
|
|
431
|
+
bypass
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/** Resolve the module state once from disk; failures disable the proxy. */
|
|
435
|
+
async function ensureReady() {
|
|
436
|
+
ready ??= loadConfigFile(proxyFilePath()).then(async (cfg) => {
|
|
437
|
+
await applyConfig(cfg);
|
|
438
|
+
return current;
|
|
439
|
+
}, async (error) => {
|
|
440
|
+
withError(error);
|
|
441
|
+
await applyConfig(void 0);
|
|
442
|
+
return current;
|
|
443
|
+
});
|
|
444
|
+
return ready;
|
|
445
|
+
}
|
|
446
|
+
/** Persist a config atomically with owner-only permissions, then apply it. */
|
|
447
|
+
async function persistConfig(cfg, path) {
|
|
448
|
+
await mkdir(dirname(path), { recursive: true });
|
|
449
|
+
const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
450
|
+
try {
|
|
451
|
+
await writeFile(tmp, JSON.stringify(cfg, null, 2), { mode: 384 });
|
|
452
|
+
await chmod(tmp, 384);
|
|
453
|
+
await rename(tmp, path);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
await rm(tmp, { force: true });
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Current proxy config as served to the client (secrets omitted).
|
|
461
|
+
* @returns the view; {@link ProxyConfigView.error} carries the last
|
|
462
|
+
* load/apply failure when the stored config is unusable.
|
|
463
|
+
*/
|
|
464
|
+
async function proxyGetConfig() {
|
|
465
|
+
await ensureReady();
|
|
466
|
+
return {
|
|
467
|
+
enabled: current.enabled,
|
|
468
|
+
url: current.url,
|
|
469
|
+
...current.username === void 0 ? {} : { username: current.username },
|
|
470
|
+
passwordSet: current.password !== void 0 && current.password !== "",
|
|
471
|
+
bypass: [...current.bypass],
|
|
472
|
+
...configError === void 0 ? {} : { error: configError }
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Validate, persist, and apply one proxy config. A `password` of `undefined`
|
|
477
|
+
* keeps the stored value; `null` or `''` clears it.
|
|
478
|
+
* @param input - the client's payload.
|
|
479
|
+
* @returns the resulting view (secrets omitted).
|
|
480
|
+
*/
|
|
481
|
+
async function proxySetConfig(input) {
|
|
482
|
+
await ensureReady();
|
|
483
|
+
const password = input.password === void 0 ? current.password : input.password === null || input.password === "" ? void 0 : input.password;
|
|
484
|
+
const next = normalizeConfig({
|
|
485
|
+
enabled: input.enabled,
|
|
486
|
+
url: input.url,
|
|
487
|
+
...input.username === void 0 ? {} : { username: input.username },
|
|
488
|
+
...password === void 0 ? {} : { password },
|
|
489
|
+
bypass: input.bypass ?? current.bypass
|
|
490
|
+
});
|
|
491
|
+
await persistConfig(next, proxyFilePath());
|
|
492
|
+
await applyConfig(next);
|
|
493
|
+
return proxyGetConfig();
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* The fetch caller all subscription code uses: routes through the configured
|
|
497
|
+
* proxy unless the host bypasses it. Identity-passthrough otherwise.
|
|
498
|
+
*
|
|
499
|
+
* Proxied requests run on undici's own fetch (not the global one) so the
|
|
500
|
+
* ProxyAgent dispatcher always comes from the same undici build the request
|
|
501
|
+
* is issued with — a mismatched dispatcher can be silently ignored by the
|
|
502
|
+
* host's global fetch.
|
|
503
|
+
*/
|
|
504
|
+
async function proxiedFetch(input, init = {}) {
|
|
505
|
+
await ensureReady();
|
|
506
|
+
let dispatcher;
|
|
507
|
+
if (current.enabled && agent !== void 0) {
|
|
508
|
+
let hostname = "";
|
|
509
|
+
try {
|
|
510
|
+
hostname = (typeof input === "string" ? new URL(input) : input instanceof URL ? input : new URL(input.url)).hostname;
|
|
511
|
+
} catch {
|
|
512
|
+
hostname = "";
|
|
513
|
+
}
|
|
514
|
+
if (!matchesBypass(hostname, current.bypass)) dispatcher = agent;
|
|
515
|
+
}
|
|
516
|
+
if (dispatcher === void 0) return fetch(input, init);
|
|
517
|
+
return dispatchFetch(input, {
|
|
518
|
+
...init,
|
|
519
|
+
dispatcher
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Probe a destination through a proxy, answering with the HTTP status or a
|
|
524
|
+
* flattened transport error. The probe uses `draft` when given (the dialog's
|
|
525
|
+
* current inputs, without saving) and the stored config otherwise.
|
|
526
|
+
* @param target - `http(s)` URL to fetch; defaults to {@link DEFAULT_PROXY_TEST_URL}.
|
|
527
|
+
* @param draft - unsaved proxy inputs to test; absent means the stored config.
|
|
528
|
+
* @returns the result; any HTTP status counts as a successful connection,
|
|
529
|
+
* only a transport failure is an error.
|
|
530
|
+
*/
|
|
531
|
+
async function proxyTestConnection(target = DEFAULT_PROXY_TEST_URL, draft) {
|
|
532
|
+
let parsed;
|
|
533
|
+
try {
|
|
534
|
+
parsed = new URL(target);
|
|
535
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return {
|
|
536
|
+
ok: false,
|
|
537
|
+
viaProxy: false,
|
|
538
|
+
error: `test destination must be http or https (got "${parsed.protocol}//")`
|
|
539
|
+
};
|
|
540
|
+
} catch (error) {
|
|
541
|
+
return {
|
|
542
|
+
ok: false,
|
|
543
|
+
viaProxy: false,
|
|
544
|
+
error: errorMessage(error)
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
await ensureReady();
|
|
548
|
+
let probeAgent;
|
|
549
|
+
let viaProxy;
|
|
550
|
+
let closeProbe = false;
|
|
551
|
+
if (draft !== void 0) try {
|
|
552
|
+
probeAgent = buildAgent(normalizeConfig({
|
|
553
|
+
enabled: true,
|
|
554
|
+
url: draft.url,
|
|
555
|
+
...draft.username === void 0 || draft.username === "" ? {} : { username: draft.username },
|
|
556
|
+
...draft.password === void 0 || draft.password === "" ? {} : { password: draft.password },
|
|
557
|
+
bypass: []
|
|
558
|
+
}));
|
|
559
|
+
viaProxy = probeAgent !== void 0;
|
|
560
|
+
closeProbe = true;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return {
|
|
563
|
+
ok: false,
|
|
564
|
+
viaProxy: false,
|
|
565
|
+
error: errorMessage(error)
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
viaProxy = current.enabled && agent !== void 0 && !matchesBypass(parsed.hostname, current.bypass);
|
|
570
|
+
probeAgent = viaProxy ? agent : void 0;
|
|
571
|
+
}
|
|
572
|
+
const started = Date.now();
|
|
573
|
+
try {
|
|
574
|
+
const init = probeAgent !== void 0 ? {
|
|
575
|
+
method: "GET",
|
|
576
|
+
dispatcher: probeAgent,
|
|
577
|
+
signal: AbortSignal.timeout(DEFAULT_PROXY_TEST_TIMEOUT_MS)
|
|
578
|
+
} : {
|
|
579
|
+
method: "GET",
|
|
580
|
+
signal: AbortSignal.timeout(DEFAULT_PROXY_TEST_TIMEOUT_MS)
|
|
581
|
+
};
|
|
582
|
+
const response = probeAgent !== void 0 ? await dispatchFetch(parsed.toString(), init) : await fetch(parsed.toString(), init);
|
|
583
|
+
response.arrayBuffer().catch(() => void 0);
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
viaProxy,
|
|
587
|
+
status: response.status,
|
|
588
|
+
latencyMs: Date.now() - started
|
|
589
|
+
};
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
viaProxy,
|
|
594
|
+
latencyMs: Date.now() - started,
|
|
595
|
+
error: describeFetchError(error)
|
|
596
|
+
};
|
|
597
|
+
} finally {
|
|
598
|
+
if (closeProbe && probeAgent !== void 0) await probeAgent.close().catch(() => void 0);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/auth/device-flow.ts
|
|
604
|
+
/** Default poll interval when the device-code response omits one. */
|
|
605
|
+
const DEFAULT_INTERVAL_SEC = 5;
|
|
606
|
+
/** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
|
|
607
|
+
const DEFAULT_EXPIRES_IN_SEC = 900;
|
|
608
|
+
/** Sleep for `ms`, rejecting early when the signal aborts. */
|
|
609
|
+
function sleep$1(ms, signal) {
|
|
610
|
+
return new Promise((resolve, reject) => {
|
|
611
|
+
if (signal.aborted) {
|
|
612
|
+
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("aborted"));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const timer = setTimeout(() => {
|
|
616
|
+
signal.removeEventListener("abort", onAbort);
|
|
617
|
+
resolve();
|
|
618
|
+
}, ms);
|
|
619
|
+
timer.unref();
|
|
620
|
+
const onAbort = () => {
|
|
621
|
+
clearTimeout(timer);
|
|
622
|
+
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("aborted"));
|
|
623
|
+
};
|
|
624
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Own the set of in-flight device-flow attempts, keyed by provider. One
|
|
629
|
+
* attempt per provider at a time; an attempt removes itself when it settles.
|
|
630
|
+
*/
|
|
631
|
+
var DeviceFlowManager = class {
|
|
632
|
+
attempts = /* @__PURE__ */ new Map();
|
|
633
|
+
/**
|
|
634
|
+
* Whether a device-flow attempt is running for one provider.
|
|
635
|
+
* @param provider - the provider route.
|
|
636
|
+
* @returns true while an attempt is polling.
|
|
637
|
+
*/
|
|
638
|
+
isBusy(provider) {
|
|
639
|
+
return this.attempts.has(provider);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* The pending attempt for one provider, when any.
|
|
643
|
+
* @param provider - the provider route.
|
|
644
|
+
* @returns the in-flight attempt, or `undefined`.
|
|
645
|
+
*/
|
|
646
|
+
pending(provider) {
|
|
647
|
+
return this.attempts.get(provider);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Start a device-flow attempt: request a device code, then poll the token
|
|
651
|
+
* endpoint in the background of `waitToken`.
|
|
652
|
+
* @param provider - the provider route (one attempt at a time).
|
|
653
|
+
* @param spec - static flow facts for this provider.
|
|
654
|
+
* @returns the live attempt; its `waitToken()` settles the login.
|
|
655
|
+
* @throws when an attempt is already running or the device-code request fails.
|
|
656
|
+
*/
|
|
657
|
+
async start(provider, spec) {
|
|
658
|
+
if (this.attempts.has(provider)) throw new Error(`a ${provider} login attempt is already in progress`);
|
|
659
|
+
const fetchFn = spec.fetchFn ?? proxiedFetch;
|
|
660
|
+
const response = await fetchFn(spec.deviceCodeUrl, {
|
|
661
|
+
method: "POST",
|
|
662
|
+
headers: {
|
|
663
|
+
"accept": "application/json",
|
|
664
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
665
|
+
},
|
|
666
|
+
body: new URLSearchParams({
|
|
667
|
+
client_id: spec.clientId,
|
|
668
|
+
scope: spec.scope
|
|
669
|
+
}).toString()
|
|
670
|
+
});
|
|
671
|
+
if (!response.ok) throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
|
|
672
|
+
const wire = await response.json();
|
|
673
|
+
if (typeof wire.device_code !== "string" || wire.device_code.length === 0 || typeof wire.user_code !== "string" || wire.user_code.length === 0 || typeof wire.verification_uri !== "string" || wire.verification_uri.length === 0) throw new Error(`${provider} device-code response is missing device_code/user_code/verification_uri`);
|
|
674
|
+
const intervalSec = typeof wire.interval === "number" && wire.interval > 0 ? wire.interval : DEFAULT_INTERVAL_SEC;
|
|
675
|
+
const expiresInSec = typeof wire.expires_in === "number" && wire.expires_in > 0 ? wire.expires_in : DEFAULT_EXPIRES_IN_SEC;
|
|
676
|
+
const controller = new AbortController();
|
|
677
|
+
let resolveToken;
|
|
678
|
+
let rejectToken;
|
|
679
|
+
const tokenPromise = new Promise((resolve, reject) => {
|
|
680
|
+
resolveToken = resolve;
|
|
681
|
+
rejectToken = reject;
|
|
682
|
+
});
|
|
683
|
+
tokenPromise.catch(() => void 0);
|
|
684
|
+
const settle = (error, token) => {
|
|
685
|
+
if (this.attempts.get(provider) !== attempt) return;
|
|
686
|
+
this.attempts.delete(provider);
|
|
687
|
+
if (error !== void 0) rejectToken(error);
|
|
688
|
+
else if (token !== void 0) resolveToken(token);
|
|
689
|
+
};
|
|
690
|
+
const poll = async () => {
|
|
691
|
+
let intervalMs = intervalSec * 1e3;
|
|
692
|
+
const deadline = Date.now() + expiresInSec * 1e3;
|
|
693
|
+
while (true) {
|
|
694
|
+
await sleep$1(intervalMs, controller.signal);
|
|
695
|
+
if (Date.now() >= deadline) {
|
|
696
|
+
settle(/* @__PURE__ */ new Error(`login timed out after ${String(Math.round(expiresInSec))}s`));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const pollResponse = await fetchFn(spec.tokenUrl, {
|
|
700
|
+
method: "POST",
|
|
701
|
+
headers: {
|
|
702
|
+
"accept": "application/json",
|
|
703
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
704
|
+
},
|
|
705
|
+
body: new URLSearchParams({
|
|
706
|
+
client_id: spec.clientId,
|
|
707
|
+
device_code: wire.device_code,
|
|
708
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
|
|
709
|
+
}).toString(),
|
|
710
|
+
signal: controller.signal
|
|
711
|
+
});
|
|
712
|
+
const result = await pollResponse.json();
|
|
713
|
+
if (typeof result.access_token === "string" && result.access_token.length > 0) {
|
|
714
|
+
settle(void 0, result.access_token);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
switch (result.error) {
|
|
718
|
+
case "authorization_pending": break;
|
|
719
|
+
case "slow_down":
|
|
720
|
+
intervalMs += 5e3;
|
|
721
|
+
break;
|
|
722
|
+
case "access_denied":
|
|
723
|
+
settle(/* @__PURE__ */ new Error("login declined on the GitHub authorization page"));
|
|
724
|
+
return;
|
|
725
|
+
case "expired_token":
|
|
726
|
+
settle(/* @__PURE__ */ new Error("the device code expired before authorization completed"));
|
|
727
|
+
return;
|
|
728
|
+
default:
|
|
729
|
+
settle(/* @__PURE__ */ new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
const attempt = {
|
|
735
|
+
verificationUrl: wire.verification_uri,
|
|
736
|
+
userCode: wire.user_code,
|
|
737
|
+
waitToken: () => tokenPromise,
|
|
738
|
+
cancel: () => {
|
|
739
|
+
controller.abort(/* @__PURE__ */ new Error("login cancelled"));
|
|
740
|
+
settle(/* @__PURE__ */ new Error("login cancelled"));
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
this.attempts.set(provider, attempt);
|
|
744
|
+
poll().catch((error) => {
|
|
745
|
+
settle(error instanceof Error ? error : new Error(String(error)));
|
|
746
|
+
});
|
|
747
|
+
return attempt;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
|
|
253
751
|
//#endregion
|
|
254
752
|
//#region src/auth/claude-code-creds.ts
|
|
255
753
|
const PRIMARY_SERVICE = "Claude Code-credentials";
|
|
@@ -437,7 +935,8 @@ async function refreshClaudeSynced(session, doRefresh) {
|
|
|
437
935
|
const PROVIDER_IDS = [
|
|
438
936
|
"codex",
|
|
439
937
|
"claude",
|
|
440
|
-
"grok"
|
|
938
|
+
"grok",
|
|
939
|
+
"copilot"
|
|
441
940
|
];
|
|
442
941
|
/**
|
|
443
942
|
* Absolute path of the auth store file.
|
|
@@ -513,6 +1012,34 @@ async function writeStore(store, path) {
|
|
|
513
1012
|
}
|
|
514
1013
|
}
|
|
515
1014
|
/**
|
|
1015
|
+
* One write chain per store path. Every mutation is a read-modify-write of a
|
|
1016
|
+
* single JSON file, and the plugin has several independent writers — a login,
|
|
1017
|
+
* a logout, and one token refresh per provider adapter, each on its own
|
|
1018
|
+
* schedule. Overlapping them unserialized costs whichever provider read the
|
|
1019
|
+
* store first its entry.
|
|
1020
|
+
*
|
|
1021
|
+
* A chain is dropped once nothing is queued behind it, so the map holds an
|
|
1022
|
+
* entry only while writes are in flight.
|
|
1023
|
+
*/
|
|
1024
|
+
const writeChains = /* @__PURE__ */ new Map();
|
|
1025
|
+
/**
|
|
1026
|
+
* Run one read-modify-write of a store path after every write already queued
|
|
1027
|
+
* for it. Callers join the chain synchronously, so call order is write order.
|
|
1028
|
+
* @param path - the store file being mutated.
|
|
1029
|
+
* @param action - the read-modify-write to run.
|
|
1030
|
+
* @returns whatever `action` returns.
|
|
1031
|
+
*/
|
|
1032
|
+
async function serialize(path, action) {
|
|
1033
|
+
const next = (writeChains.get(path) ?? Promise.resolve()).then(action, action);
|
|
1034
|
+
const tail = next.then(() => void 0, () => void 0);
|
|
1035
|
+
writeChains.set(path, tail);
|
|
1036
|
+
try {
|
|
1037
|
+
return await next;
|
|
1038
|
+
} finally {
|
|
1039
|
+
if (writeChains.get(path) === tail) writeChains.delete(path);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
516
1043
|
* Read one provider's session.
|
|
517
1044
|
* @param provider - the provider route.
|
|
518
1045
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
@@ -528,9 +1055,11 @@ async function getSession(provider, path = authFilePath()) {
|
|
|
528
1055
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
529
1056
|
*/
|
|
530
1057
|
async function saveSession(provider, session, path = authFilePath()) {
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
1058
|
+
return serialize(path, async () => {
|
|
1059
|
+
const store = await loadStore(path);
|
|
1060
|
+
store[provider] = session;
|
|
1061
|
+
await writeStore(store, path);
|
|
1062
|
+
});
|
|
534
1063
|
}
|
|
535
1064
|
/**
|
|
536
1065
|
* Delete one provider's session (logout).
|
|
@@ -538,10 +1067,12 @@ async function saveSession(provider, session, path = authFilePath()) {
|
|
|
538
1067
|
* @param path - store file path; defaults to {@link authFilePath}.
|
|
539
1068
|
*/
|
|
540
1069
|
async function deleteSession(provider, path = authFilePath()) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1070
|
+
return serialize(path, async () => {
|
|
1071
|
+
const store = await loadStore(path);
|
|
1072
|
+
if (store[provider] === void 0) return;
|
|
1073
|
+
delete store[provider];
|
|
1074
|
+
await writeStore(store, path);
|
|
1075
|
+
});
|
|
545
1076
|
}
|
|
546
1077
|
|
|
547
1078
|
//#endregion
|
|
@@ -644,7 +1175,69 @@ function readSessionId(payload) {
|
|
|
644
1175
|
if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
|
|
645
1176
|
return readString(payload, "sessionId");
|
|
646
1177
|
}
|
|
647
|
-
|
|
1178
|
+
/** Validate a `proxySet` payload into a shape `ProxyInput` accepts. */
|
|
1179
|
+
function readProxyInput(payload) {
|
|
1180
|
+
if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
|
|
1181
|
+
const record = payload;
|
|
1182
|
+
if (typeof record.enabled !== "boolean") throw new BadRequest("payload.enabled must be a boolean");
|
|
1183
|
+
if (typeof record.url !== "string") throw new BadRequest("payload.url must be a string");
|
|
1184
|
+
let username;
|
|
1185
|
+
if (record.username !== void 0) {
|
|
1186
|
+
if (typeof record.username !== "string") throw new BadRequest("payload.username must be a string when present");
|
|
1187
|
+
username = record.username;
|
|
1188
|
+
}
|
|
1189
|
+
let password;
|
|
1190
|
+
if (record.password !== void 0) {
|
|
1191
|
+
if (record.password !== null && typeof record.password !== "string") throw new BadRequest("payload.password must be a string or null when present");
|
|
1192
|
+
password = record.password;
|
|
1193
|
+
}
|
|
1194
|
+
let bypass;
|
|
1195
|
+
if (record.bypass !== void 0) {
|
|
1196
|
+
if (!Array.isArray(record.bypass) || record.bypass.some((entry) => typeof entry !== "string")) throw new BadRequest("payload.bypass must be an array of strings when present");
|
|
1197
|
+
bypass = record.bypass;
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
enabled: record.enabled,
|
|
1201
|
+
url: record.url,
|
|
1202
|
+
...username === void 0 ? {} : { username },
|
|
1203
|
+
...password === void 0 ? {} : { password },
|
|
1204
|
+
...bypass === void 0 ? {} : { bypass }
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
/** Validate a `proxyTest` payload (the destination URL and an optional draft). */
|
|
1208
|
+
function readProxyTestPayload(payload) {
|
|
1209
|
+
if (typeof payload !== "object" || payload === null) return {};
|
|
1210
|
+
const record = payload;
|
|
1211
|
+
const url = record.url;
|
|
1212
|
+
if (url === void 0 && record.proxy === void 0) return {};
|
|
1213
|
+
if (url !== void 0 && (typeof url !== "string" || url.length === 0)) throw new BadRequest("payload.url must be a non-empty string when present");
|
|
1214
|
+
let proxy;
|
|
1215
|
+
if (record.proxy !== void 0) {
|
|
1216
|
+
if (typeof record.proxy !== "object" || record.proxy === null) throw new BadRequest("payload.proxy must be an object when present");
|
|
1217
|
+
const draftRecord = record.proxy;
|
|
1218
|
+
if (typeof draftRecord.url !== "string" || draftRecord.url.length === 0) throw new BadRequest("payload.proxy.url must be a non-empty string");
|
|
1219
|
+
let username;
|
|
1220
|
+
if (draftRecord.username !== void 0) {
|
|
1221
|
+
if (typeof draftRecord.username !== "string") throw new BadRequest("payload.proxy.username must be a string when present");
|
|
1222
|
+
username = draftRecord.username;
|
|
1223
|
+
}
|
|
1224
|
+
let password;
|
|
1225
|
+
if (draftRecord.password !== void 0) {
|
|
1226
|
+
if (typeof draftRecord.password !== "string") throw new BadRequest("payload.proxy.password must be a string when present");
|
|
1227
|
+
password = draftRecord.password;
|
|
1228
|
+
}
|
|
1229
|
+
proxy = {
|
|
1230
|
+
url: draftRecord.url,
|
|
1231
|
+
...username === void 0 ? {} : { username },
|
|
1232
|
+
...password === void 0 ? {} : { password }
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
return {
|
|
1236
|
+
...url === void 0 ? {} : { url },
|
|
1237
|
+
...proxy === void 0 ? {} : { proxy }
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
async function dispatch(controller, speed, proxy, endpoint, payload, signal) {
|
|
648
1241
|
switch (endpoint) {
|
|
649
1242
|
case "status": {
|
|
650
1243
|
const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
|
|
@@ -669,6 +1262,15 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
|
|
|
669
1262
|
case "setSpeed":
|
|
670
1263
|
await speed.setSpeed(readSessionId(payload), readSpeedTier(payload));
|
|
671
1264
|
return ok({ ok: true });
|
|
1265
|
+
case "proxyGet":
|
|
1266
|
+
if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
|
|
1267
|
+
return ok(await proxy.get());
|
|
1268
|
+
case "proxySet":
|
|
1269
|
+
if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
|
|
1270
|
+
return ok(await proxy.set(readProxyInput(payload)));
|
|
1271
|
+
case "proxyTest":
|
|
1272
|
+
if (proxy === void 0) throw new BadRequest("proxy configuration is unavailable");
|
|
1273
|
+
return ok(await proxy.test(readProxyTestPayload(payload)));
|
|
672
1274
|
default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
|
|
673
1275
|
}
|
|
674
1276
|
}
|
|
@@ -677,13 +1279,14 @@ async function dispatch(controller, speed, endpoint, payload, signal) {
|
|
|
677
1279
|
* @param ctx - the plugin context (headless profiles have no `connection`).
|
|
678
1280
|
* @param controller - the auth operations backing the endpoints.
|
|
679
1281
|
* @param speed - the per-session speed-tier state backing the Speed toggle.
|
|
1282
|
+
* @param proxy - optional proxy-config controller backing `proxyGet`/`proxySet`/`proxyTest`.
|
|
680
1283
|
*/
|
|
681
|
-
function registerAuthRpc(ctx, controller, speed) {
|
|
1284
|
+
function registerAuthRpc(ctx, controller, speed, proxy = void 0) {
|
|
682
1285
|
ctx.inject(["connection"], (ctx$1) => {
|
|
683
1286
|
const connection = ctx$1.get("connection");
|
|
684
1287
|
ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
|
|
685
1288
|
try {
|
|
686
|
-
return await dispatch(controller, speed, endpoint, payload, signal);
|
|
1289
|
+
return await dispatch(controller, speed, proxy, endpoint, payload, signal);
|
|
687
1290
|
} catch (error) {
|
|
688
1291
|
return failure(error);
|
|
689
1292
|
}
|
|
@@ -707,6 +1310,7 @@ function validateModels(models, label) {
|
|
|
707
1310
|
if (model.contextWindow !== void 0 && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) throw new Error(`${label}: catalog model "${model.id}" contextWindow must be a positive integer`);
|
|
708
1311
|
if (model.maxTokens !== void 0 && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) throw new Error(`${label}: catalog model "${model.id}" maxTokens must be a positive integer`);
|
|
709
1312
|
if (model.inputModalities !== void 0 && (model.inputModalities.length === 0 || model.inputModalities.some((modality) => modality !== "text" && modality !== "image"))) throw new Error(`${label}: catalog model "${model.id}" inputModalities must be a non-empty list of "text"/"image"`);
|
|
1313
|
+
if (model.wire !== void 0 && model.wire !== "chat-completions" && model.wire !== "responses") throw new Error(`${label}: catalog model "${model.id}" wire must be "chat-completions" or "responses"`);
|
|
710
1314
|
if (seen.has(model.id)) throw new Error(`${label}: duplicate catalog model "${model.id}"`);
|
|
711
1315
|
seen.add(model.id);
|
|
712
1316
|
return {
|
|
@@ -714,7 +1318,8 @@ function validateModels(models, label) {
|
|
|
714
1318
|
...model.name === void 0 ? {} : { name: model.name },
|
|
715
1319
|
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
716
1320
|
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens },
|
|
717
|
-
...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] }
|
|
1321
|
+
...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] },
|
|
1322
|
+
...model.wire === void 0 ? {} : { wire: model.wire }
|
|
718
1323
|
};
|
|
719
1324
|
});
|
|
720
1325
|
}
|
|
@@ -882,9 +1487,9 @@ var TokenManager = class {
|
|
|
882
1487
|
}
|
|
883
1488
|
}
|
|
884
1489
|
async doRefresh(session) {
|
|
885
|
-
const current = await this.options.load();
|
|
886
|
-
if (current !== void 0 && current.accessToken !== session.accessToken && current.expiresAt - Date.now() > this.options.preemptMs) return current;
|
|
887
|
-
const next = await this.options.refresh(current ?? session);
|
|
1490
|
+
const current$1 = await this.options.load();
|
|
1491
|
+
if (current$1 !== void 0 && current$1.accessToken !== session.accessToken && current$1.expiresAt - Date.now() > this.options.preemptMs) return current$1;
|
|
1492
|
+
const next = await this.options.refresh(current$1 ?? session);
|
|
888
1493
|
await this.options.save(next);
|
|
889
1494
|
return next;
|
|
890
1495
|
}
|
|
@@ -901,7 +1506,7 @@ const DISCOVERY_TTL_MS = 5 * 6e4;
|
|
|
901
1506
|
* while a stale entry refreshes in the background, and only awaits the fetch
|
|
902
1507
|
* when nothing is known yet. An optional {@link CatalogPersistence} seeds the
|
|
903
1508
|
* last-known state across restarts and receives every successful fetch. A 401
|
|
904
|
-
*
|
|
1509
|
+
* that still fails after a forced token refresh must call {@link invalidate}.
|
|
905
1510
|
*/
|
|
906
1511
|
var ModelCatalogCache = class {
|
|
907
1512
|
entry;
|
|
@@ -922,6 +1527,14 @@ var ModelCatalogCache = class {
|
|
|
922
1527
|
if (this.entry === void 0 || Date.now() - this.entry.at >= this.ttlMs) return void 0;
|
|
923
1528
|
return this.entry.models;
|
|
924
1529
|
}
|
|
1530
|
+
/**
|
|
1531
|
+
* The last successfully fetched catalog, ignoring TTL. Used to carry
|
|
1532
|
+
* capability metadata forward when a later fetch cannot re-enrich.
|
|
1533
|
+
* @returns the last-known models, or `undefined` when nothing has been stored.
|
|
1534
|
+
*/
|
|
1535
|
+
lastKnown() {
|
|
1536
|
+
return this.entry?.models;
|
|
1537
|
+
}
|
|
925
1538
|
/** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
|
|
926
1539
|
ensureSeeded() {
|
|
927
1540
|
if (this.persistence === void 0) return Promise.resolve();
|
|
@@ -986,6 +1599,34 @@ var ModelCatalogCache = class {
|
|
|
986
1599
|
this.persistence?.clear().catch(() => void 0);
|
|
987
1600
|
}
|
|
988
1601
|
};
|
|
1602
|
+
/** Whether discovery failed because the stored login is gone. */
|
|
1603
|
+
function isMissingOrInvalidCredential(error) {
|
|
1604
|
+
return error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL");
|
|
1605
|
+
}
|
|
1606
|
+
/** Whether discovery failed because the access token was rejected. */
|
|
1607
|
+
function isDiscoveryAuthFailure(error) {
|
|
1608
|
+
return error instanceof OAuthEndpointError && error.status === 401 || error instanceof LlmError && error.code === "AUTH";
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Run a catalog fetch, retrying once after a forced token refresh when the
|
|
1612
|
+
* first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
|
|
1613
|
+
* when the retry is also an auth failure, so a refresh race cannot erase
|
|
1614
|
+
* last-known capability metadata.
|
|
1615
|
+
*/
|
|
1616
|
+
async function discoverOrRetryAuth(session, catalog, run) {
|
|
1617
|
+
try {
|
|
1618
|
+
return await run();
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
if (isMissingOrInvalidCredential(error) || !isDiscoveryAuthFailure(error)) throw error;
|
|
1621
|
+
try {
|
|
1622
|
+
await session(true);
|
|
1623
|
+
return await run();
|
|
1624
|
+
} catch (retryError) {
|
|
1625
|
+
if (!isMissingOrInvalidCredential(retryError) && isDiscoveryAuthFailure(retryError)) catalog.invalidate();
|
|
1626
|
+
throw retryError;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
989
1630
|
|
|
990
1631
|
//#endregion
|
|
991
1632
|
//#region src/providers/catalog-store.ts
|
|
@@ -1031,6 +1672,12 @@ function sanitizeModel(value) {
|
|
|
1031
1672
|
if (thinkingType !== void 0 && thinkingType !== "enabled" && thinkingType !== "adaptive") return void 0;
|
|
1032
1673
|
const fastTier = raw.fastTier;
|
|
1033
1674
|
if (fastTier !== void 0 && typeof fastTier !== "boolean") return void 0;
|
|
1675
|
+
const copilotWire = raw.copilotWire;
|
|
1676
|
+
if (copilotWire !== void 0 && copilotWire !== "chat-completions" && copilotWire !== "responses") return;
|
|
1677
|
+
const copilotResponses = raw.copilotResponses;
|
|
1678
|
+
if (copilotResponses !== void 0 && typeof copilotResponses !== "boolean") return void 0;
|
|
1679
|
+
const inputModalities = raw.inputModalities;
|
|
1680
|
+
if (inputModalities !== void 0 && (!Array.isArray(inputModalities) || inputModalities.length === 0 || inputModalities.some((modality) => modality !== "text" && modality !== "image"))) return void 0;
|
|
1034
1681
|
return {
|
|
1035
1682
|
id: raw.id,
|
|
1036
1683
|
name: raw.name,
|
|
@@ -1039,7 +1686,10 @@ function sanitizeModel(value) {
|
|
|
1039
1686
|
...raw.priority === void 0 ? {} : { priority: raw.priority },
|
|
1040
1687
|
...reasoning === void 0 ? {} : { reasoning },
|
|
1041
1688
|
...thinkingType === void 0 ? {} : { thinkingType },
|
|
1042
|
-
...fastTier === void 0 ? {} : { fastTier }
|
|
1689
|
+
...fastTier === void 0 ? {} : { fastTier },
|
|
1690
|
+
...copilotWire === void 0 ? {} : { copilotWire },
|
|
1691
|
+
...copilotResponses === void 0 ? {} : { copilotResponses },
|
|
1692
|
+
...inputModalities === void 0 ? {} : { inputModalities: [...inputModalities] }
|
|
1043
1693
|
};
|
|
1044
1694
|
}
|
|
1045
1695
|
/**
|
|
@@ -1221,22 +1871,31 @@ async function* parseSse(stream, onActivity) {
|
|
|
1221
1871
|
//#endregion
|
|
1222
1872
|
//#region src/translate/responses.ts
|
|
1223
1873
|
/** Flatten a tool result's content to plain text for `function_call_output`. */
|
|
1224
|
-
function toolResultText$
|
|
1874
|
+
function toolResultText$2(block) {
|
|
1225
1875
|
return block.content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
1226
1876
|
}
|
|
1227
1877
|
/**
|
|
1228
1878
|
* Convert harness messages into Responses `instructions` + `input` items.
|
|
1229
1879
|
* System-role messages become `instructions`; an explicit `system` argument
|
|
1230
|
-
* wins over them when both exist. Reasoning blocks are
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
1880
|
+
* wins over them when both exist. Reasoning blocks are never replayed in
|
|
1881
|
+
* their text form: a Responses model continuing past a tool call needs its
|
|
1882
|
+
* reasoning back as the provider's completed reasoning items (id, summary,
|
|
1883
|
+
* and the ENCRYPTED payload), so `reasoningFor` may resolve per-call
|
|
1884
|
+
* captured items, replayed ahead of the matching function_call item. Images
|
|
1885
|
+
* must arrive pre-resolved
|
|
1886
|
+
* ({@link TranslatableMessage}); an unresolved ImageBlock is skipped because
|
|
1887
|
+
* its bytes are unreachable here.
|
|
1233
1888
|
* @param messages - ordered conversation messages with resolved images.
|
|
1234
1889
|
* @param system - explicit system prompt, which takes precedence.
|
|
1890
|
+
* @param reasoningFor - resolves one tool call id to the COMPLETED reasoning
|
|
1891
|
+
* items captured for it (id, summary, status, encrypted payload), replayed
|
|
1892
|
+
* ahead of the matching function_call item, when the adapter kept them.
|
|
1235
1893
|
* @returns request fields ready to merge into the request body.
|
|
1236
1894
|
*/
|
|
1237
|
-
function toResponsesInput(messages, system) {
|
|
1895
|
+
function toResponsesInput(messages, system, reasoningFor) {
|
|
1238
1896
|
const input = [];
|
|
1239
1897
|
const systemTexts = [];
|
|
1898
|
+
let lastReplay;
|
|
1240
1899
|
for (const message of messages) {
|
|
1241
1900
|
if (message.role === "system") {
|
|
1242
1901
|
for (const block of message.content) if (block.type === "text") systemTexts.push(block.text);
|
|
@@ -1260,8 +1919,19 @@ function toResponsesInput(messages, system) {
|
|
|
1260
1919
|
text: block.text
|
|
1261
1920
|
});
|
|
1262
1921
|
break;
|
|
1263
|
-
case "tool-call":
|
|
1922
|
+
case "tool-call": {
|
|
1264
1923
|
flushMessage();
|
|
1924
|
+
const encrypted = reasoningFor?.(String(block.id));
|
|
1925
|
+
if (encrypted !== void 0 && encrypted !== lastReplay) {
|
|
1926
|
+
for (const item of encrypted) input.push({
|
|
1927
|
+
type: "reasoning",
|
|
1928
|
+
...item.id === void 0 ? {} : { id: item.id },
|
|
1929
|
+
...item.summary === void 0 ? {} : { summary: item.summary },
|
|
1930
|
+
...item.status === void 0 ? {} : { status: item.status },
|
|
1931
|
+
encrypted_content: item.encrypted_content
|
|
1932
|
+
});
|
|
1933
|
+
lastReplay = encrypted;
|
|
1934
|
+
}
|
|
1265
1935
|
input.push({
|
|
1266
1936
|
type: "function_call",
|
|
1267
1937
|
call_id: String(block.id),
|
|
@@ -1269,12 +1939,13 @@ function toResponsesInput(messages, system) {
|
|
|
1269
1939
|
arguments: block.arguments
|
|
1270
1940
|
});
|
|
1271
1941
|
break;
|
|
1942
|
+
}
|
|
1272
1943
|
case "tool-result":
|
|
1273
1944
|
flushMessage();
|
|
1274
1945
|
input.push({
|
|
1275
1946
|
type: "function_call_output",
|
|
1276
1947
|
call_id: String(block.toolCallId),
|
|
1277
|
-
output: toolResultText$
|
|
1948
|
+
output: toolResultText$2(block)
|
|
1278
1949
|
});
|
|
1279
1950
|
break;
|
|
1280
1951
|
case "image":
|
|
@@ -1336,7 +2007,7 @@ function responsesFailure(code, message) {
|
|
|
1336
2007
|
return new LlmError(text, "SERVER");
|
|
1337
2008
|
}
|
|
1338
2009
|
/** Assemble the final ContentBlock for one open block. */
|
|
1339
|
-
function closeBlock$
|
|
2010
|
+
function closeBlock$2(block) {
|
|
1340
2011
|
switch (block.kind) {
|
|
1341
2012
|
case "text": return {
|
|
1342
2013
|
type: "text",
|
|
@@ -1398,7 +2069,7 @@ var ResponsesStreamTranslator = class {
|
|
|
1398
2069
|
chunks.push({
|
|
1399
2070
|
type: "block-end",
|
|
1400
2071
|
index: block.index,
|
|
1401
|
-
block: closeBlock$
|
|
2072
|
+
block: closeBlock$2(block)
|
|
1402
2073
|
});
|
|
1403
2074
|
}
|
|
1404
2075
|
/** Close every still-open block for one output item (prefix match on the key). */
|
|
@@ -1415,7 +2086,7 @@ var ResponsesStreamTranslator = class {
|
|
|
1415
2086
|
chunks.push({
|
|
1416
2087
|
type: "block-end",
|
|
1417
2088
|
index: block.index,
|
|
1418
|
-
block: closeBlock$
|
|
2089
|
+
block: closeBlock$2(block)
|
|
1419
2090
|
});
|
|
1420
2091
|
return;
|
|
1421
2092
|
}
|
|
@@ -1540,9 +2211,12 @@ var ResponsesStreamTranslator = class {
|
|
|
1540
2211
|
* Consume a Responses SSE byte stream and yield harness StreamChunks.
|
|
1541
2212
|
* @param stream - raw response body.
|
|
1542
2213
|
* @param onActivity - transport-activity callback for the idle watchdog.
|
|
2214
|
+
* @param transform - optional per-event rewrite applied before translation
|
|
2215
|
+
* (Copilot's gateway mints a fresh item id per event; the adapter rewrites
|
|
2216
|
+
* them into stable per-item keys).
|
|
1543
2217
|
* @returns the chunk stream; throws when the stream ends before `response.completed`.
|
|
1544
2218
|
*/
|
|
1545
|
-
async function* streamResponses(stream, onActivity) {
|
|
2219
|
+
async function* streamResponses(stream, onActivity, transform) {
|
|
1546
2220
|
const translator = new ResponsesStreamTranslator();
|
|
1547
2221
|
for await (const sseEvent of parseSse(stream, onActivity)) {
|
|
1548
2222
|
let event;
|
|
@@ -1551,6 +2225,7 @@ async function* streamResponses(stream, onActivity) {
|
|
|
1551
2225
|
} catch {
|
|
1552
2226
|
throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, "MALFORMED_RESPONSE");
|
|
1553
2227
|
}
|
|
2228
|
+
if (transform !== void 0) event = transform(event);
|
|
1554
2229
|
yield* translator.push(event);
|
|
1555
2230
|
if (translator.terminated) return;
|
|
1556
2231
|
}
|
|
@@ -1697,7 +2372,7 @@ function codexSession(tokens, fallback) {
|
|
|
1697
2372
|
* @returns the session to store.
|
|
1698
2373
|
*/
|
|
1699
2374
|
async function exchangeCodexCode(code, verifier, redirectUri) {
|
|
1700
|
-
const response = await
|
|
2375
|
+
const response = await proxiedFetch(CODEX_TOKEN_URL, {
|
|
1701
2376
|
method: "POST",
|
|
1702
2377
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1703
2378
|
body: new URLSearchParams({
|
|
@@ -1717,7 +2392,7 @@ async function exchangeCodexCode(code, verifier, redirectUri) {
|
|
|
1717
2392
|
* @returns the fresh session to store.
|
|
1718
2393
|
*/
|
|
1719
2394
|
async function refreshCodex(session) {
|
|
1720
|
-
const response = await
|
|
2395
|
+
const response = await proxiedFetch(CODEX_TOKEN_URL, {
|
|
1721
2396
|
method: "POST",
|
|
1722
2397
|
headers: { "content-type": "application/json" },
|
|
1723
2398
|
body: JSON.stringify({
|
|
@@ -1785,7 +2460,7 @@ function codexUsageWindow(value, fallbackKind) {
|
|
|
1785
2460
|
* @param signal - caller cancellation from the RPC transport.
|
|
1786
2461
|
* @returns the mapped usage snapshot.
|
|
1787
2462
|
*/
|
|
1788
|
-
async function fetchCodexUsage(session, fetchFn =
|
|
2463
|
+
async function fetchCodexUsage(session, fetchFn = proxiedFetch, signal) {
|
|
1789
2464
|
const response = await fetchFn(CODEX_USAGE_URL, {
|
|
1790
2465
|
headers: {
|
|
1791
2466
|
"authorization": `Bearer ${session.accessToken}`,
|
|
@@ -1835,7 +2510,7 @@ function supportsFastTier(entry) {
|
|
|
1835
2510
|
* @param fetchFn - fetch implementation (injectable for tests).
|
|
1836
2511
|
* @returns discovered models: hidden entries dropped, sorted by priority.
|
|
1837
2512
|
*/
|
|
1838
|
-
async function fetchCodexModels(session, fetchFn =
|
|
2513
|
+
async function fetchCodexModels(session, fetchFn = proxiedFetch) {
|
|
1839
2514
|
const response = await fetchFn(`${CODEX_MODELS_URL}?client_version=${CODEX_CLIENT_VERSION}`, { headers: {
|
|
1840
2515
|
"authorization": `Bearer ${session.accessToken}`,
|
|
1841
2516
|
"chatgpt-account-id": session.accountId,
|
|
@@ -1874,6 +2549,50 @@ async function fetchCodexModels(session, fetchFn = fetch) {
|
|
|
1874
2549
|
if (discovered.length === 0) throw new Error(`codex models endpoint returned an empty catalog (client_version ${CODEX_CLIENT_VERSION})`);
|
|
1875
2550
|
return discovered;
|
|
1876
2551
|
}
|
|
2552
|
+
const CODEX_CALL_ID_MAX_LENGTH = 64;
|
|
2553
|
+
const CODEX_CALL_ID_PREFIX = "call_";
|
|
2554
|
+
/**
|
|
2555
|
+
* Bound tool-call ids at the Codex wire boundary without changing the shared
|
|
2556
|
+
* Responses translation used by Grok. Short ids stay verbatim. Oversized ids
|
|
2557
|
+
* become deterministic hashes, and every id already present in this request
|
|
2558
|
+
* is reserved first so a generated id cannot collide with a legitimate short
|
|
2559
|
+
* one (or another oversized id).
|
|
2560
|
+
*/
|
|
2561
|
+
function normalizeCodexCallIds(input) {
|
|
2562
|
+
const mapping = /* @__PURE__ */ new Map();
|
|
2563
|
+
const used = /* @__PURE__ */ new Set();
|
|
2564
|
+
const callId = (item) => (item.type === "function_call" || item.type === "function_call_output") && typeof item.call_id === "string" ? item.call_id : void 0;
|
|
2565
|
+
for (const item of input) {
|
|
2566
|
+
const id = callId(item);
|
|
2567
|
+
if (id !== void 0 && id.length <= CODEX_CALL_ID_MAX_LENGTH) {
|
|
2568
|
+
mapping.set(id, id);
|
|
2569
|
+
used.add(id);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
for (const item of input) {
|
|
2573
|
+
const id = callId(item);
|
|
2574
|
+
if (id === void 0 || mapping.has(id)) continue;
|
|
2575
|
+
let attempt = 0;
|
|
2576
|
+
let normalized;
|
|
2577
|
+
do {
|
|
2578
|
+
const hash = createHash("sha256");
|
|
2579
|
+
if (attempt > 0) hash.update(String(attempt)).update("\0");
|
|
2580
|
+
normalized = `${CODEX_CALL_ID_PREFIX}${hash.update(id).digest("hex").slice(0, CODEX_CALL_ID_MAX_LENGTH - 5)}`;
|
|
2581
|
+
attempt += 1;
|
|
2582
|
+
} while (used.has(normalized));
|
|
2583
|
+
mapping.set(id, normalized);
|
|
2584
|
+
used.add(normalized);
|
|
2585
|
+
}
|
|
2586
|
+
return input.map((item) => {
|
|
2587
|
+
const id = callId(item);
|
|
2588
|
+
if (id === void 0) return item;
|
|
2589
|
+
const normalized = mapping.get(id) ?? id;
|
|
2590
|
+
return normalized === id ? item : {
|
|
2591
|
+
...item,
|
|
2592
|
+
call_id: normalized
|
|
2593
|
+
};
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
1877
2596
|
/**
|
|
1878
2597
|
* The Responses request body for one generation. A fast-tier request (the
|
|
1879
2598
|
* composer Speed toggle, the codex CLI's fast mode) carries
|
|
@@ -1884,7 +2603,7 @@ function codexRequestBody(options, resolved, fast) {
|
|
|
1884
2603
|
return {
|
|
1885
2604
|
model: options.model,
|
|
1886
2605
|
instructions: resolved.instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
|
|
1887
|
-
input: resolved.input,
|
|
2606
|
+
input: normalizeCodexCallIds(resolved.input),
|
|
1888
2607
|
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toResponsesTools(options.tools) } : {},
|
|
1889
2608
|
tool_choice: "auto",
|
|
1890
2609
|
parallel_tool_calls: true,
|
|
@@ -1929,7 +2648,7 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
1929
2648
|
if (await this.options.tokens.peek() === void 0) return [];
|
|
1930
2649
|
if (!this.options.discovery) return this.staticModels(provider);
|
|
1931
2650
|
try {
|
|
1932
|
-
return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
|
|
2651
|
+
return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
|
|
1933
2652
|
provider,
|
|
1934
2653
|
id: model.id,
|
|
1935
2654
|
name: model.name,
|
|
@@ -1937,8 +2656,7 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
1937
2656
|
inputModalities: CODEX_MODALITIES
|
|
1938
2657
|
}));
|
|
1939
2658
|
} catch (error) {
|
|
1940
|
-
if (
|
|
1941
|
-
if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
|
|
2659
|
+
if (isMissingOrInvalidCredential(error)) return [];
|
|
1942
2660
|
this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
1943
2661
|
return this.staticModels(provider);
|
|
1944
2662
|
}
|
|
@@ -2005,7 +2723,7 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
2005
2723
|
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
2006
2724
|
const fast = this.options.speedFor !== void 0 && await this.options.speedFor(options.sessionId, options.model);
|
|
2007
2725
|
const body = codexRequestBody(options, toResponsesInput(messages, options.system), fast);
|
|
2008
|
-
return
|
|
2726
|
+
return proxiedFetch(CODEX_API_URL, {
|
|
2009
2727
|
method: "POST",
|
|
2010
2728
|
headers: {
|
|
2011
2729
|
"authorization": `Bearer ${session.accessToken}`,
|
|
@@ -2030,8 +2748,27 @@ var CodexAdapter = class extends LlmAdapter {
|
|
|
2030
2748
|
* system entry on every request.
|
|
2031
2749
|
*/
|
|
2032
2750
|
const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
2751
|
+
/** Tags wrapping a mid-conversation system message where it sits in the history. */
|
|
2752
|
+
const SYSTEM_REMINDER_OPEN = "<system-reminder>";
|
|
2753
|
+
const SYSTEM_REMINDER_CLOSE = "</system-reminder>";
|
|
2754
|
+
/**
|
|
2755
|
+
* How far apart consecutive message breakpoints sit, in content blocks.
|
|
2756
|
+
*
|
|
2757
|
+
* A breakpoint looks back at most 20 blocks for an entry an earlier request
|
|
2758
|
+
* wrote, so marks must stay closer than that: one agentic turn can append a
|
|
2759
|
+
* dozen tool_use/tool_result blocks at once, and a single trailing mark would
|
|
2760
|
+
* silently fall out of range and rebuild the whole prefix.
|
|
2761
|
+
*/
|
|
2762
|
+
const CACHE_BLOCK_STRIDE = 15;
|
|
2763
|
+
/**
|
|
2764
|
+
* Message breakpoints per request. Anthropic allows four in total and the
|
|
2765
|
+
* last `system` block takes the fourth, so three are left for the history —
|
|
2766
|
+
* enough to tolerate a turn appending roughly {@link CACHE_BLOCK_STRIDE} × 3
|
|
2767
|
+
* blocks before a read is lost.
|
|
2768
|
+
*/
|
|
2769
|
+
const MESSAGE_CACHE_BREAKPOINTS = 3;
|
|
2033
2770
|
/** Flatten a tool result's content to plain text for `tool_result`. */
|
|
2034
|
-
function toolResultText(block) {
|
|
2771
|
+
function toolResultText$1(block) {
|
|
2035
2772
|
return block.content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
2036
2773
|
}
|
|
2037
2774
|
/** Parse a tool call's raw JSON arguments into Anthropic's object-shaped `input`. */
|
|
@@ -2045,11 +2782,49 @@ function parseToolInput(raw) {
|
|
|
2045
2782
|
}
|
|
2046
2783
|
}
|
|
2047
2784
|
/**
|
|
2785
|
+
* Move a user message's `tool_result` blocks into one contiguous run at the
|
|
2786
|
+
* front, preserving the relative order of both groups.
|
|
2787
|
+
*
|
|
2788
|
+
* Anthropic answers every `tool_use` against the blocks that *lead* the next
|
|
2789
|
+
* message, so a block of any other kind before or between the results reads
|
|
2790
|
+
* as a call left unanswered and the request is rejected. The harness merges
|
|
2791
|
+
* everything queued for one user turn into a single message, and a parallel
|
|
2792
|
+
* tool batch arrives as one result message per call, so any context spliced
|
|
2793
|
+
* mid-batch lands between two results. Restoring the run here keeps that
|
|
2794
|
+
* independent of delivery order. Order *among* the results does not matter.
|
|
2795
|
+
* @param message - one assembled user message, reordered in place.
|
|
2796
|
+
*/
|
|
2797
|
+
function leadWithToolResults(message) {
|
|
2798
|
+
const firstOther = message.content.findIndex((block) => block.type !== "tool_result");
|
|
2799
|
+
if (firstOther === -1) return;
|
|
2800
|
+
if (!message.content.slice(firstOther).some((block) => block.type === "tool_result")) return;
|
|
2801
|
+
message.content = [...message.content.filter((block) => block.type === "tool_result"), ...message.content.filter((block) => block.type !== "tool_result")];
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* Index of the first non-system message; `messages.length` when every message
|
|
2805
|
+
* is a system one.
|
|
2806
|
+
*
|
|
2807
|
+
* A system message before the conversation starts is the operator's opening
|
|
2808
|
+
* instruction and belongs in the `system` slot. One that arrives later is
|
|
2809
|
+
* mid-conversation context, and hoisting it into `system` would move bytes in
|
|
2810
|
+
* front of the whole history — invalidating every cached turn behind it — so
|
|
2811
|
+
* it stays where it is, as a reminder block in `messages`.
|
|
2812
|
+
* @param messages - ordered conversation messages.
|
|
2813
|
+
* @returns the boundary index separating the two.
|
|
2814
|
+
*/
|
|
2815
|
+
function conversationStart(messages) {
|
|
2816
|
+
const index = messages.findIndex((message) => message.role !== "system");
|
|
2817
|
+
return index === -1 ? messages.length : index;
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2048
2820
|
* Convert harness messages into Anthropic messages. Consecutive same-role
|
|
2049
2821
|
* messages merge into one message with multiple content blocks; tool results
|
|
2050
|
-
* arrive as user messages with `tool_result` blocks
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2822
|
+
* arrive as user messages with `tool_result` blocks, which a merged user
|
|
2823
|
+
* message keeps in one leading run ({@link leadWithToolResults}); system-role
|
|
2824
|
+
* messages before the conversation starts are handled by
|
|
2825
|
+
* {@link toAnthropicSystem} and skipped here, while a later one rides in
|
|
2826
|
+
* place as a user-role `<system-reminder>` block.
|
|
2827
|
+
* Reasoning blocks are not replayed (v1). Images must arrive pre-resolved
|
|
2053
2828
|
* ({@link TranslatableMessage}); an unresolved ImageBlock is skipped because
|
|
2054
2829
|
* its bytes are unreachable here.
|
|
2055
2830
|
* @param messages - ordered conversation messages with resolved images.
|
|
@@ -2057,30 +2832,34 @@ function parseToolInput(raw) {
|
|
|
2057
2832
|
*/
|
|
2058
2833
|
function toAnthropicMessages(messages) {
|
|
2059
2834
|
const out = [];
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2835
|
+
const start = conversationStart(messages);
|
|
2836
|
+
for (const [index, message] of messages.entries()) {
|
|
2837
|
+
if (message.role === "system" && index < start) continue;
|
|
2838
|
+
const role = message.role === "system" ? "user" : message.role;
|
|
2063
2839
|
const blocks = [];
|
|
2064
2840
|
for (const block of message.content) switch (block.type) {
|
|
2065
2841
|
case "text":
|
|
2066
2842
|
blocks.push({
|
|
2067
2843
|
type: "text",
|
|
2068
|
-
text: block.text
|
|
2844
|
+
text: message.role === "system" ? `${SYSTEM_REMINDER_OPEN}${block.text}${SYSTEM_REMINDER_CLOSE}` : block.text
|
|
2069
2845
|
});
|
|
2070
2846
|
break;
|
|
2071
2847
|
case "tool-call":
|
|
2072
|
-
blocks.push({
|
|
2848
|
+
blocks.push(role === "assistant" ? {
|
|
2073
2849
|
type: "tool_use",
|
|
2074
2850
|
id: String(block.id),
|
|
2075
2851
|
name: block.name,
|
|
2076
2852
|
input: parseToolInput(block.arguments)
|
|
2853
|
+
} : {
|
|
2854
|
+
type: "text",
|
|
2855
|
+
text: `[tool call ${block.name}: ${block.arguments}]`
|
|
2077
2856
|
});
|
|
2078
2857
|
break;
|
|
2079
2858
|
case "tool-result":
|
|
2080
2859
|
blocks.push({
|
|
2081
2860
|
type: "tool_result",
|
|
2082
2861
|
tool_use_id: String(block.toolCallId),
|
|
2083
|
-
content: toolResultText(block),
|
|
2862
|
+
content: toolResultText$1(block),
|
|
2084
2863
|
...block.isError === true ? { is_error: true } : {}
|
|
2085
2864
|
});
|
|
2086
2865
|
break;
|
|
@@ -2104,13 +2883,34 @@ function toAnthropicMessages(messages) {
|
|
|
2104
2883
|
content: blocks
|
|
2105
2884
|
});
|
|
2106
2885
|
}
|
|
2886
|
+
for (const message of out) if (message.role === "user") leadWithToolResults(message);
|
|
2107
2887
|
return out;
|
|
2108
2888
|
}
|
|
2109
2889
|
/**
|
|
2890
|
+
* Mark the conversation's cache breakpoints in place: the last content block,
|
|
2891
|
+
* then one every {@link CACHE_BLOCK_STRIDE} blocks backwards, {@link
|
|
2892
|
+
* MESSAGE_CACHE_BREAKPOINTS} in total.
|
|
2893
|
+
*
|
|
2894
|
+
* The history is append-only, so the block one request marks last is
|
|
2895
|
+
* byte-identical in the next — that entry is what the next request reads.
|
|
2896
|
+
* Marks are counted across the flattened block sequence, not per message,
|
|
2897
|
+
* because the lookback window Anthropic walks counts blocks the same way.
|
|
2898
|
+
* @param messages - assembled Anthropic messages, marked in place.
|
|
2899
|
+
*/
|
|
2900
|
+
function markMessageCache(messages) {
|
|
2901
|
+
const blocks = messages.flatMap((message) => message.content);
|
|
2902
|
+
for (let mark = 0; mark < MESSAGE_CACHE_BREAKPOINTS; mark++) {
|
|
2903
|
+
const at = blocks.length - 1 - mark * CACHE_BLOCK_STRIDE;
|
|
2904
|
+
if (at < 0) return;
|
|
2905
|
+
blocks[at].cache_control = { type: "ephemeral" };
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
/**
|
|
2110
2909
|
* Build the Anthropic `system` array: the mandatory Claude Code identity
|
|
2111
2910
|
* block, then the explicit system prompt, then any system-role messages.
|
|
2112
2911
|
* @param system - explicit system prompt, when set.
|
|
2113
|
-
* @param messages - conversation messages;
|
|
2912
|
+
* @param messages - conversation messages; the system-role text preceding the
|
|
2913
|
+
* conversation is appended, and a later one is left to {@link toAnthropicMessages}.
|
|
2114
2914
|
* @returns the system content blocks.
|
|
2115
2915
|
*/
|
|
2116
2916
|
function toAnthropicSystem(system, messages) {
|
|
@@ -2122,29 +2922,34 @@ function toAnthropicSystem(system, messages) {
|
|
|
2122
2922
|
type: "text",
|
|
2123
2923
|
text: system
|
|
2124
2924
|
});
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
}
|
|
2925
|
+
const history = messages ?? [];
|
|
2926
|
+
for (const message of history.slice(0, conversationStart(history))) for (const block of message.content) if (block.type === "text") blocks.push({
|
|
2927
|
+
type: "text",
|
|
2928
|
+
text: block.text
|
|
2929
|
+
});
|
|
2930
|
+
blocks[blocks.length - 1].cache_control = { type: "ephemeral" };
|
|
2132
2931
|
return blocks;
|
|
2133
2932
|
}
|
|
2134
2933
|
/**
|
|
2135
|
-
* Map harness tool schemas to Anthropic tools.
|
|
2934
|
+
* Map harness tool schemas to Anthropic tools, in name order.
|
|
2935
|
+
*
|
|
2936
|
+
* `tools` renders at position 0 of the cached prefix, so any reordering
|
|
2937
|
+
* invalidates every cache entry behind it — `system` and the whole
|
|
2938
|
+
* conversation included. Registration order belongs to the caller and plugin
|
|
2939
|
+
* load order can differ between processes, so the wire order is fixed here
|
|
2940
|
+
* instead. Anthropic selects a tool by name; the array order carries nothing.
|
|
2136
2941
|
* @param tools - tool schemas from the request.
|
|
2137
|
-
* @returns Anthropic `tools` array entries.
|
|
2942
|
+
* @returns Anthropic `tools` array entries, ordered by tool name.
|
|
2138
2943
|
*/
|
|
2139
2944
|
function toAnthropicTools(tools) {
|
|
2140
|
-
return tools.map((tool) => ({
|
|
2945
|
+
return [...tools].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0).map((tool) => ({
|
|
2141
2946
|
name: tool.name,
|
|
2142
2947
|
description: tool.description,
|
|
2143
2948
|
input_schema: tool.parameters
|
|
2144
2949
|
}));
|
|
2145
2950
|
}
|
|
2146
2951
|
/** Assemble the final ContentBlock for one open block. */
|
|
2147
|
-
function closeBlock(block) {
|
|
2952
|
+
function closeBlock$1(block) {
|
|
2148
2953
|
switch (block.kind) {
|
|
2149
2954
|
case "text": return {
|
|
2150
2955
|
type: "text",
|
|
@@ -2313,7 +3118,7 @@ var AnthropicStreamTranslator = class {
|
|
|
2313
3118
|
chunks.push({
|
|
2314
3119
|
type: "block-end",
|
|
2315
3120
|
index: block.index,
|
|
2316
|
-
block: closeBlock(block)
|
|
3121
|
+
block: closeBlock$1(block)
|
|
2317
3122
|
});
|
|
2318
3123
|
return chunks;
|
|
2319
3124
|
}
|
|
@@ -2340,7 +3145,7 @@ var AnthropicStreamTranslator = class {
|
|
|
2340
3145
|
chunks.push({
|
|
2341
3146
|
type: "block-end",
|
|
2342
3147
|
index: block.index,
|
|
2343
|
-
block: closeBlock(block)
|
|
3148
|
+
block: closeBlock$1(block)
|
|
2344
3149
|
});
|
|
2345
3150
|
}
|
|
2346
3151
|
this.emitUsage(chunks);
|
|
@@ -2388,11 +3193,13 @@ async function* streamAnthropic(stream, onActivity) {
|
|
|
2388
3193
|
//#endregion
|
|
2389
3194
|
//#region src/providers/claude.ts
|
|
2390
3195
|
const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
3196
|
+
const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
2391
3197
|
const CLAUDE_TOKEN_URL = "https://claude.ai/v1/oauth/token";
|
|
2392
3198
|
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
|
|
2393
3199
|
const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
2394
3200
|
const CLAUDE_MODELS_URL = "https://api.anthropic.com/v1/models?beta=true";
|
|
2395
3201
|
const CLAUDE_SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
|
3202
|
+
const CLAUDE_CALLBACK_PATH = "/callback";
|
|
2396
3203
|
const CLAUDE_CONTEXT_WINDOW = 2e5;
|
|
2397
3204
|
const CLAUDE_DEFAULT_MAX_TOKENS = 32e3;
|
|
2398
3205
|
/** Refresh when the access token has less than this much life left. */
|
|
@@ -2428,10 +3235,30 @@ const CLAUDE_BETA_FALLBACK = [
|
|
|
2428
3235
|
"files-api-2025-04-14"
|
|
2429
3236
|
].join(",");
|
|
2430
3237
|
const CLAUDE_BETA_FLAGS = CLAUDE_BETA_FALLBACK;
|
|
3238
|
+
/** Static claude flow facts for the OAuth flow engine. */
|
|
3239
|
+
const claudeFlow = {
|
|
3240
|
+
callbackPath: CLAUDE_CALLBACK_PATH,
|
|
3241
|
+
listen: {
|
|
3242
|
+
host: "localhost",
|
|
3243
|
+
ports: [0]
|
|
3244
|
+
},
|
|
3245
|
+
buildAuthorizeUrl({ redirectUri, state, pkce }) {
|
|
3246
|
+
return `${CLAUDE_AUTHORIZE_URL}?${new URLSearchParams({
|
|
3247
|
+
code: "true",
|
|
3248
|
+
client_id: CLAUDE_CLIENT_ID,
|
|
3249
|
+
response_type: "code",
|
|
3250
|
+
redirect_uri: redirectUri,
|
|
3251
|
+
scope: CLAUDE_SCOPE,
|
|
3252
|
+
code_challenge: pkce.challenge,
|
|
3253
|
+
code_challenge_method: "S256",
|
|
3254
|
+
state
|
|
3255
|
+
}).toString()}`;
|
|
3256
|
+
}
|
|
3257
|
+
};
|
|
2431
3258
|
/** Best-effort account profile; login must not fail when this does. */
|
|
2432
3259
|
async function fetchClaudeProfile(accessToken) {
|
|
2433
3260
|
try {
|
|
2434
|
-
const response = await
|
|
3261
|
+
const response = await proxiedFetch(CLAUDE_PROFILE_URL, { headers: { authorization: `Bearer ${accessToken}` } });
|
|
2435
3262
|
if (!response.ok) return {};
|
|
2436
3263
|
const profile = await response.json();
|
|
2437
3264
|
const account = typeof profile.account === "object" && profile.account !== null ? profile.account : {};
|
|
@@ -2469,7 +3296,7 @@ async function claudeSession(tokens, fallbackRefreshToken, withProfile) {
|
|
|
2469
3296
|
* @returns the session to store.
|
|
2470
3297
|
*/
|
|
2471
3298
|
async function exchangeClaudeCode(code, verifier, redirectUri, state) {
|
|
2472
|
-
const response = await
|
|
3299
|
+
const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
|
|
2473
3300
|
method: "POST",
|
|
2474
3301
|
headers: { "content-type": "application/json" },
|
|
2475
3302
|
body: JSON.stringify({
|
|
@@ -2490,7 +3317,7 @@ async function exchangeClaudeCode(code, verifier, redirectUri, state) {
|
|
|
2490
3317
|
* @returns the fresh session to store.
|
|
2491
3318
|
*/
|
|
2492
3319
|
async function refreshClaude(session) {
|
|
2493
|
-
const response = await
|
|
3320
|
+
const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
|
|
2494
3321
|
method: "POST",
|
|
2495
3322
|
headers: { "content-type": "application/json" },
|
|
2496
3323
|
body: JSON.stringify({
|
|
@@ -2565,7 +3392,7 @@ function claudeLimitsWindows(value) {
|
|
|
2565
3392
|
* @param signal - caller cancellation from the RPC transport.
|
|
2566
3393
|
* @returns the mapped usage snapshot.
|
|
2567
3394
|
*/
|
|
2568
|
-
async function fetchClaudeUsage(session, fetchFn =
|
|
3395
|
+
async function fetchClaudeUsage(session, fetchFn = proxiedFetch, signal) {
|
|
2569
3396
|
const response = await fetchFn(CLAUDE_USAGE_URL, {
|
|
2570
3397
|
headers: {
|
|
2571
3398
|
"authorization": `Bearer ${session.accessToken}`,
|
|
@@ -2618,7 +3445,7 @@ function claudeReasoning(capabilities) {
|
|
|
2618
3445
|
return efforts.length > 0 ? { efforts } : void 0;
|
|
2619
3446
|
}
|
|
2620
3447
|
/** Fetch the live model catalog from the subscription endpoint. */
|
|
2621
|
-
async function fetchClaudeModels(session, fetchFn =
|
|
3448
|
+
async function fetchClaudeModels(session, fetchFn = proxiedFetch) {
|
|
2622
3449
|
const response = await fetchFn(CLAUDE_MODELS_URL, { headers: {
|
|
2623
3450
|
"authorization": `Bearer ${session.accessToken}`,
|
|
2624
3451
|
"anthropic-version": "2023-06-01",
|
|
@@ -2652,6 +3479,36 @@ const CLAUDE_RETRY_MAX_DELAY_MS = 6e4;
|
|
|
2652
3479
|
const CLAUDE_RETRY_JITTER_RATIO = .2;
|
|
2653
3480
|
/** The Claude 4.5 family accepts image input. */
|
|
2654
3481
|
const CLAUDE_MODALITIES = ["text", "image"];
|
|
3482
|
+
/**
|
|
3483
|
+
* Assemble the Anthropic request body.
|
|
3484
|
+
*
|
|
3485
|
+
* Extracted from the adapter so the wire shape — cache breakpoints above all —
|
|
3486
|
+
* is testable without a network round trip. The message array is marked before
|
|
3487
|
+
* it is placed so the breakpoints land on the blocks the body ships: one on the
|
|
3488
|
+
* last `system` block (covering `tools` + `system`, which render ahead of it)
|
|
3489
|
+
* and up to three across the history, Anthropic's four-slot maximum.
|
|
3490
|
+
* @param options - the generate request.
|
|
3491
|
+
* @param messages - conversation messages with images already resolved.
|
|
3492
|
+
* @param maxTokens - the resolved output cap.
|
|
3493
|
+
* @param thinking - the thinking parameter, when the model takes one.
|
|
3494
|
+
* @param effort - the reasoning effort, when the model advertises efforts.
|
|
3495
|
+
* @returns the JSON body to POST.
|
|
3496
|
+
*/
|
|
3497
|
+
function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
|
|
3498
|
+
const anthropicMessages = toAnthropicMessages(messages);
|
|
3499
|
+
markMessageCache(anthropicMessages);
|
|
3500
|
+
return {
|
|
3501
|
+
model: options.model,
|
|
3502
|
+
max_tokens: maxTokens,
|
|
3503
|
+
system: toAnthropicSystem(options.system, messages),
|
|
3504
|
+
messages: anthropicMessages,
|
|
3505
|
+
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toAnthropicTools(options.tools) } : {},
|
|
3506
|
+
...thinking === void 0 ? {} : { thinking },
|
|
3507
|
+
...effort === void 0 ? {} : { output_config: { effort } },
|
|
3508
|
+
stream: true,
|
|
3509
|
+
...options.sessionId !== void 0 ? { metadata: { user_id: String(options.sessionId) } } : {}
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
2655
3512
|
/** Claude wire adapter: one instance serves the `claude` provider route. */
|
|
2656
3513
|
var ClaudeAdapter = class extends LlmAdapter {
|
|
2657
3514
|
catalog;
|
|
@@ -2697,15 +3554,14 @@ var ClaudeAdapter = class extends LlmAdapter {
|
|
|
2697
3554
|
if (await this.options.tokens.peek() === void 0) return [];
|
|
2698
3555
|
if (!this.options.discovery) return this.staticModels(provider);
|
|
2699
3556
|
try {
|
|
2700
|
-
return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
|
|
3557
|
+
return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
|
|
2701
3558
|
provider,
|
|
2702
3559
|
id: model.id,
|
|
2703
3560
|
name: model.name,
|
|
2704
3561
|
inputModalities: CLAUDE_MODALITIES
|
|
2705
3562
|
}));
|
|
2706
3563
|
} catch (error) {
|
|
2707
|
-
if (
|
|
2708
|
-
if (error instanceof LlmError && error.code === "AUTH") this.catalog.invalidate();
|
|
3564
|
+
if (isMissingOrInvalidCredential(error)) return [];
|
|
2709
3565
|
this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
2710
3566
|
return this.staticModels(provider);
|
|
2711
3567
|
}
|
|
@@ -2770,20 +3626,8 @@ var ClaudeAdapter = class extends LlmAdapter {
|
|
|
2770
3626
|
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
2771
3627
|
const maxTokens = options.maxTokens ?? this.options.models.find((entry) => entry.id === options.model)?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS;
|
|
2772
3628
|
const disc = await this.discovered(options.model);
|
|
2773
|
-
const
|
|
2774
|
-
|
|
2775
|
-
const body = {
|
|
2776
|
-
model: options.model,
|
|
2777
|
-
max_tokens: maxTokens,
|
|
2778
|
-
system: toAnthropicSystem(options.system, messages),
|
|
2779
|
-
messages: toAnthropicMessages(messages),
|
|
2780
|
-
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toAnthropicTools(options.tools) } : {},
|
|
2781
|
-
...thinking === void 0 ? {} : { thinking },
|
|
2782
|
-
...effort,
|
|
2783
|
-
stream: true,
|
|
2784
|
-
...options.sessionId !== void 0 ? { metadata: { user_id: String(options.sessionId) } } : {}
|
|
2785
|
-
};
|
|
2786
|
-
return fetch(CLAUDE_API_URL, {
|
|
3629
|
+
const body = claudeRequestBody(options, messages, maxTokens, this.thinkingParam(disc?.thinkingType, maxTokens), options.reasoningEffort !== void 0 && disc?.reasoning !== void 0 ? String(options.reasoningEffort) : void 0);
|
|
3630
|
+
return proxiedFetch(CLAUDE_API_URL, {
|
|
2787
3631
|
method: "POST",
|
|
2788
3632
|
headers: {
|
|
2789
3633
|
"authorization": `Bearer ${session.accessToken}`,
|
|
@@ -2830,7 +3674,7 @@ let discoveryCache;
|
|
|
2830
3674
|
*/
|
|
2831
3675
|
async function grokDiscovery() {
|
|
2832
3676
|
if (discoveryCache !== void 0) return discoveryCache;
|
|
2833
|
-
const response = await
|
|
3677
|
+
const response = await proxiedFetch(GROK_DISCOVERY_URL);
|
|
2834
3678
|
if (!response.ok) throw await oauthEndpointError(response, "grok OIDC discovery");
|
|
2835
3679
|
const document = await response.json();
|
|
2836
3680
|
if (typeof document.authorization_endpoint !== "string" || typeof document.token_endpoint !== "string") throw new Error("grok OIDC discovery document is missing endpoints");
|
|
@@ -2931,7 +3775,7 @@ function grokSession(tokens, tokenEndpoint, fallbackRefreshToken) {
|
|
|
2931
3775
|
*/
|
|
2932
3776
|
async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
|
|
2933
3777
|
const discovery = await grokDiscovery();
|
|
2934
|
-
const response = await
|
|
3778
|
+
const response = await proxiedFetch(discovery.tokenEndpoint, {
|
|
2935
3779
|
method: "POST",
|
|
2936
3780
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2937
3781
|
body: new URLSearchParams({
|
|
@@ -2954,7 +3798,7 @@ async function exchangeGrokCode(code, verifier, redirectUri, challenge) {
|
|
|
2954
3798
|
* @returns the fresh session to store.
|
|
2955
3799
|
*/
|
|
2956
3800
|
async function refreshGrok(session) {
|
|
2957
|
-
const response = await
|
|
3801
|
+
const response = await proxiedFetch(session.tokenEndpoint, {
|
|
2958
3802
|
method: "POST",
|
|
2959
3803
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
2960
3804
|
body: new URLSearchParams({
|
|
@@ -3001,7 +3845,7 @@ function grokResetsAt(value) {
|
|
|
3001
3845
|
* @param signal - caller cancellation from the RPC transport.
|
|
3002
3846
|
* @returns the mapped usage snapshot.
|
|
3003
3847
|
*/
|
|
3004
|
-
async function fetchGrokUsage(session, fetchFn =
|
|
3848
|
+
async function fetchGrokUsage(session, fetchFn = proxiedFetch, signal) {
|
|
3005
3849
|
const response = await fetchFn(GROK_BILLING_URL, {
|
|
3006
3850
|
headers: {
|
|
3007
3851
|
"authorization": `Bearer ${session.accessToken}`,
|
|
@@ -3034,120 +3878,1067 @@ async function fetchGrokUsage(session, fetchFn = fetch, signal) {
|
|
|
3034
3878
|
}
|
|
3035
3879
|
const plan = typeof payload.subscriptionTier === "string" && payload.subscriptionTier.length > 0 ? payload.subscriptionTier : grokTierName(session.accessToken);
|
|
3036
3880
|
return {
|
|
3037
|
-
supported: true,
|
|
3038
|
-
windows,
|
|
3039
|
-
...plan === void 0 ? {} : { plan }
|
|
3881
|
+
supported: true,
|
|
3882
|
+
windows,
|
|
3883
|
+
...plan === void 0 ? {} : { plan }
|
|
3884
|
+
};
|
|
3885
|
+
}
|
|
3886
|
+
const GROK_MODELS_URL = "https://api.x.ai/v1/models";
|
|
3887
|
+
/**
|
|
3888
|
+
* Input modalities for one grok model: chat models (grok-4 family) accept
|
|
3889
|
+
* images; code and embedding models are text-only.
|
|
3890
|
+
*/
|
|
3891
|
+
function grokModalities(id) {
|
|
3892
|
+
return /code|embed/i.test(id) ? ["text"] : ["text", "image"];
|
|
3893
|
+
}
|
|
3894
|
+
/**
|
|
3895
|
+
* The Grok Build CLI chat proxy's model catalog — the only grok endpoint that
|
|
3896
|
+
* advertises reasoning capability. The `api.x.ai/v1/models` and
|
|
3897
|
+
* `/v1/language-models` payloads carry pricing, context, and aliases only, so
|
|
3898
|
+
* effort metadata must come from here (the same source the official CLI's
|
|
3899
|
+
* picker uses).
|
|
3900
|
+
*/
|
|
3901
|
+
const GROK_CLI_MODELS_URL = "https://cli-chat-proxy.grok.com/v1/models";
|
|
3902
|
+
/** Map one CLI catalog entry's reasoning fields, or undefined when unsupported. */
|
|
3903
|
+
function grokCliReasoning(entry) {
|
|
3904
|
+
if (entry.supports_reasoning_effort !== true) return void 0;
|
|
3905
|
+
const efforts = (entry.reasoning_efforts ?? []).filter((level) => typeof level.value === "string" && level.value.length > 0).map((level) => ({
|
|
3906
|
+
id: ReasoningEffortId(level.value),
|
|
3907
|
+
name: typeof level.label === "string" && level.label.length > 0 ? level.label : level.value,
|
|
3908
|
+
...typeof level.description === "string" && level.description.length > 0 ? { description: level.description } : {}
|
|
3909
|
+
}));
|
|
3910
|
+
if (efforts.length === 0) return void 0;
|
|
3911
|
+
const defaultEffort = typeof entry.reasoning_effort === "string" && efforts.some((effort) => effort.id === ReasoningEffortId(entry.reasoning_effort)) ? ReasoningEffortId(entry.reasoning_effort) : void 0;
|
|
3912
|
+
return {
|
|
3913
|
+
efforts,
|
|
3914
|
+
...defaultEffort === void 0 ? {} : { defaultEffort }
|
|
3915
|
+
};
|
|
3916
|
+
}
|
|
3917
|
+
/**
|
|
3918
|
+
* Fetch the CLI catalog and index its per-model metadata by model id.
|
|
3919
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
3920
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
3921
|
+
* @returns model id → contributed metadata.
|
|
3922
|
+
*/
|
|
3923
|
+
async function fetchGrokCliCatalog(session, fetchFn = proxiedFetch) {
|
|
3924
|
+
const response = await fetchFn(GROK_CLI_MODELS_URL, { headers: {
|
|
3925
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
3926
|
+
"x-xai-token-auth": "xai-grok-cli",
|
|
3927
|
+
"accept": "application/json",
|
|
3928
|
+
...attributionHeaders()
|
|
3929
|
+
} });
|
|
3930
|
+
if (!response.ok) throw await oauthEndpointError(response, "grok CLI catalog");
|
|
3931
|
+
const payload = await response.json();
|
|
3932
|
+
if (!Array.isArray(payload.data)) throw new Error("grok CLI catalog returned no data array");
|
|
3933
|
+
const catalog = /* @__PURE__ */ new Map();
|
|
3934
|
+
for (const entry of payload.data) {
|
|
3935
|
+
if (typeof entry.id !== "string" || entry.id.length === 0) continue;
|
|
3936
|
+
const reasoning = grokCliReasoning(entry);
|
|
3937
|
+
catalog.set(entry.id, {
|
|
3938
|
+
...typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {},
|
|
3939
|
+
...typeof entry.description === "string" && entry.description.length > 0 ? { description: entry.description } : {},
|
|
3940
|
+
...typeof entry.context_window === "number" && entry.context_window > 0 ? { contextWindow: entry.context_window } : {},
|
|
3941
|
+
...reasoning === void 0 ? {} : { reasoning }
|
|
3942
|
+
});
|
|
3943
|
+
}
|
|
3944
|
+
return catalog;
|
|
3945
|
+
}
|
|
3946
|
+
/**
|
|
3947
|
+
* The /v1/models list also serves generation models that cannot chat
|
|
3948
|
+
* (grok-imagine-image*, grok-imagine-video*) and embedding models; the picker
|
|
3949
|
+
* must not offer them. Heuristic over the id substring, verified against the
|
|
3950
|
+
* live catalog (grok-build-0.1 and the grok-4 family pass).
|
|
3951
|
+
*/
|
|
3952
|
+
function isChatModel(id) {
|
|
3953
|
+
return !/imagine|image-|video|embed/i.test(id);
|
|
3954
|
+
}
|
|
3955
|
+
/**
|
|
3956
|
+
* CLI-contributed fields carried forward from a previously discovered model.
|
|
3957
|
+
* @param prior - the last-known entry for this id, if any.
|
|
3958
|
+
* @returns enrichment to apply when the live CLI catalog cannot contribute.
|
|
3959
|
+
*/
|
|
3960
|
+
function grokPriorMeta(prior) {
|
|
3961
|
+
if (prior === void 0) return {};
|
|
3962
|
+
return {
|
|
3963
|
+
...prior.name.length > 0 ? { name: prior.name } : {},
|
|
3964
|
+
...prior.description === void 0 ? {} : { description: prior.description },
|
|
3965
|
+
...prior.contextWindow === void 0 ? {} : { contextWindow: prior.contextWindow },
|
|
3966
|
+
...prior.reasoning === void 0 ? {} : { reasoning: prior.reasoning }
|
|
3967
|
+
};
|
|
3968
|
+
}
|
|
3969
|
+
/**
|
|
3970
|
+
* Fetch the live grok model list, enriched with the CLI catalog's per-model
|
|
3971
|
+
* metadata (display name, context window, reasoning efforts). The api.x.ai
|
|
3972
|
+
* list stays authoritative for which models exist; the CLI catalog is
|
|
3973
|
+
* enrichment only, so its failure degrades to a plain list instead of taking
|
|
3974
|
+
* discovery down. When enrichment is missing, last-known capability metadata
|
|
3975
|
+
* is carried forward so a transient CLI outage cannot strip efforts a
|
|
3976
|
+
* session already selected.
|
|
3977
|
+
* @param session - the stored session (used as-is; never refreshed here).
|
|
3978
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
3979
|
+
* @param onWarn - warning sink for a failed CLI catalog fetch.
|
|
3980
|
+
* @param previous - last-known catalog used to keep enrichment when the CLI
|
|
3981
|
+
* catalog is down or omits a model.
|
|
3982
|
+
* @returns discovered chat models in endpoint order.
|
|
3983
|
+
*/
|
|
3984
|
+
async function fetchGrokModels(session, fetchFn = proxiedFetch, onWarn, previous) {
|
|
3985
|
+
const previousById = previous === void 0 || previous.length === 0 ? void 0 : new Map(previous.map((model) => [model.id, model]));
|
|
3986
|
+
const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, { headers: {
|
|
3987
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
3988
|
+
"accept": "application/json",
|
|
3989
|
+
...attributionHeaders()
|
|
3990
|
+
} }), fetchGrokCliCatalog(session, fetchFn).catch((error) => {
|
|
3991
|
+
onWarn?.(previousById === void 0 ? `grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})` : `grok CLI catalog fetch failed; keeping last-known reasoning efforts (${errorChain(error)})`);
|
|
3992
|
+
})]);
|
|
3993
|
+
if (!response.ok) throw await oauthEndpointError(response, "grok models");
|
|
3994
|
+
const payload = await response.json();
|
|
3995
|
+
if (!Array.isArray(payload.data)) throw new Error("grok models endpoint returned no data array");
|
|
3996
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3997
|
+
const discovered = [];
|
|
3998
|
+
for (const entry of payload.data) {
|
|
3999
|
+
if (typeof entry.id !== "string" || entry.id.length === 0 || seen.has(entry.id)) continue;
|
|
4000
|
+
if (!isChatModel(entry.id)) continue;
|
|
4001
|
+
seen.add(entry.id);
|
|
4002
|
+
const cli = cliCatalog?.get(entry.id);
|
|
4003
|
+
discovered.push({
|
|
4004
|
+
id: entry.id,
|
|
4005
|
+
name: entry.id,
|
|
4006
|
+
...cli ?? grokPriorMeta(previousById?.get(entry.id))
|
|
4007
|
+
});
|
|
4008
|
+
}
|
|
4009
|
+
if (discovered.length === 0) throw new Error("grok models endpoint returned an empty catalog");
|
|
4010
|
+
return discovered;
|
|
4011
|
+
}
|
|
4012
|
+
/** Grok wire adapter: one instance serves the `grok` provider route. */
|
|
4013
|
+
var GrokAdapter = class extends LlmAdapter {
|
|
4014
|
+
catalog;
|
|
4015
|
+
constructor(options) {
|
|
4016
|
+
super();
|
|
4017
|
+
this.options = options;
|
|
4018
|
+
this.catalog = new ModelCatalogCache(options.catalogStore);
|
|
4019
|
+
}
|
|
4020
|
+
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
4021
|
+
async fetchCatalog() {
|
|
4022
|
+
return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn, this.catalog.lastKnown());
|
|
4023
|
+
}
|
|
4024
|
+
listed(provider, discovered) {
|
|
4025
|
+
return discovered.map((model) => ({
|
|
4026
|
+
provider,
|
|
4027
|
+
id: model.id,
|
|
4028
|
+
name: model.name,
|
|
4029
|
+
...model.description === void 0 ? {} : { description: model.description },
|
|
4030
|
+
inputModalities: grokModalities(model.id)
|
|
4031
|
+
}));
|
|
4032
|
+
}
|
|
4033
|
+
providerInfo(provider) {
|
|
4034
|
+
return {
|
|
4035
|
+
id: provider,
|
|
4036
|
+
name: "Grok (Subscription)"
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
staticModels(provider) {
|
|
4040
|
+
return this.options.models.map((model) => ({
|
|
4041
|
+
provider,
|
|
4042
|
+
id: model.id,
|
|
4043
|
+
name: model.name ?? model.id,
|
|
4044
|
+
inputModalities: model.inputModalities ?? grokModalities(model.id)
|
|
4045
|
+
}));
|
|
4046
|
+
}
|
|
4047
|
+
async listModels(provider) {
|
|
4048
|
+
if (await this.options.tokens.peek() === void 0) return [];
|
|
4049
|
+
if (!this.options.discovery) return this.staticModels(provider);
|
|
4050
|
+
try {
|
|
4051
|
+
return this.listed(provider, await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog())));
|
|
4052
|
+
} catch (error) {
|
|
4053
|
+
if (isMissingOrInvalidCredential(error)) return [];
|
|
4054
|
+
this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
4055
|
+
return this.staticModels(provider);
|
|
4056
|
+
}
|
|
4057
|
+
}
|
|
4058
|
+
/**
|
|
4059
|
+
* The discovered entry for one model. Resolved through the cache's
|
|
4060
|
+
* stale-while-revalidate path: capability metadata must stay stable across
|
|
4061
|
+
* a long conversation — a session that selected a reasoning effort calls
|
|
4062
|
+
* this on EVERY step, and forgetting the efforts just because the TTL
|
|
4063
|
+
* lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
|
|
4064
|
+
* before provider I/O.
|
|
4065
|
+
*/
|
|
4066
|
+
async discovered(model) {
|
|
4067
|
+
if (!this.options.discovery) return void 0;
|
|
4068
|
+
return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
|
|
4069
|
+
}
|
|
4070
|
+
async resolveModel(provider, model) {
|
|
4071
|
+
const discovered = await this.discovered(model);
|
|
4072
|
+
const configured = this.options.models.find((entry) => entry.id === model);
|
|
4073
|
+
return {
|
|
4074
|
+
provider,
|
|
4075
|
+
id: model,
|
|
4076
|
+
name: discovered?.name ?? configured?.name ?? model,
|
|
4077
|
+
...discovered?.description === void 0 ? {} : { description: discovered.description },
|
|
4078
|
+
inputModalities: configured?.inputModalities ?? grokModalities(model),
|
|
4079
|
+
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
|
|
4080
|
+
defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
|
|
4081
|
+
...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
|
|
4082
|
+
};
|
|
4083
|
+
}
|
|
4084
|
+
async *stream(options) {
|
|
4085
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
4086
|
+
try {
|
|
4087
|
+
let session = await this.options.tokens.session();
|
|
4088
|
+
let response = await this.request(options, session, watchdog.signal);
|
|
4089
|
+
if (response.status === 401) {
|
|
4090
|
+
session = await this.options.tokens.session(true);
|
|
4091
|
+
response = await this.request(options, session, watchdog.signal);
|
|
4092
|
+
}
|
|
4093
|
+
if (!response.ok) throw await httpLlmError(response, "grok API");
|
|
4094
|
+
if (response.body === null) throw new LlmError("grok API returned no response body", EMPTY_RESPONSE_CODE);
|
|
4095
|
+
yield* streamResponses(response.body, () => {
|
|
4096
|
+
watchdog.pulse();
|
|
4097
|
+
});
|
|
4098
|
+
} catch (error) {
|
|
4099
|
+
throw mapFetchFailure("grok API", error, watchdog, options.signal);
|
|
4100
|
+
} finally {
|
|
4101
|
+
watchdog.stop();
|
|
4102
|
+
}
|
|
4103
|
+
}
|
|
4104
|
+
async request(options, session, signal) {
|
|
4105
|
+
const { instructions, input } = toResponsesInput(await resolveImages(options.messages, this.options.resolveAttachments?.(), signal), options.system);
|
|
4106
|
+
const body = {
|
|
4107
|
+
model: options.model,
|
|
4108
|
+
...instructions === void 0 ? {} : { instructions },
|
|
4109
|
+
input,
|
|
4110
|
+
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toResponsesTools(options.tools) } : {},
|
|
4111
|
+
tool_choice: "auto",
|
|
4112
|
+
parallel_tool_calls: true,
|
|
4113
|
+
...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
|
|
4114
|
+
...options.reasoningEffort !== void 0 ? { reasoning: { effort: String(options.reasoningEffort) } } : {},
|
|
4115
|
+
store: false,
|
|
4116
|
+
stream: true
|
|
4117
|
+
};
|
|
4118
|
+
return proxiedFetch(GROK_API_URL, {
|
|
4119
|
+
method: "POST",
|
|
4120
|
+
headers: {
|
|
4121
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
4122
|
+
"accept": "text/event-stream",
|
|
4123
|
+
"content-type": "application/json",
|
|
4124
|
+
...attributionHeaders()
|
|
4125
|
+
},
|
|
4126
|
+
body: JSON.stringify(body),
|
|
4127
|
+
signal
|
|
4128
|
+
});
|
|
4129
|
+
}
|
|
4130
|
+
};
|
|
4131
|
+
|
|
4132
|
+
//#endregion
|
|
4133
|
+
//#region src/translate/chat-completions.ts
|
|
4134
|
+
/** Flatten a tool result's content to plain text for a `tool` message. */
|
|
4135
|
+
function toolResultText(block) {
|
|
4136
|
+
return block.content.map((part) => part.type === "text" ? part.text : "").join("");
|
|
4137
|
+
}
|
|
4138
|
+
/**
|
|
4139
|
+
* Convert harness messages into chat completions `messages`. System-role
|
|
4140
|
+
* messages become one leading `system` message; an explicit `system` argument
|
|
4141
|
+
* wins over them when both exist. Reasoning blocks are not replayed (matching
|
|
4142
|
+
* the Responses translator). Images must arrive pre-resolved; an unresolved
|
|
4143
|
+
* ImageBlock is skipped because its bytes are unreachable here. A user message
|
|
4144
|
+
* carrying only text collapses to a plain string body (some endpoints still
|
|
4145
|
+
* reject content-part arrays); tool results become separate `tool` messages.
|
|
4146
|
+
* @param messages - ordered conversation messages with resolved images.
|
|
4147
|
+
* @param system - explicit system prompt, which takes precedence.
|
|
4148
|
+
* @returns the wire `messages` array.
|
|
4149
|
+
*/
|
|
4150
|
+
function toChatMessages(messages, system) {
|
|
4151
|
+
const out = [];
|
|
4152
|
+
const systemTexts = [];
|
|
4153
|
+
for (const message of messages) {
|
|
4154
|
+
if (message.role === "system") {
|
|
4155
|
+
for (const block of message.content) if (block.type === "text") systemTexts.push(block.text);
|
|
4156
|
+
continue;
|
|
4157
|
+
}
|
|
4158
|
+
if (message.role === "user") {
|
|
4159
|
+
let texts$1 = [];
|
|
4160
|
+
let parts = [];
|
|
4161
|
+
const flushUser = () => {
|
|
4162
|
+
if (parts.length > 0) {
|
|
4163
|
+
if (texts$1.length > 0) parts.unshift({
|
|
4164
|
+
type: "text",
|
|
4165
|
+
text: texts$1.join("\n")
|
|
4166
|
+
});
|
|
4167
|
+
out.push({
|
|
4168
|
+
role: "user",
|
|
4169
|
+
content: parts
|
|
4170
|
+
});
|
|
4171
|
+
} else if (texts$1.length > 0) out.push({
|
|
4172
|
+
role: "user",
|
|
4173
|
+
content: texts$1.join("\n")
|
|
4174
|
+
});
|
|
4175
|
+
texts$1 = [];
|
|
4176
|
+
parts = [];
|
|
4177
|
+
};
|
|
4178
|
+
for (const block of message.content) switch (block.type) {
|
|
4179
|
+
case "text":
|
|
4180
|
+
texts$1.push(block.text);
|
|
4181
|
+
break;
|
|
4182
|
+
case "image":
|
|
4183
|
+
if ("dataBase64" in block) parts.push({
|
|
4184
|
+
type: "image_url",
|
|
4185
|
+
image_url: { url: `data:${block.mediaType};base64,${block.dataBase64}` }
|
|
4186
|
+
});
|
|
4187
|
+
break;
|
|
4188
|
+
case "tool-result":
|
|
4189
|
+
flushUser();
|
|
4190
|
+
out.push({
|
|
4191
|
+
role: "tool",
|
|
4192
|
+
tool_call_id: String(block.toolCallId),
|
|
4193
|
+
content: toolResultText(block)
|
|
4194
|
+
});
|
|
4195
|
+
break;
|
|
4196
|
+
default: break;
|
|
4197
|
+
}
|
|
4198
|
+
flushUser();
|
|
4199
|
+
continue;
|
|
4200
|
+
}
|
|
4201
|
+
const texts = [];
|
|
4202
|
+
const toolCalls = [];
|
|
4203
|
+
for (const block of message.content) switch (block.type) {
|
|
4204
|
+
case "text":
|
|
4205
|
+
texts.push(block.text);
|
|
4206
|
+
break;
|
|
4207
|
+
case "tool-call":
|
|
4208
|
+
toolCalls.push({
|
|
4209
|
+
id: String(block.id),
|
|
4210
|
+
type: "function",
|
|
4211
|
+
function: {
|
|
4212
|
+
name: block.name,
|
|
4213
|
+
arguments: block.arguments
|
|
4214
|
+
}
|
|
4215
|
+
});
|
|
4216
|
+
break;
|
|
4217
|
+
default: break;
|
|
4218
|
+
}
|
|
4219
|
+
if (texts.length === 0 && toolCalls.length === 0) continue;
|
|
4220
|
+
out.push({
|
|
4221
|
+
role: "assistant",
|
|
4222
|
+
content: texts.join("\n"),
|
|
4223
|
+
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
4226
|
+
const systemText = system ?? (systemTexts.length > 0 ? systemTexts.join("\n\n") : void 0);
|
|
4227
|
+
if (systemText !== void 0) out.unshift({
|
|
4228
|
+
role: "system",
|
|
4229
|
+
content: systemText
|
|
4230
|
+
});
|
|
4231
|
+
return out;
|
|
4232
|
+
}
|
|
4233
|
+
/**
|
|
4234
|
+
* Map harness tool schemas to chat completions function tools.
|
|
4235
|
+
* @param tools - tool schemas from the request.
|
|
4236
|
+
* @returns the wire `tools` array.
|
|
4237
|
+
*/
|
|
4238
|
+
function toChatTools(tools) {
|
|
4239
|
+
return tools.map((tool) => ({
|
|
4240
|
+
type: "function",
|
|
4241
|
+
function: {
|
|
4242
|
+
name: tool.name,
|
|
4243
|
+
description: tool.description,
|
|
4244
|
+
parameters: tool.parameters
|
|
4245
|
+
}
|
|
4246
|
+
}));
|
|
4247
|
+
}
|
|
4248
|
+
/**
|
|
4249
|
+
* Map chat completions usage to disjoint harness counts (cached input is
|
|
4250
|
+
* subtracted out of `inputTokens` and reported as `cacheReadTokens`).
|
|
4251
|
+
* @param usage - wire usage from the terminal chunk.
|
|
4252
|
+
* @returns harness token usage.
|
|
4253
|
+
*/
|
|
4254
|
+
function mapChatCompletionsUsage(usage) {
|
|
4255
|
+
const cached = usage.prompt_tokens_details?.cached_tokens;
|
|
4256
|
+
const reasoning = usage.completion_tokens_details?.reasoning_tokens;
|
|
4257
|
+
return {
|
|
4258
|
+
inputTokens: usage.prompt_tokens - (cached ?? 0),
|
|
4259
|
+
outputTokens: usage.completion_tokens,
|
|
4260
|
+
...cached !== void 0 ? { cacheReadTokens: cached } : {},
|
|
4261
|
+
...reasoning !== void 0 ? { reasoningTokens: reasoning } : {}
|
|
4262
|
+
};
|
|
4263
|
+
}
|
|
4264
|
+
/** Assemble the final ContentBlock for one open block. */
|
|
4265
|
+
function closeBlock(block) {
|
|
4266
|
+
switch (block.kind) {
|
|
4267
|
+
case "text": return {
|
|
4268
|
+
type: "text",
|
|
4269
|
+
text: block.text
|
|
4270
|
+
};
|
|
4271
|
+
case "reasoning": return {
|
|
4272
|
+
type: "reasoning",
|
|
4273
|
+
text: block.text
|
|
4274
|
+
};
|
|
4275
|
+
case "tool-call": return {
|
|
4276
|
+
type: "tool-call",
|
|
4277
|
+
id: CallId(block.callId),
|
|
4278
|
+
name: block.name ?? "",
|
|
4279
|
+
arguments: block.text
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
/**
|
|
4284
|
+
* Push-model chat completions SSE translator: feed each parsed chunk object
|
|
4285
|
+
* to {@link push} and collect the emitted harness StreamChunks. The terminal
|
|
4286
|
+
* `finish_reason` chunk closes every block but only ARMS the finish chunk —
|
|
4287
|
+
* usage must precede the terminal finish, and where usage lives differs by
|
|
4288
|
+
* upstream: OpenAI-style streams send a trailing usage-only chunk
|
|
4289
|
+
* (stream_options.include_usage), while Copilot's Gemini models attach a
|
|
4290
|
+
* (zero) usage object to EVERY chunk and fold the real usage into the
|
|
4291
|
+
* finish chunk itself. A chunk therefore never early-returns on `usage`
|
|
4292
|
+
* alone: its deltas are always processed, and the terminal pair is drained
|
|
4293
|
+
* when the finish is armed and usage arrived (or when a usage-only chunk
|
|
4294
|
+
* follows an armed finish). `flush()` emits whatever remains when the
|
|
4295
|
+
* stream's `[DONE]` (or EOF) arrives.
|
|
4296
|
+
*/
|
|
4297
|
+
var ChatCompletionsStreamTranslator = class {
|
|
4298
|
+
/** Text/reasoning blocks keyed by kind; tool calls keyed by their wire index. */
|
|
4299
|
+
blocks = /* @__PURE__ */ new Map();
|
|
4300
|
+
order = [];
|
|
4301
|
+
nextIndex = 0;
|
|
4302
|
+
sawToolCall = false;
|
|
4303
|
+
pendingUsage;
|
|
4304
|
+
armedFinish;
|
|
4305
|
+
/** Set once the terminal finish chunk was emitted. */
|
|
4306
|
+
terminated = false;
|
|
4307
|
+
open(key, kind, chunks, callId = "", name$1) {
|
|
4308
|
+
const block = {
|
|
4309
|
+
index: this.nextIndex++,
|
|
4310
|
+
kind,
|
|
4311
|
+
text: "",
|
|
4312
|
+
callId,
|
|
4313
|
+
...name$1 === void 0 ? {} : { name: name$1 }
|
|
4314
|
+
};
|
|
4315
|
+
this.blocks.set(key, block);
|
|
4316
|
+
this.order.push(block);
|
|
4317
|
+
chunks.push({
|
|
4318
|
+
type: "block-start",
|
|
4319
|
+
index: block.index,
|
|
4320
|
+
blockType: kind
|
|
4321
|
+
});
|
|
4322
|
+
return block;
|
|
4323
|
+
}
|
|
4324
|
+
close(key, chunks) {
|
|
4325
|
+
const block = this.blocks.get(key);
|
|
4326
|
+
if (block === void 0) return;
|
|
4327
|
+
this.blocks.delete(key);
|
|
4328
|
+
chunks.push({
|
|
4329
|
+
type: "block-end",
|
|
4330
|
+
index: block.index,
|
|
4331
|
+
block: closeBlock(block)
|
|
4332
|
+
});
|
|
4333
|
+
}
|
|
4334
|
+
closeAll(chunks) {
|
|
4335
|
+
for (const key of [...this.blocks.keys()]) this.close(key, chunks);
|
|
4336
|
+
}
|
|
4337
|
+
/** Build the terminal finish chunk for one wire finish reason. */
|
|
4338
|
+
finishChunk(finishReason) {
|
|
4339
|
+
if (this.order.length === 0) return {
|
|
4340
|
+
type: "finish",
|
|
4341
|
+
reason: {
|
|
4342
|
+
kind: "error",
|
|
4343
|
+
failure: {
|
|
4344
|
+
message: "model returned a completed response with no content",
|
|
4345
|
+
code: EMPTY_RESPONSE_CODE
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
};
|
|
4349
|
+
switch (finishReason) {
|
|
4350
|
+
case "tool_calls": return {
|
|
4351
|
+
type: "finish",
|
|
4352
|
+
reason: { kind: "tool-calls" }
|
|
4353
|
+
};
|
|
4354
|
+
case "length": return {
|
|
4355
|
+
type: "finish",
|
|
4356
|
+
reason: { kind: "max-tokens" }
|
|
4357
|
+
};
|
|
4358
|
+
case "content_filter": return {
|
|
4359
|
+
type: "finish",
|
|
4360
|
+
reason: {
|
|
4361
|
+
kind: "error",
|
|
4362
|
+
failure: {
|
|
4363
|
+
message: "the response was blocked by the provider content filter",
|
|
4364
|
+
code: "CONTENT_FILTER"
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
};
|
|
4368
|
+
default: return {
|
|
4369
|
+
type: "finish",
|
|
4370
|
+
reason: { kind: this.sawToolCall ? "tool-calls" : "stop" }
|
|
4371
|
+
};
|
|
4372
|
+
}
|
|
4373
|
+
}
|
|
4374
|
+
/** Usage, then the armed finish: the only order the harness accepts. */
|
|
4375
|
+
drainTerminal(chunks) {
|
|
4376
|
+
if (this.pendingUsage !== void 0) {
|
|
4377
|
+
chunks.push({
|
|
4378
|
+
type: "usage",
|
|
4379
|
+
usage: mapChatCompletionsUsage(this.pendingUsage)
|
|
4380
|
+
});
|
|
4381
|
+
this.pendingUsage = void 0;
|
|
4382
|
+
}
|
|
4383
|
+
if (this.armedFinish !== void 0) {
|
|
4384
|
+
chunks.push(this.armedFinish);
|
|
4385
|
+
this.armedFinish = void 0;
|
|
4386
|
+
this.terminated = true;
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
/**
|
|
4390
|
+
* Process one parsed chat-completion chunk.
|
|
4391
|
+
* @param event - the parsed chunk object.
|
|
4392
|
+
* @returns the StreamChunks this event produced (possibly none).
|
|
4393
|
+
*/
|
|
4394
|
+
push(event) {
|
|
4395
|
+
if (this.terminated) return [];
|
|
4396
|
+
const chunks = [];
|
|
4397
|
+
const usage = event.usage;
|
|
4398
|
+
const hasUsage = usage !== void 0 && usage !== null;
|
|
4399
|
+
if (hasUsage) this.pendingUsage = usage;
|
|
4400
|
+
const choice = event.choices?.[0];
|
|
4401
|
+
const delta = choice?.delta;
|
|
4402
|
+
if (delta !== void 0) {
|
|
4403
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
4404
|
+
const block = this.blocks.get("content") ?? this.open("content", "text", chunks);
|
|
4405
|
+
block.text += delta.content;
|
|
4406
|
+
chunks.push({
|
|
4407
|
+
type: "text-delta",
|
|
4408
|
+
index: block.index,
|
|
4409
|
+
text: delta.content
|
|
4410
|
+
});
|
|
4411
|
+
}
|
|
4412
|
+
const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning_text === "string" ? delta.reasoning_text : void 0;
|
|
4413
|
+
if (reasoning !== void 0 && reasoning.length > 0) {
|
|
4414
|
+
const block = this.blocks.get("reasoning") ?? this.open("reasoning", "reasoning", chunks);
|
|
4415
|
+
block.text += reasoning;
|
|
4416
|
+
chunks.push({
|
|
4417
|
+
type: "reasoning-delta",
|
|
4418
|
+
index: block.index,
|
|
4419
|
+
text: reasoning
|
|
4420
|
+
});
|
|
4421
|
+
}
|
|
4422
|
+
for (const call of delta.tool_calls ?? []) {
|
|
4423
|
+
const key = `call:${String(call.index ?? 0)}`;
|
|
4424
|
+
let block = this.blocks.get(key);
|
|
4425
|
+
if (block === void 0) {
|
|
4426
|
+
this.sawToolCall = true;
|
|
4427
|
+
block = this.open(key, "tool-call", chunks, call.id ?? "", call.function?.name);
|
|
4428
|
+
chunks.push({
|
|
4429
|
+
type: "tool-call-delta",
|
|
4430
|
+
index: block.index,
|
|
4431
|
+
id: CallId(block.callId),
|
|
4432
|
+
...block.name === void 0 ? {} : { name: block.name },
|
|
4433
|
+
argumentsDelta: ""
|
|
4434
|
+
});
|
|
4435
|
+
}
|
|
4436
|
+
if (call.function?.arguments !== void 0 && call.function.arguments.length > 0) {
|
|
4437
|
+
block.text += call.function.arguments;
|
|
4438
|
+
chunks.push({
|
|
4439
|
+
type: "tool-call-delta",
|
|
4440
|
+
index: block.index,
|
|
4441
|
+
id: CallId(block.callId),
|
|
4442
|
+
argumentsDelta: call.function.arguments
|
|
4443
|
+
});
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
if (choice?.finish_reason !== void 0 && choice.finish_reason !== null) {
|
|
4448
|
+
this.closeAll(chunks);
|
|
4449
|
+
if (this.armedFinish === void 0) this.armedFinish = this.finishChunk(choice.finish_reason);
|
|
4450
|
+
}
|
|
4451
|
+
if (hasUsage && (this.armedFinish !== void 0 || choice === void 0)) this.drainTerminal(chunks);
|
|
4452
|
+
return chunks;
|
|
4453
|
+
}
|
|
4454
|
+
/**
|
|
4455
|
+
* Emit whatever the stream left pending (`[DONE]` or EOF without a final
|
|
4456
|
+
* usage chunk). Safe to call repeatedly.
|
|
4457
|
+
* @returns the remaining terminal chunks.
|
|
4458
|
+
*/
|
|
4459
|
+
flush() {
|
|
4460
|
+
const chunks = [];
|
|
4461
|
+
this.drainTerminal(chunks);
|
|
4462
|
+
return chunks;
|
|
4463
|
+
}
|
|
4464
|
+
};
|
|
4465
|
+
/**
|
|
4466
|
+
* Consume a chat completions SSE byte stream and yield harness StreamChunks.
|
|
4467
|
+
* @param stream - raw response body.
|
|
4468
|
+
* @param onActivity - transport-activity callback for the idle watchdog.
|
|
4469
|
+
* @returns the chunk stream; throws when the stream ends before any finish chunk.
|
|
4470
|
+
*/
|
|
4471
|
+
async function* streamChatCompletions(stream, onActivity) {
|
|
4472
|
+
const translator = new ChatCompletionsStreamTranslator();
|
|
4473
|
+
for await (const sseEvent of parseSse(stream, onActivity)) {
|
|
4474
|
+
if (sseEvent.data === "[DONE]") {
|
|
4475
|
+
yield* translator.flush();
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
let event;
|
|
4479
|
+
try {
|
|
4480
|
+
event = JSON.parse(sseEvent.data);
|
|
4481
|
+
} catch {
|
|
4482
|
+
throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, "MALFORMED_RESPONSE");
|
|
4483
|
+
}
|
|
4484
|
+
yield* translator.push(event);
|
|
4485
|
+
if (translator.terminated) return;
|
|
4486
|
+
}
|
|
4487
|
+
yield* translator.flush();
|
|
4488
|
+
if (!translator.terminated) throw new LlmError("chat completions SSE stream ended before a finish chunk", "STREAM_CLOSED");
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
//#endregion
|
|
4492
|
+
//#region src/providers/copilot.ts
|
|
4493
|
+
/**
|
|
4494
|
+
* Client id of the VS Code Copilot Chat GitHub App (pi-mono and
|
|
4495
|
+
* copilot2api-go use the same value): the app is pre-authorized for the
|
|
4496
|
+
* Copilot internal token exchange, a self-registered OAuth App is not.
|
|
4497
|
+
*/
|
|
4498
|
+
const COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
4499
|
+
const COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code";
|
|
4500
|
+
const COPILOT_DEVICE_TOKEN_URL = "https://github.com/login/oauth/access_token";
|
|
4501
|
+
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
4502
|
+
const GITHUB_USER_URL = "https://api.github.com/user";
|
|
4503
|
+
const COPILOT_API_URL = "https://api.githubcopilot.com/chat/completions";
|
|
4504
|
+
/** Responses endpoint for models whose catalog entry only lists `/responses`. */
|
|
4505
|
+
const COPILOT_RESPONSES_URL = "https://api.githubcopilot.com/responses";
|
|
4506
|
+
const COPILOT_MODELS_URL = "https://api.githubcopilot.com/models";
|
|
4507
|
+
const COPILOT_SCOPE = "read:user";
|
|
4508
|
+
const COPILOT_CONTEXT_WINDOW = 128e3;
|
|
4509
|
+
const COPILOT_DEFAULT_MAX_TOKENS = 16e3;
|
|
4510
|
+
/** Refresh when the Copilot API token has less than this much life left. */
|
|
4511
|
+
const COPILOT_PREEMPT_MS = 5 * 6e4;
|
|
4512
|
+
/**
|
|
4513
|
+
* The VS Code update feed answers a JSON array of version strings, latest
|
|
4514
|
+
* stable first. The Copilot API rejects requests whose Editor-Version is too
|
|
4515
|
+
* old with `401 IDE token expired`, so the version is resolved live (cached
|
|
4516
|
+
* for a day) instead of hardcoded — a stale hardcode bricks every request.
|
|
4517
|
+
*/
|
|
4518
|
+
const VSCODE_RELEASES_URL = "https://update.code.visualstudio.com/api/releases/stable";
|
|
4519
|
+
/** Last-known-good VS Code version when the feed is unreachable. */
|
|
4520
|
+
const FALLBACK_VSCODE_VERSION = "1.107.0";
|
|
4521
|
+
const VSCODE_VERSION_TTL_MS = 24 * 36e5;
|
|
4522
|
+
let vscodeVersionCache;
|
|
4523
|
+
let vscodeVersionInflight;
|
|
4524
|
+
/**
|
|
4525
|
+
* Resolve the VS Code version presented as Editor-Version: the latest stable
|
|
4526
|
+
* from the update feed, cached for a day, falling back to a pinned version
|
|
4527
|
+
* when the feed fails. Concurrent resolves coalesce behind one fetch.
|
|
4528
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
4529
|
+
* @param forceRefresh - bypass the cache (a 401 `IDE token expired` retry).
|
|
4530
|
+
* @returns a `major.minor.patch` version string.
|
|
4531
|
+
*/
|
|
4532
|
+
async function latestVsCodeVersion(fetchFn = proxiedFetch, forceRefresh = false) {
|
|
4533
|
+
if (!forceRefresh && vscodeVersionCache !== void 0 && Date.now() - vscodeVersionCache.at < VSCODE_VERSION_TTL_MS) return vscodeVersionCache.version;
|
|
4534
|
+
vscodeVersionInflight ??= (async () => {
|
|
4535
|
+
try {
|
|
4536
|
+
const response = await fetchFn(VSCODE_RELEASES_URL, { headers: { accept: "application/json" } });
|
|
4537
|
+
if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
|
|
4538
|
+
const releases = await response.json();
|
|
4539
|
+
const version = Array.isArray(releases) ? releases.find((entry) => typeof entry === "string" && /^\d+\.\d+\.\d+$/.test(entry)) : void 0;
|
|
4540
|
+
if (version === void 0) throw new Error("no version string in the feed");
|
|
4541
|
+
vscodeVersionCache = {
|
|
4542
|
+
version,
|
|
4543
|
+
at: Date.now()
|
|
4544
|
+
};
|
|
4545
|
+
return version;
|
|
4546
|
+
} catch {
|
|
4547
|
+
return vscodeVersionCache?.version ?? FALLBACK_VSCODE_VERSION;
|
|
4548
|
+
}
|
|
4549
|
+
})().finally(() => {
|
|
4550
|
+
vscodeVersionInflight = void 0;
|
|
4551
|
+
});
|
|
4552
|
+
return vscodeVersionInflight;
|
|
4553
|
+
}
|
|
4554
|
+
/**
|
|
4555
|
+
* The device-flow facts for the auth controller's DeviceFlowManager.
|
|
4556
|
+
* @returns the flow spec for one attempt.
|
|
4557
|
+
*/
|
|
4558
|
+
function copilotDeviceFlow() {
|
|
4559
|
+
return {
|
|
4560
|
+
clientId: COPILOT_CLIENT_ID,
|
|
4561
|
+
scope: COPILOT_SCOPE,
|
|
4562
|
+
deviceCodeUrl: COPILOT_DEVICE_CODE_URL,
|
|
4563
|
+
tokenUrl: COPILOT_DEVICE_TOKEN_URL
|
|
4564
|
+
};
|
|
4565
|
+
}
|
|
4566
|
+
/**
|
|
4567
|
+
* Header set presenting requests as the VS Code Copilot Chat extension; the
|
|
4568
|
+
* Copilot API rejects traffic without an editor identity.
|
|
4569
|
+
* @param hasVision - whether the request carries image input.
|
|
4570
|
+
* @param vscodeVersion - Editor-Version value from {@link latestVsCodeVersion}.
|
|
4571
|
+
* @returns headers to merge into Copilot API requests.
|
|
4572
|
+
*/
|
|
4573
|
+
function copilotHeaders(hasVision = false, vscodeVersion = FALLBACK_VSCODE_VERSION) {
|
|
4574
|
+
return {
|
|
4575
|
+
"user-agent": "GitHubCopilotChat/0.35.0",
|
|
4576
|
+
"editor-version": `vscode/${vscodeVersion}`,
|
|
4577
|
+
"editor-plugin-version": "copilot-chat/0.35.0",
|
|
4578
|
+
"copilot-integration-id": "vscode-chat",
|
|
4579
|
+
"openai-intent": "conversation-edits",
|
|
4580
|
+
"x-github-api-version": "2026-06-01",
|
|
4581
|
+
...hasVision ? { "copilot-vision-request": "true" } : {}
|
|
3040
4582
|
};
|
|
3041
4583
|
}
|
|
3042
|
-
const GROK_MODELS_URL = "https://api.x.ai/v1/models";
|
|
3043
4584
|
/**
|
|
3044
|
-
*
|
|
3045
|
-
*
|
|
4585
|
+
* Exchange a long-lived GitHub OAuth token for a short-lived Copilot API
|
|
4586
|
+
* token. A 401/403 means the GitHub token is revoked or the account lost its
|
|
4587
|
+
* Copilot subscription — permanent, re-login required.
|
|
4588
|
+
* @param githubToken - the GitHub OAuth token from the device flow.
|
|
4589
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
4590
|
+
* @returns the Copilot API token and its expiry.
|
|
3046
4591
|
*/
|
|
3047
|
-
function
|
|
3048
|
-
|
|
4592
|
+
async function exchangeCopilotToken(githubToken, fetchFn = proxiedFetch) {
|
|
4593
|
+
const response = await fetchFn(COPILOT_TOKEN_URL, { headers: {
|
|
4594
|
+
"authorization": `Bearer ${githubToken}`,
|
|
4595
|
+
"accept": "application/json",
|
|
4596
|
+
...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
|
|
4597
|
+
} });
|
|
4598
|
+
if (!response.ok) throw await oauthEndpointError(response, "copilot");
|
|
4599
|
+
const wire = await response.json();
|
|
4600
|
+
if (typeof wire.token !== "string" || wire.token.length === 0) throw new Error("copilot token endpoint returned no token");
|
|
4601
|
+
return {
|
|
4602
|
+
accessToken: wire.token,
|
|
4603
|
+
expiresAt: typeof wire.expires_at === "number" && wire.expires_at > 0 ? wire.expires_at * 1e3 : Date.now() + 25 * 6e4
|
|
4604
|
+
};
|
|
3049
4605
|
}
|
|
3050
4606
|
/**
|
|
3051
|
-
*
|
|
3052
|
-
*
|
|
3053
|
-
*
|
|
3054
|
-
*
|
|
3055
|
-
*
|
|
4607
|
+
* Complete a device-flow login: exchange the GitHub token for a Copilot API
|
|
4608
|
+
* token and read the GitHub login name for the status display.
|
|
4609
|
+
* @param githubToken - the GitHub OAuth token the device flow released.
|
|
4610
|
+
* @param fetchFn - fetch implementation (injectable for tests).
|
|
4611
|
+
* @returns the session to store.
|
|
3056
4612
|
*/
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
4613
|
+
async function completeCopilotLogin(githubToken, fetchFn = proxiedFetch) {
|
|
4614
|
+
const pair = await exchangeCopilotToken(githubToken, fetchFn);
|
|
4615
|
+
let account;
|
|
4616
|
+
try {
|
|
4617
|
+
const response = await fetchFn(GITHUB_USER_URL, { headers: {
|
|
4618
|
+
"authorization": `Bearer ${githubToken}`,
|
|
4619
|
+
"accept": "application/json",
|
|
4620
|
+
"user-agent": "GitHubCopilotChat/0.35.0"
|
|
4621
|
+
} });
|
|
4622
|
+
if (response.ok) {
|
|
4623
|
+
const profile = await response.json();
|
|
4624
|
+
if (typeof profile.login === "string" && profile.login.length > 0) account = profile.login;
|
|
4625
|
+
}
|
|
4626
|
+
} catch {}
|
|
3068
4627
|
return {
|
|
3069
|
-
|
|
3070
|
-
|
|
4628
|
+
accessToken: pair.accessToken,
|
|
4629
|
+
refreshToken: githubToken,
|
|
4630
|
+
expiresAt: pair.expiresAt,
|
|
4631
|
+
...account === void 0 ? {} : { account }
|
|
3071
4632
|
};
|
|
3072
4633
|
}
|
|
3073
4634
|
/**
|
|
3074
|
-
*
|
|
3075
|
-
*
|
|
4635
|
+
* Refresh a copilot session: re-exchange the long-lived GitHub token for a
|
|
4636
|
+
* fresh Copilot API token.
|
|
4637
|
+
* @param session - the stored session.
|
|
3076
4638
|
* @param fetchFn - fetch implementation (injectable for tests).
|
|
3077
|
-
* @returns
|
|
4639
|
+
* @returns the fresh session to store.
|
|
3078
4640
|
*/
|
|
3079
|
-
async function
|
|
3080
|
-
const
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
const payload = await response.json();
|
|
3088
|
-
if (!Array.isArray(payload.data)) throw new Error("grok CLI catalog returned no data array");
|
|
3089
|
-
const catalog = /* @__PURE__ */ new Map();
|
|
3090
|
-
for (const entry of payload.data) {
|
|
3091
|
-
if (typeof entry.id !== "string" || entry.id.length === 0) continue;
|
|
3092
|
-
const reasoning = grokCliReasoning(entry);
|
|
3093
|
-
catalog.set(entry.id, {
|
|
3094
|
-
...typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {},
|
|
3095
|
-
...typeof entry.description === "string" && entry.description.length > 0 ? { description: entry.description } : {},
|
|
3096
|
-
...typeof entry.context_window === "number" && entry.context_window > 0 ? { contextWindow: entry.context_window } : {},
|
|
3097
|
-
...reasoning === void 0 ? {} : { reasoning }
|
|
3098
|
-
});
|
|
3099
|
-
}
|
|
3100
|
-
return catalog;
|
|
4641
|
+
async function refreshCopilot(session, fetchFn = proxiedFetch) {
|
|
4642
|
+
const pair = await exchangeCopilotToken(session.refreshToken, fetchFn);
|
|
4643
|
+
return {
|
|
4644
|
+
accessToken: pair.accessToken,
|
|
4645
|
+
refreshToken: session.refreshToken,
|
|
4646
|
+
expiresAt: pair.expiresAt,
|
|
4647
|
+
...session.account === void 0 ? {} : { account: session.account }
|
|
4648
|
+
};
|
|
3101
4649
|
}
|
|
3102
4650
|
/**
|
|
3103
|
-
*
|
|
3104
|
-
*
|
|
3105
|
-
*
|
|
3106
|
-
* live catalog (grok-build-0.1 and the grok-4 family pass).
|
|
4651
|
+
* Whether a copilot refresh failure means the login is permanently gone.
|
|
4652
|
+
* @param error - the thrown refresh error.
|
|
4653
|
+
* @returns true when re-login is the only fix (GitHub token revoked or the subscription lost).
|
|
3107
4654
|
*/
|
|
3108
|
-
function
|
|
3109
|
-
return
|
|
4655
|
+
function isCopilotPermanentRefreshError(error) {
|
|
4656
|
+
return error instanceof OAuthEndpointError && (error.status === 401 || error.status === 403);
|
|
4657
|
+
}
|
|
4658
|
+
/** Display name for one Copilot wire reasoning-effort value. */
|
|
4659
|
+
function copilotEffortName(effort) {
|
|
4660
|
+
return effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1);
|
|
3110
4661
|
}
|
|
3111
4662
|
/**
|
|
3112
|
-
*
|
|
3113
|
-
*
|
|
3114
|
-
*
|
|
3115
|
-
*
|
|
3116
|
-
|
|
4663
|
+
* Map a catalog entry's `supports.reasoning_effort` array into selectable
|
|
4664
|
+
* efforts. The endpoint discloses no default effort, so none is claimed
|
|
4665
|
+
* (absence preserves the provider's own default). Duplicates and non-string
|
|
4666
|
+
* entries are dropped: the harness rejects duplicate effort ids outright.
|
|
4667
|
+
*/
|
|
4668
|
+
function copilotReasoning(entry) {
|
|
4669
|
+
const wire = entry.capabilities?.supports?.reasoning_effort;
|
|
4670
|
+
if (!Array.isArray(wire)) return void 0;
|
|
4671
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4672
|
+
const efforts = [];
|
|
4673
|
+
for (const value of wire) {
|
|
4674
|
+
if (typeof value !== "string" || value.length === 0 || seen.has(value)) continue;
|
|
4675
|
+
seen.add(value);
|
|
4676
|
+
efforts.push({
|
|
4677
|
+
id: ReasoningEffortId(value),
|
|
4678
|
+
name: copilotEffortName(value)
|
|
4679
|
+
});
|
|
4680
|
+
}
|
|
4681
|
+
return efforts.length > 0 ? { efforts } : void 0;
|
|
4682
|
+
}
|
|
4683
|
+
/**
|
|
4684
|
+
* Fetch the live Copilot model list. Models hidden from the picker or
|
|
4685
|
+
* disabled by policy are excluded, as are models able to speak neither
|
|
4686
|
+
* protocol this adapter knows: an entry listing `/chat/completions` speaks
|
|
4687
|
+
* the chat wire, one listing only `/responses` (the newer GPT families,
|
|
4688
|
+
* e.g. gpt-5.6) speaks the Responses wire, and the choice is recorded on the
|
|
4689
|
+
* discovered entry so requests pick the matching endpoint; an entry listing
|
|
4690
|
+
* BOTH endpoints additionally records `/responses` availability, which
|
|
4691
|
+
* {@link copilotRequestWire} uses to reroute tools+effort requests. Vision
|
|
4692
|
+
* support from the catalog becomes the model's input modalities, and a
|
|
4693
|
+
* non-empty `supports.reasoning_effort` array becomes the model's selectable
|
|
4694
|
+
* reasoning efforts (the endpoint discloses no default, so none is claimed).
|
|
3117
4695
|
* @param session - the stored session (used as-is; never refreshed here).
|
|
3118
4696
|
* @param fetchFn - fetch implementation (injectable for tests).
|
|
3119
|
-
* @param onWarn - warning sink for a failed CLI catalog fetch.
|
|
3120
4697
|
* @returns discovered chat models in endpoint order.
|
|
3121
4698
|
*/
|
|
3122
|
-
async function
|
|
3123
|
-
const
|
|
4699
|
+
async function fetchCopilotModels(session, fetchFn = proxiedFetch) {
|
|
4700
|
+
const response = await fetchFn(COPILOT_MODELS_URL, { headers: {
|
|
3124
4701
|
"authorization": `Bearer ${session.accessToken}`,
|
|
3125
4702
|
"accept": "application/json",
|
|
3126
|
-
...
|
|
3127
|
-
} })
|
|
3128
|
-
|
|
3129
|
-
})]);
|
|
3130
|
-
if (!response.ok) throw await oauthEndpointError(response, "grok models");
|
|
4703
|
+
...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
|
|
4704
|
+
} });
|
|
4705
|
+
if (!response.ok) throw await oauthEndpointError(response, "copilot models");
|
|
3131
4706
|
const payload = await response.json();
|
|
3132
|
-
if (!Array.isArray(payload.data)) throw new Error("
|
|
4707
|
+
if (!Array.isArray(payload.data)) throw new Error("copilot models endpoint returned no data array");
|
|
3133
4708
|
const seen = /* @__PURE__ */ new Set();
|
|
3134
4709
|
const discovered = [];
|
|
3135
4710
|
for (const entry of payload.data) {
|
|
3136
4711
|
if (typeof entry.id !== "string" || entry.id.length === 0 || seen.has(entry.id)) continue;
|
|
3137
|
-
if (
|
|
4712
|
+
if (entry.model_picker_enabled !== true || entry.policy?.state === "disabled") continue;
|
|
4713
|
+
let wire;
|
|
4714
|
+
let responsesSupported = false;
|
|
4715
|
+
if (Array.isArray(entry.supported_endpoints)) {
|
|
4716
|
+
responsesSupported = entry.supported_endpoints.includes("/responses");
|
|
4717
|
+
if (entry.supported_endpoints.includes("/chat/completions")) wire = "chat-completions";
|
|
4718
|
+
else if (responsesSupported) wire = "responses";
|
|
4719
|
+
else continue;
|
|
4720
|
+
}
|
|
3138
4721
|
seen.add(entry.id);
|
|
4722
|
+
const reasoning = copilotReasoning(entry);
|
|
3139
4723
|
discovered.push({
|
|
3140
4724
|
id: entry.id,
|
|
3141
|
-
name: entry.id,
|
|
3142
|
-
...
|
|
4725
|
+
name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : entry.id,
|
|
4726
|
+
...typeof entry.capabilities?.limits?.max_context_window_tokens === "number" && entry.capabilities.limits.max_context_window_tokens > 0 ? { contextWindow: entry.capabilities.limits.max_context_window_tokens } : {},
|
|
4727
|
+
inputModalities: entry.capabilities?.supports?.vision === true ? ["text", "image"] : ["text"],
|
|
4728
|
+
...reasoning === void 0 ? {} : { reasoning },
|
|
4729
|
+
...wire === void 0 ? {} : { copilotWire: wire },
|
|
4730
|
+
...responsesSupported ? { copilotResponses: true } : {}
|
|
3143
4731
|
});
|
|
3144
4732
|
}
|
|
3145
|
-
if (discovered.length === 0) throw new Error("
|
|
4733
|
+
if (discovered.length === 0) throw new Error("copilot models endpoint returned an empty catalog");
|
|
3146
4734
|
return discovered;
|
|
3147
4735
|
}
|
|
3148
|
-
/**
|
|
3149
|
-
|
|
4736
|
+
/**
|
|
4737
|
+
* The wire protocol for one model: the discovered catalog entry's recorded
|
|
4738
|
+
* choice, defaulting to chat completions for unknown models (static-catalog
|
|
4739
|
+
* and no-discovery configurations, and models listing both endpoints).
|
|
4740
|
+
* @param entry - the discovered catalog entry, when known.
|
|
4741
|
+
* @returns the protocol the request for this model must speak.
|
|
4742
|
+
*/
|
|
4743
|
+
function copilotWireFor(entry) {
|
|
4744
|
+
return entry?.copilotWire === "responses" ? "responses" : "chat-completions";
|
|
4745
|
+
}
|
|
4746
|
+
/**
|
|
4747
|
+
* The upstream protocol for ONE REQUEST: the model's default wire, except
|
|
4748
|
+
* that a dual-protocol model defaulting to chat completions must reroute to
|
|
4749
|
+
* Responses when the request combines function tools with a reasoning effort
|
|
4750
|
+
* — Copilot rejects exactly that combination on /chat/completions with
|
|
4751
|
+
* HTTP 400 invalid_request_body ("Function tools with reasoning_effort are
|
|
4752
|
+
* not supported … use /v1/responses or set reasoning_effort to 'none'",
|
|
4753
|
+
* observed on gpt-5.4) while /responses serves it. Effort 'none' stays on
|
|
4754
|
+
* the chat wire (the API allows the combination there), and models not
|
|
4755
|
+
* listing /responses never reroute.
|
|
4756
|
+
* @param entry - the discovered catalog entry, when known.
|
|
4757
|
+
* @param options - the harness generate options (tools + effort only).
|
|
4758
|
+
* @returns the protocol the request for this model must speak.
|
|
4759
|
+
*/
|
|
4760
|
+
function copilotRequestWire(entry, options) {
|
|
4761
|
+
const wire = copilotWireFor(entry);
|
|
4762
|
+
if (wire !== "chat-completions") return wire;
|
|
4763
|
+
if (entry?.copilotResponses !== true) return wire;
|
|
4764
|
+
if (options.tools === void 0 || options.tools.length === 0) return wire;
|
|
4765
|
+
if (options.reasoningEffort === void 0 || options.reasoningEffort === "none") return wire;
|
|
4766
|
+
return "responses";
|
|
4767
|
+
}
|
|
4768
|
+
/**
|
|
4769
|
+
* The chat completions request body for one generation. The output cap rides
|
|
4770
|
+
* `max_completion_tokens` — the newer OpenAI-family models on Copilot reject
|
|
4771
|
+
* the legacy `max_tokens` parameter outright (HTTP 400 "Unsupported
|
|
4772
|
+
* parameter"), and the rest of the catalog accepts the new spelling.
|
|
4773
|
+
* @param options - the harness generate options.
|
|
4774
|
+
* @param messages - translated wire messages (images pre-resolved).
|
|
4775
|
+
* @returns the JSON body.
|
|
4776
|
+
*/
|
|
4777
|
+
function copilotChatRequestBody(options, messages) {
|
|
4778
|
+
return {
|
|
4779
|
+
model: options.model,
|
|
4780
|
+
messages,
|
|
4781
|
+
...options.tools !== void 0 && options.tools.length > 0 ? {
|
|
4782
|
+
tools: toChatTools(options.tools),
|
|
4783
|
+
tool_choice: "auto"
|
|
4784
|
+
} : {},
|
|
4785
|
+
...options.maxTokens !== void 0 ? { max_completion_tokens: options.maxTokens } : {},
|
|
4786
|
+
...options.reasoningEffort !== void 0 ? { reasoning_effort: String(options.reasoningEffort) } : {},
|
|
4787
|
+
stream: true,
|
|
4788
|
+
stream_options: { include_usage: true }
|
|
4789
|
+
};
|
|
4790
|
+
}
|
|
4791
|
+
/**
|
|
4792
|
+
* The Responses request body for one generation (the wire the `/responses`-
|
|
4793
|
+
* only model families speak). Usage arrives on `response.completed`.
|
|
4794
|
+
* @param options - the harness generate options.
|
|
4795
|
+
* @param resolved - translated instructions + input (images pre-resolved).
|
|
4796
|
+
* @returns the JSON body.
|
|
4797
|
+
*/
|
|
4798
|
+
function copilotResponsesRequestBody(options, resolved) {
|
|
4799
|
+
return {
|
|
4800
|
+
model: options.model,
|
|
4801
|
+
...resolved.instructions !== void 0 ? { instructions: resolved.instructions } : {},
|
|
4802
|
+
input: resolved.input,
|
|
4803
|
+
...options.tools !== void 0 && options.tools.length > 0 ? {
|
|
4804
|
+
tools: toResponsesTools(options.tools),
|
|
4805
|
+
tool_choice: "auto"
|
|
4806
|
+
} : {},
|
|
4807
|
+
...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
|
|
4808
|
+
...options.reasoningEffort !== void 0 ? { reasoning: { effort: String(options.reasoningEffort) } } : {},
|
|
4809
|
+
include: ["reasoning.encrypted_content"],
|
|
4810
|
+
stream: true
|
|
4811
|
+
};
|
|
4812
|
+
}
|
|
4813
|
+
/**
|
|
4814
|
+
* The replayable form of one completed reasoning item: the COMPLETE item as
|
|
4815
|
+
* the gateway delivered it on `response.output_item.done` — its ORIGINAL id
|
|
4816
|
+
* (captured before the stable-key rewrite), summary parts, status, and the
|
|
4817
|
+
* encrypted payload. A reasoning item's `id` and `summary` are not optional
|
|
4818
|
+
* in the Responses input schema, so an item missing its id or its blob is
|
|
4819
|
+
* not replayable and degrades to the no-replay path instead of risking an
|
|
4820
|
+
* invalid input item.
|
|
4821
|
+
*/
|
|
4822
|
+
function completedReasoningItem(item) {
|
|
4823
|
+
if (typeof item.encrypted_content !== "string" || item.encrypted_content.length === 0) return void 0;
|
|
4824
|
+
if (typeof item.id !== "string" || item.id.length === 0) return void 0;
|
|
4825
|
+
return {
|
|
4826
|
+
type: "reasoning",
|
|
4827
|
+
id: item.id,
|
|
4828
|
+
...Array.isArray(item.summary) ? { summary: item.summary } : {},
|
|
4829
|
+
...typeof item.status === "string" && item.status.length > 0 ? { status: item.status } : {},
|
|
4830
|
+
encrypted_content: item.encrypted_content
|
|
4831
|
+
};
|
|
4832
|
+
}
|
|
4833
|
+
/**
|
|
4834
|
+
* Rewrite Copilot's Responses-gateway item ids into stable per-item keys.
|
|
4835
|
+
* Unlike chatgpt.com's Responses backend, the Copilot gateway mints a FRESH
|
|
4836
|
+
* opaque `item.id`/`item_id` on every event of one response (the `added`,
|
|
4837
|
+
* each delta, and the `done` all differ), which defeats id-keyed block
|
|
4838
|
+
* assembly in the shared translator: text fragments would each open their
|
|
4839
|
+
* own block, `done` would synthesize duplicates, and a function call whose
|
|
4840
|
+
* arguments arrive whole only on `done` (the deltas carry empty strings)
|
|
4841
|
+
* would close empty. The stable key derives from the event's `output_index`
|
|
4842
|
+
* — the item's position in the response's output array, which survives the
|
|
4843
|
+
* gateway's per-event id churn even when two items' events interleave on
|
|
4844
|
+
* the wire (parallel tool calls do exactly that). Events without an
|
|
4845
|
+
* `output_index` fall back to the key of the last `output_item.added`, which
|
|
4846
|
+
* is only correct while one item's events stay contiguous — the pre-
|
|
4847
|
+
* interleaving behavior, kept for gateways that omit the field; with no
|
|
4848
|
+
* `added` seen yet they key to `copilot-item-0` as before. Function-call
|
|
4849
|
+
* identity additionally rides the gateway-stable `call_id`.
|
|
4850
|
+
*/
|
|
4851
|
+
var CopilotResponsesItemNormalizer = class {
|
|
4852
|
+
adds = 0;
|
|
4853
|
+
lastKey = "copilot-item-0";
|
|
4854
|
+
/** Call ids and completed reasoning items collected for the open response. */
|
|
4855
|
+
capturedCallIds = [];
|
|
4856
|
+
capturedReasoning = [];
|
|
4857
|
+
/**
|
|
4858
|
+
* @param onCaptured - fired at each `response.completed` that produced BOTH
|
|
4859
|
+
* function calls and completed reasoning items, receiving the response's
|
|
4860
|
+
* call ids and replayable reasoning items so the adapter can replay them
|
|
4861
|
+
* on the next request.
|
|
4862
|
+
*/
|
|
4863
|
+
constructor(onCaptured) {
|
|
4864
|
+
this.onCaptured = onCaptured;
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* [2026-08-23]-[a single arrival-order ordinal mis-buckets every event after
|
|
4868
|
+
* a second item's `added`, mangling interleaved parallel tool calls;
|
|
4869
|
+
* output_index is the only correlator the gateway keeps stable]-[changes
|
|
4870
|
+
* keys only for streams that carry output_index; no-index streams keep the
|
|
4871
|
+
* old last-added-key behavior byte for byte]
|
|
4872
|
+
*/
|
|
4873
|
+
keyFor(event) {
|
|
4874
|
+
return event.output_index !== void 0 ? `copilot-item-${String(event.output_index)}` : this.lastKey;
|
|
4875
|
+
}
|
|
4876
|
+
/**
|
|
4877
|
+
* Rewrite one parsed Responses event.
|
|
4878
|
+
* @param event - the event as parsed off the wire.
|
|
4879
|
+
* @returns the event with a stable item key.
|
|
4880
|
+
*/
|
|
4881
|
+
push(event) {
|
|
4882
|
+
if (event.type === "response.output_item.added") {
|
|
4883
|
+
this.adds += 1;
|
|
4884
|
+
const key = event.output_index !== void 0 ? `copilot-item-${String(event.output_index)}` : `copilot-item-${String(this.adds)}`;
|
|
4885
|
+
this.lastKey = key;
|
|
4886
|
+
const item = event.item;
|
|
4887
|
+
if (item?.type === "function_call" && typeof item.call_id === "string" && item.call_id.length > 0) this.capturedCallIds.push(item.call_id);
|
|
4888
|
+
return item === void 0 ? event : {
|
|
4889
|
+
...event,
|
|
4890
|
+
item: {
|
|
4891
|
+
...item,
|
|
4892
|
+
id: key
|
|
4893
|
+
}
|
|
4894
|
+
};
|
|
4895
|
+
}
|
|
4896
|
+
if (event.type === "response.output_item.done") {
|
|
4897
|
+
const item = event.item;
|
|
4898
|
+
if (item?.type === "reasoning") {
|
|
4899
|
+
const captured = completedReasoningItem(item);
|
|
4900
|
+
if (captured !== void 0) this.capturedReasoning.push(captured);
|
|
4901
|
+
}
|
|
4902
|
+
return item === void 0 ? event : {
|
|
4903
|
+
...event,
|
|
4904
|
+
item: {
|
|
4905
|
+
...item,
|
|
4906
|
+
id: this.keyFor(event)
|
|
4907
|
+
}
|
|
4908
|
+
};
|
|
4909
|
+
}
|
|
4910
|
+
if (event.type === "response.completed") {
|
|
4911
|
+
if (this.capturedCallIds.length > 0 && this.capturedReasoning.length > 0) this.onCaptured?.(this.capturedCallIds, this.capturedReasoning);
|
|
4912
|
+
this.capturedCallIds = [];
|
|
4913
|
+
this.capturedReasoning = [];
|
|
4914
|
+
return event;
|
|
4915
|
+
}
|
|
4916
|
+
if (event.item_id === void 0) return event;
|
|
4917
|
+
return {
|
|
4918
|
+
...event,
|
|
4919
|
+
item_id: this.keyFor(event)
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
};
|
|
4923
|
+
/** Copilot wire adapter: one instance serves the `copilot` provider route. */
|
|
4924
|
+
var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
|
|
3150
4925
|
catalog;
|
|
4926
|
+
/**
|
|
4927
|
+
* [2026-08-23]-[a reasoning model continuing a tool chain must get its
|
|
4928
|
+
* reasoning back or it restarts from scratch every tool round trip; the
|
|
4929
|
+
* items live in ADAPTER memory because dsh-llm's reasoning ContentBlock is
|
|
4930
|
+
* a closed shape that cannot carry them through the harness]-[entries are
|
|
4931
|
+
* namespaced per ACCOUNT × CONVERSATION × MODEL, idle out via a sliding
|
|
4932
|
+
* TTL, and the whole store is dropped on auth transitions, so replay
|
|
4933
|
+
* degrades to the old behavior instead of leaking across contexts]
|
|
4934
|
+
*/
|
|
4935
|
+
replayByScope = /* @__PURE__ */ new Map();
|
|
4936
|
+
/** Call-id entries kept per scope; see {@link captureReasoning}. */
|
|
4937
|
+
static REPLAY_CALL_LIMIT = 64;
|
|
4938
|
+
/** Conversation scopes kept at once; bounds memory when many sessions interleave. */
|
|
4939
|
+
static REPLAY_SCOPE_LIMIT = 32;
|
|
4940
|
+
/** How long a captured entry stays replayable; tool round trips take minutes, not hours. */
|
|
4941
|
+
static REPLAY_TTL_MS = 30 * 6e4;
|
|
3151
4942
|
constructor(options) {
|
|
3152
4943
|
super();
|
|
3153
4944
|
this.options = options;
|
|
@@ -3155,12 +4946,12 @@ var GrokAdapter = class extends LlmAdapter {
|
|
|
3155
4946
|
}
|
|
3156
4947
|
/** Discovery fetcher: resolves the session through the refresh-aware path. */
|
|
3157
4948
|
async fetchCatalog() {
|
|
3158
|
-
return
|
|
4949
|
+
return fetchCopilotModels(await this.options.tokens.session(), this.options.fetchFn);
|
|
3159
4950
|
}
|
|
3160
4951
|
providerInfo(provider) {
|
|
3161
4952
|
return {
|
|
3162
4953
|
id: provider,
|
|
3163
|
-
name: "
|
|
4954
|
+
name: "GitHub Copilot"
|
|
3164
4955
|
};
|
|
3165
4956
|
}
|
|
3166
4957
|
staticModels(provider) {
|
|
@@ -3168,39 +4959,133 @@ var GrokAdapter = class extends LlmAdapter {
|
|
|
3168
4959
|
provider,
|
|
3169
4960
|
id: model.id,
|
|
3170
4961
|
name: model.name ?? model.id,
|
|
3171
|
-
inputModalities: model.inputModalities ??
|
|
4962
|
+
inputModalities: model.inputModalities ?? ["text"]
|
|
3172
4963
|
}));
|
|
3173
4964
|
}
|
|
3174
4965
|
async listModels(provider) {
|
|
3175
4966
|
if (await this.options.tokens.peek() === void 0) return [];
|
|
3176
4967
|
if (!this.options.discovery) return this.staticModels(provider);
|
|
3177
4968
|
try {
|
|
3178
|
-
return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
|
|
4969
|
+
return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
|
|
3179
4970
|
provider,
|
|
3180
4971
|
id: model.id,
|
|
3181
4972
|
name: model.name,
|
|
3182
4973
|
...model.description === void 0 ? {} : { description: model.description },
|
|
3183
|
-
inputModalities:
|
|
4974
|
+
...model.inputModalities === void 0 ? {} : { inputModalities: model.inputModalities }
|
|
3184
4975
|
}));
|
|
3185
4976
|
} catch (error) {
|
|
3186
|
-
if (
|
|
3187
|
-
|
|
3188
|
-
this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
4977
|
+
if (isMissingOrInvalidCredential(error)) return [];
|
|
4978
|
+
this.options.onWarn?.(`copilot model discovery failed; using the built-in catalog (${errorChain(error)})`);
|
|
3189
4979
|
return this.staticModels(provider);
|
|
3190
4980
|
}
|
|
3191
4981
|
}
|
|
3192
4982
|
/**
|
|
3193
4983
|
* The discovered entry for one model. Resolved through the cache's
|
|
3194
4984
|
* stale-while-revalidate path: capability metadata must stay stable across
|
|
3195
|
-
* a long conversation — a
|
|
3196
|
-
*
|
|
3197
|
-
* lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
|
|
3198
|
-
* before provider I/O.
|
|
4985
|
+
* a long conversation — a mid-turn refetch must neither block nor fail the
|
|
4986
|
+
* call before provider I/O.
|
|
3199
4987
|
*/
|
|
3200
4988
|
async discovered(model) {
|
|
3201
4989
|
if (!this.options.discovery) return void 0;
|
|
3202
4990
|
return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
|
|
3203
4991
|
}
|
|
4992
|
+
/**
|
|
4993
|
+
* [2026-08-23]-[a manually configured responses-only model combined with
|
|
4994
|
+
* `discovery:false` left discovered() undefined, so copilotRequestWire
|
|
4995
|
+
* silently defaulted to /chat/completions and the request 404/400'd at the
|
|
4996
|
+
* gateway; an explicit config wire must win over catalog inference]-[config
|
|
4997
|
+
* `models[].wire` now routes the request even without discovery]
|
|
4998
|
+
*/
|
|
4999
|
+
configuredWireEntry(model) {
|
|
5000
|
+
const configured = this.options.models.find((entry) => entry.id === model);
|
|
5001
|
+
return configured?.wire === void 0 ? void 0 : {
|
|
5002
|
+
id: configured.id,
|
|
5003
|
+
name: configured.name ?? configured.id,
|
|
5004
|
+
copilotWire: configured.wire
|
|
5005
|
+
};
|
|
5006
|
+
}
|
|
5007
|
+
/**
|
|
5008
|
+
* The replay scope isolating one ACCOUNT × CONVERSATION × MODEL. The
|
|
5009
|
+
* account identity is the session's long-lived GitHub token (stable across
|
|
5010
|
+
* Copilot-token refreshes, different per GitHub login); the conversation is
|
|
5011
|
+
* the loop-stamped `sessionId`, falling back to the first message's id
|
|
5012
|
+
* when a hand-built request carries no session stamp; the model separates
|
|
5013
|
+
* wire families. A call id captured in one scope is invisible to every
|
|
5014
|
+
* other scope, so reused ids cannot leak reasoning across accounts,
|
|
5015
|
+
* conversations, or models.
|
|
5016
|
+
*/
|
|
5017
|
+
replayScope(tokenKey, options) {
|
|
5018
|
+
return `${tokenKey}\u0000${options.sessionId !== void 0 ? `session:${String(options.sessionId)}` : options.messages[0] !== void 0 ? `anchor:${String(options.messages[0].id)}` : "conversation:none"}\u0000${options.model}`;
|
|
5019
|
+
}
|
|
5020
|
+
/**
|
|
5021
|
+
* Store one response's completed reasoning items behind every call id it
|
|
5022
|
+
* produced, inside one replay scope. Retention: a CONSUMED entry is kept —
|
|
5023
|
+
* every later round of the same conversation replays ALL its earlier
|
|
5024
|
+
* function_calls — until it idles out of the TTL (see {@link replayFor})
|
|
5025
|
+
* or the per-scope entry cap evicts it oldest-first. All calls of one
|
|
5026
|
+
* response share ONE entry object: toResponsesInput dedupes replays by
|
|
5027
|
+
* array reference, so parallel calls replay the items once instead of once
|
|
5028
|
+
* per call.
|
|
5029
|
+
*/
|
|
5030
|
+
captureReasoning(scope, callIds, items) {
|
|
5031
|
+
let entries = this.replayByScope.get(scope);
|
|
5032
|
+
if (entries === void 0) {
|
|
5033
|
+
entries = /* @__PURE__ */ new Map();
|
|
5034
|
+
this.replayByScope.set(scope, entries);
|
|
5035
|
+
} else {
|
|
5036
|
+
this.replayByScope.delete(scope);
|
|
5037
|
+
this.replayByScope.set(scope, entries);
|
|
5038
|
+
}
|
|
5039
|
+
const now = Date.now();
|
|
5040
|
+
for (const [callId, entry$1] of entries) if (now - entry$1.at >= CopilotAdapter.REPLAY_TTL_MS) entries.delete(callId);
|
|
5041
|
+
const entry = {
|
|
5042
|
+
items: [...items],
|
|
5043
|
+
at: now
|
|
5044
|
+
};
|
|
5045
|
+
for (const callId of callIds) entries.set(callId, entry);
|
|
5046
|
+
while (entries.size > CopilotAdapter.REPLAY_CALL_LIMIT) {
|
|
5047
|
+
const oldest = entries.keys().next().value;
|
|
5048
|
+
if (oldest === void 0) break;
|
|
5049
|
+
entries.delete(oldest);
|
|
5050
|
+
}
|
|
5051
|
+
while (this.replayByScope.size > CopilotAdapter.REPLAY_SCOPE_LIMIT) {
|
|
5052
|
+
const oldest = this.replayByScope.keys().next().value;
|
|
5053
|
+
if (oldest === void 0) break;
|
|
5054
|
+
this.replayByScope.delete(oldest);
|
|
5055
|
+
}
|
|
5056
|
+
}
|
|
5057
|
+
/**
|
|
5058
|
+
* The replay items for one call id in one scope, when still fresh. The TTL
|
|
5059
|
+
* bounds IDLE time, not total age: a hit refreshes the entry (and its
|
|
5060
|
+
* eviction recency), so an ongoing conversation keeps its chain alive
|
|
5061
|
+
* while a conversation that stopped asking forgets within the TTL. An
|
|
5062
|
+
* absent or aged-out entry answers `undefined` — the no-replay
|
|
5063
|
+
* degradation, never an error.
|
|
5064
|
+
*/
|
|
5065
|
+
replayFor(scope, callId) {
|
|
5066
|
+
const entries = this.replayByScope.get(scope);
|
|
5067
|
+
const entry = entries?.get(callId);
|
|
5068
|
+
if (entries === void 0 || entry === void 0) return void 0;
|
|
5069
|
+
const now = Date.now();
|
|
5070
|
+
if (now - entry.at >= CopilotAdapter.REPLAY_TTL_MS) return void 0;
|
|
5071
|
+
entry.at = now;
|
|
5072
|
+
entries.delete(callId);
|
|
5073
|
+
entries.set(callId, entry);
|
|
5074
|
+
this.replayByScope.delete(scope);
|
|
5075
|
+
this.replayByScope.set(scope, entries);
|
|
5076
|
+
return entry.items;
|
|
5077
|
+
}
|
|
5078
|
+
/**
|
|
5079
|
+
* Drop every captured replay entry. Lookup correctness never depends on
|
|
5080
|
+
* the call — the scope already carries the account identity — but the host
|
|
5081
|
+
* wiring invokes this on every copilot auth transition (login, logout,
|
|
5082
|
+
* credential death) so a switched account's memory never holds the
|
|
5083
|
+
* previous account's encrypted reasoning at all; conversation teardown is
|
|
5084
|
+
* bounded by the TTL and the caps.
|
|
5085
|
+
*/
|
|
5086
|
+
clearReplayState() {
|
|
5087
|
+
this.replayByScope.clear();
|
|
5088
|
+
}
|
|
3204
5089
|
async resolveModel(provider, model) {
|
|
3205
5090
|
const discovered = await this.discovered(model);
|
|
3206
5091
|
const configured = this.options.models.find((entry) => entry.id === model);
|
|
@@ -3209,53 +5094,52 @@ var GrokAdapter = class extends LlmAdapter {
|
|
|
3209
5094
|
id: model,
|
|
3210
5095
|
name: discovered?.name ?? configured?.name ?? model,
|
|
3211
5096
|
...discovered?.description === void 0 ? {} : { description: discovered.description },
|
|
3212
|
-
inputModalities: configured?.inputModalities ??
|
|
3213
|
-
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ??
|
|
3214
|
-
defaultMaxTokens: configured?.maxTokens ??
|
|
5097
|
+
inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ["text"],
|
|
5098
|
+
context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? COPILOT_CONTEXT_WINDOW },
|
|
5099
|
+
defaultMaxTokens: configured?.maxTokens ?? COPILOT_DEFAULT_MAX_TOKENS,
|
|
3215
5100
|
...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
|
|
3216
5101
|
};
|
|
3217
5102
|
}
|
|
3218
5103
|
async *stream(options) {
|
|
3219
5104
|
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
3220
5105
|
try {
|
|
5106
|
+
const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
|
|
3221
5107
|
let session = await this.options.tokens.session();
|
|
3222
|
-
|
|
5108
|
+
const scope = this.replayScope(session.refreshToken, options);
|
|
5109
|
+
let response = await this.request(options, session, watchdog.signal, wire, scope);
|
|
3223
5110
|
if (response.status === 401) {
|
|
5111
|
+
await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
|
|
3224
5112
|
session = await this.options.tokens.session(true);
|
|
3225
|
-
response = await this.request(options, session, watchdog.signal);
|
|
5113
|
+
response = await this.request(options, session, watchdog.signal, wire, scope);
|
|
3226
5114
|
}
|
|
3227
|
-
if (!response.ok) throw await httpLlmError(response, "
|
|
3228
|
-
if (response.body === null) throw new LlmError("
|
|
3229
|
-
|
|
5115
|
+
if (!response.ok) throw await httpLlmError(response, "copilot API");
|
|
5116
|
+
if (response.body === null) throw new LlmError("copilot API returned no response body", EMPTY_RESPONSE_CODE);
|
|
5117
|
+
const pulse = () => {
|
|
3230
5118
|
watchdog.pulse();
|
|
3231
|
-
}
|
|
5119
|
+
};
|
|
5120
|
+
if (wire === "responses") {
|
|
5121
|
+
const normalizer = new CopilotResponsesItemNormalizer((callIds, items) => {
|
|
5122
|
+
this.captureReasoning(scope, callIds, items);
|
|
5123
|
+
});
|
|
5124
|
+
yield* streamResponses(response.body, pulse, (event) => normalizer.push(event));
|
|
5125
|
+
} else yield* streamChatCompletions(response.body, pulse);
|
|
3232
5126
|
} catch (error) {
|
|
3233
|
-
throw mapFetchFailure("
|
|
5127
|
+
throw mapFetchFailure("copilot API", error, watchdog, options.signal);
|
|
3234
5128
|
} finally {
|
|
3235
5129
|
watchdog.stop();
|
|
3236
5130
|
}
|
|
3237
5131
|
}
|
|
3238
|
-
async request(options, session, signal) {
|
|
3239
|
-
const
|
|
3240
|
-
const
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
input,
|
|
3244
|
-
...options.tools !== void 0 && options.tools.length > 0 ? { tools: toResponsesTools(options.tools) } : {},
|
|
3245
|
-
tool_choice: "auto",
|
|
3246
|
-
parallel_tool_calls: true,
|
|
3247
|
-
...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
|
|
3248
|
-
...options.reasoningEffort !== void 0 ? { reasoning: { effort: String(options.reasoningEffort) } } : {},
|
|
3249
|
-
store: false,
|
|
3250
|
-
stream: true
|
|
3251
|
-
};
|
|
3252
|
-
return fetch(GROK_API_URL, {
|
|
5132
|
+
async request(options, session, signal, wire, replayScopeKey) {
|
|
5133
|
+
const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
|
|
5134
|
+
const hasVision = messages.some((message) => message.content.some((block) => block.type === "image"));
|
|
5135
|
+
const body = wire === "responses" ? copilotResponsesRequestBody(options, toResponsesInput(messages, options.system, (callId) => this.replayFor(replayScopeKey, callId))) : copilotChatRequestBody(options, toChatMessages(messages, options.system));
|
|
5136
|
+
return proxiedFetch(wire === "responses" ? COPILOT_RESPONSES_URL : COPILOT_API_URL, {
|
|
3253
5137
|
method: "POST",
|
|
3254
5138
|
headers: {
|
|
3255
5139
|
"authorization": `Bearer ${session.accessToken}`,
|
|
3256
5140
|
"accept": "text/event-stream",
|
|
3257
5141
|
"content-type": "application/json",
|
|
3258
|
-
...
|
|
5142
|
+
...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch))
|
|
3259
5143
|
},
|
|
3260
5144
|
body: JSON.stringify(body),
|
|
3261
5145
|
signal
|
|
@@ -3423,7 +5307,7 @@ function createXSearchTool(options) {
|
|
|
3423
5307
|
async execute(args, exec) {
|
|
3424
5308
|
const request = buildXSearchRequest(args);
|
|
3425
5309
|
const session = await options.tokens.session();
|
|
3426
|
-
const response = await (options.fetchFn ??
|
|
5310
|
+
const response = await (options.fetchFn ?? proxiedFetch)(X_SEARCH_URL, {
|
|
3427
5311
|
method: "POST",
|
|
3428
5312
|
headers: {
|
|
3429
5313
|
"authorization": `Bearer ${session.accessToken}`,
|
|
@@ -3688,7 +5572,7 @@ function createImageGenerateTool(options) {
|
|
|
3688
5572
|
content: result.content.filter((block) => block.type === "text")
|
|
3689
5573
|
}),
|
|
3690
5574
|
async execute(args, exec) {
|
|
3691
|
-
const fetchFn = options.fetchFn ??
|
|
5575
|
+
const fetchFn = options.fetchFn ?? proxiedFetch;
|
|
3692
5576
|
const preferGrok = args.provider === "grok";
|
|
3693
5577
|
const codexReady = options.codexTokens !== void 0 && await options.codexTokens.hasSession();
|
|
3694
5578
|
const grokReady = options.grokTokens !== void 0 && await options.grokTokens.hasSession();
|
|
@@ -3958,7 +5842,7 @@ function createVideoGenerateTool(options) {
|
|
|
3958
5842
|
async execute(args, exec) {
|
|
3959
5843
|
const body = buildVideoGenerateBody(args);
|
|
3960
5844
|
const session = await options.tokens.session();
|
|
3961
|
-
const fetchFn = options.fetchFn ??
|
|
5845
|
+
const fetchFn = options.fetchFn ?? proxiedFetch;
|
|
3962
5846
|
const headers = {
|
|
3963
5847
|
"authorization": `Bearer ${session.accessToken}`,
|
|
3964
5848
|
"accept": "application/json"
|
|
@@ -4020,26 +5904,30 @@ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
|
4020
5904
|
const providerIdSchema = z.union([
|
|
4021
5905
|
"codex",
|
|
4022
5906
|
"claude",
|
|
4023
|
-
"grok"
|
|
5907
|
+
"grok",
|
|
5908
|
+
"copilot"
|
|
4024
5909
|
]);
|
|
4025
5910
|
const modelEntrySchema = z.object({
|
|
4026
5911
|
id: z.string().required(),
|
|
4027
5912
|
name: z.string(),
|
|
4028
5913
|
contextWindow: z.number().step(1).min(1),
|
|
4029
5914
|
maxTokens: z.number().step(1).min(1),
|
|
4030
|
-
inputModalities: z.array(z.union(["text", "image"]))
|
|
5915
|
+
inputModalities: z.array(z.union(["text", "image"])),
|
|
5916
|
+
wire: z.union(["chat-completions", "responses"])
|
|
4031
5917
|
});
|
|
4032
5918
|
const Config = z.object({
|
|
4033
5919
|
providers: z.array(providerIdSchema).default([
|
|
4034
5920
|
"codex",
|
|
4035
5921
|
"claude",
|
|
4036
|
-
"grok"
|
|
5922
|
+
"grok",
|
|
5923
|
+
"copilot"
|
|
4037
5924
|
]),
|
|
4038
5925
|
streamIdleTimeoutMs: z.number().min(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
4039
5926
|
models: z.object({
|
|
4040
5927
|
codex: z.array(modelEntrySchema),
|
|
4041
5928
|
claude: z.array(modelEntrySchema),
|
|
4042
|
-
grok: z.array(modelEntrySchema)
|
|
5929
|
+
grok: z.array(modelEntrySchema),
|
|
5930
|
+
copilot: z.array(modelEntrySchema)
|
|
4043
5931
|
})
|
|
4044
5932
|
});
|
|
4045
5933
|
/** Built-in catalogs used when the config does not override a provider's models. */
|
|
@@ -4096,6 +5984,28 @@ const DEFAULT_MODELS = {
|
|
|
4096
5984
|
id: "grok-code-fast-1",
|
|
4097
5985
|
name: "Grok Code Fast 1"
|
|
4098
5986
|
}
|
|
5987
|
+
],
|
|
5988
|
+
copilot: [
|
|
5989
|
+
{
|
|
5990
|
+
id: "gpt-4.1",
|
|
5991
|
+
name: "GPT-4.1",
|
|
5992
|
+
inputModalities: ["text", "image"]
|
|
5993
|
+
},
|
|
5994
|
+
{
|
|
5995
|
+
id: "gpt-4o",
|
|
5996
|
+
name: "GPT-4o",
|
|
5997
|
+
inputModalities: ["text", "image"]
|
|
5998
|
+
},
|
|
5999
|
+
{
|
|
6000
|
+
id: "claude-sonnet-4.5",
|
|
6001
|
+
name: "Claude Sonnet 4.5",
|
|
6002
|
+
inputModalities: ["text", "image"]
|
|
6003
|
+
},
|
|
6004
|
+
{
|
|
6005
|
+
id: "gemini-2.5-pro",
|
|
6006
|
+
name: "Gemini 2.5 Pro",
|
|
6007
|
+
inputModalities: ["text", "image"]
|
|
6008
|
+
}
|
|
4099
6009
|
]
|
|
4100
6010
|
};
|
|
4101
6011
|
/** Validate and detach the model catalog for every provider. */
|
|
@@ -4107,7 +6017,8 @@ function resolveCatalog(models) {
|
|
|
4107
6017
|
return {
|
|
4108
6018
|
codex: resolve("codex"),
|
|
4109
6019
|
claude: resolve("claude"),
|
|
4110
|
-
grok: resolve("grok")
|
|
6020
|
+
grok: resolve("grok"),
|
|
6021
|
+
copilot: resolve("copilot")
|
|
4111
6022
|
};
|
|
4112
6023
|
}
|
|
4113
6024
|
/** The display account of a stored session, for the status endpoint. */
|
|
@@ -4120,21 +6031,49 @@ function accountOf(provider, session) {
|
|
|
4120
6031
|
}
|
|
4121
6032
|
case "claude": return session.emailAddress;
|
|
4122
6033
|
case "grok": return session.account;
|
|
6034
|
+
case "copilot": return session.account;
|
|
4123
6035
|
}
|
|
4124
6036
|
}
|
|
4125
6037
|
/**
|
|
4126
6038
|
* Auth operations behind the `/subscriptions-auth` RPC channel: start/complete
|
|
4127
6039
|
* OAuth attempts in the background, feed pasted codes, cancel, log out, and
|
|
4128
6040
|
* answer usage lookups.
|
|
6041
|
+
*
|
|
6042
|
+
* @internal Exported for tests only; not part of the plugin's public surface.
|
|
4129
6043
|
*/
|
|
4130
6044
|
var SubscriptionsAuthController = class {
|
|
4131
6045
|
/** Last login failure per provider, surfaced as `detail` until the next success. */
|
|
4132
6046
|
lastError = /* @__PURE__ */ new Map();
|
|
4133
|
-
|
|
6047
|
+
/**
|
|
6048
|
+
* Device-flow logins whose poll already settled but whose token exchange +
|
|
6049
|
+
* persist is still running. Between those two moments the attempt is gone
|
|
6050
|
+
* from the flow manager (busy=false) while no session exists yet
|
|
6051
|
+
* (loggedIn=false) — counting this window as busy keeps the Settings page
|
|
6052
|
+
* polling until the card can show the real outcome.
|
|
6053
|
+
*/
|
|
6054
|
+
finalizing = /* @__PURE__ */ new Set();
|
|
6055
|
+
/** In-flight OAuth completions, one per provider at most. */
|
|
6056
|
+
completions = /* @__PURE__ */ new Map();
|
|
6057
|
+
/**
|
|
6058
|
+
* Per-provider claim counter. Everything that takes ownership of a
|
|
6059
|
+
* provider's session — starting a login, importing Claude Code credentials,
|
|
6060
|
+
* cancelling, logging out — bumps it, and a session write carrying an older
|
|
6061
|
+
* number has been superseded and is dropped.
|
|
6062
|
+
*
|
|
6063
|
+
* The counter is what makes a late OAuth completion safe: an attempt leaves
|
|
6064
|
+
* `OAuthFlowManager`'s pending map the moment its callback delivers the
|
|
6065
|
+
* code, while the token exchange that follows can still run for seconds. For
|
|
6066
|
+
* that whole window `pending(provider)?.cancel()` is a no-op, so ownership
|
|
6067
|
+
* cannot be read off the flow manager.
|
|
6068
|
+
*/
|
|
6069
|
+
claims = /* @__PURE__ */ new Map();
|
|
6070
|
+
constructor(flows, deviceFlows, onAuthChanged, resolveAttachments, usageFetchers = {}, readClaudeCreds = readClaudeCodeCredentials) {
|
|
4134
6071
|
this.flows = flows;
|
|
6072
|
+
this.deviceFlows = deviceFlows;
|
|
4135
6073
|
this.onAuthChanged = onAuthChanged;
|
|
4136
6074
|
this.resolveAttachments = resolveAttachments;
|
|
4137
6075
|
this.usageFetchers = usageFetchers;
|
|
6076
|
+
this.readClaudeCreds = readClaudeCreds;
|
|
4138
6077
|
}
|
|
4139
6078
|
usage(provider, signal) {
|
|
4140
6079
|
const fetcher = this.usageFetchers[provider];
|
|
@@ -4162,7 +6101,7 @@ var SubscriptionsAuthController = class {
|
|
|
4162
6101
|
const detail = this.lastError.get(provider);
|
|
4163
6102
|
return {
|
|
4164
6103
|
loggedIn: session !== void 0,
|
|
4165
|
-
busy: this.flows.isBusy(provider),
|
|
6104
|
+
busy: this.flows.isBusy(provider) || this.deviceFlows.isBusy(provider) || this.finalizing.has(provider),
|
|
4166
6105
|
...session === void 0 ? {} : { expiresAt: session.expiresAt },
|
|
4167
6106
|
...account === void 0 ? {} : { account },
|
|
4168
6107
|
...detail === void 0 ? {} : { detail }
|
|
@@ -4170,30 +6109,73 @@ var SubscriptionsAuthController = class {
|
|
|
4170
6109
|
}
|
|
4171
6110
|
async login(provider) {
|
|
4172
6111
|
if (provider === "claude") {
|
|
4173
|
-
const
|
|
4174
|
-
if (
|
|
4175
|
-
|
|
6112
|
+
const imported = this.readClaudeCreds();
|
|
6113
|
+
if (imported !== void 0) {
|
|
6114
|
+
this.claim("claude");
|
|
6115
|
+
this.flows.pending("claude")?.cancel();
|
|
6116
|
+
await this.persist("claude", imported);
|
|
4176
6117
|
this.lastError.delete("claude");
|
|
4177
6118
|
this.onAuthChanged("claude");
|
|
4178
6119
|
return { authorizeUrl: "" };
|
|
4179
6120
|
}
|
|
4180
|
-
|
|
6121
|
+
const attempt$1 = await this.flows.start("claude", claudeFlow);
|
|
6122
|
+
this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
|
|
6123
|
+
return { authorizeUrl: attempt$1.authorizeUrl };
|
|
6124
|
+
}
|
|
6125
|
+
if (provider === "copilot") {
|
|
6126
|
+
const attempt$1 = await this.deviceFlows.start(provider, copilotDeviceFlow());
|
|
6127
|
+
this.finalizing.add(provider);
|
|
6128
|
+
this.completeDevice(provider, attempt$1);
|
|
6129
|
+
return {
|
|
6130
|
+
authorizeUrl: attempt$1.verificationUrl,
|
|
6131
|
+
userCode: attempt$1.userCode
|
|
6132
|
+
};
|
|
4181
6133
|
}
|
|
4182
6134
|
const spec = provider === "grok" ? await grokFlow() : codexFlow;
|
|
4183
6135
|
const attempt = await this.flows.start(provider, spec);
|
|
4184
|
-
this.complete(provider, attempt);
|
|
6136
|
+
this.completions.set(provider, this.complete(provider, attempt, this.claim(provider)));
|
|
4185
6137
|
return { authorizeUrl: attempt.authorizeUrl };
|
|
4186
6138
|
}
|
|
4187
|
-
/**
|
|
4188
|
-
|
|
6139
|
+
/**
|
|
6140
|
+
* Take ownership of a provider's session, superseding every older claim.
|
|
6141
|
+
* @param provider - the provider route.
|
|
6142
|
+
* @returns the claim number a later write checks itself against.
|
|
6143
|
+
*/
|
|
6144
|
+
claim(provider) {
|
|
6145
|
+
const next = (this.claims.get(provider) ?? 0) + 1;
|
|
6146
|
+
this.claims.set(provider, next);
|
|
6147
|
+
return next;
|
|
6148
|
+
}
|
|
6149
|
+
/**
|
|
6150
|
+
* Drive one attempt to a stored session; records failures for the status
|
|
6151
|
+
* endpoint. The exchange runs unsupervised — the attempt is gone from the
|
|
6152
|
+
* flow manager as soon as its code arrives — so the result is stored only
|
|
6153
|
+
* while `claim` still owns the provider's session.
|
|
6154
|
+
*/
|
|
6155
|
+
async complete(provider, attempt, claim) {
|
|
4189
6156
|
try {
|
|
4190
6157
|
const code = await attempt.waitCode();
|
|
4191
6158
|
const session = await this.exchange(provider, code, attempt);
|
|
6159
|
+
if (this.claims.get(provider) !== claim) return;
|
|
6160
|
+
await this.persist(provider, session);
|
|
6161
|
+
this.lastError.delete(provider);
|
|
6162
|
+
this.onAuthChanged(provider);
|
|
6163
|
+
} catch (error) {
|
|
6164
|
+
if (this.claims.get(provider) !== claim) return;
|
|
6165
|
+
if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
|
|
6166
|
+
}
|
|
6167
|
+
}
|
|
6168
|
+
/** Drive one device-flow attempt to a stored session (the copilot path of {@link complete}). */
|
|
6169
|
+
async completeDevice(provider, attempt) {
|
|
6170
|
+
try {
|
|
6171
|
+
const session = await completeCopilotLogin(await attempt.waitToken());
|
|
4192
6172
|
await this.persist(provider, session);
|
|
4193
6173
|
this.lastError.delete(provider);
|
|
4194
6174
|
this.onAuthChanged(provider);
|
|
4195
6175
|
} catch (error) {
|
|
4196
6176
|
if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
|
|
6177
|
+
} finally {
|
|
6178
|
+
this.finalizing.delete(provider);
|
|
4197
6179
|
}
|
|
4198
6180
|
}
|
|
4199
6181
|
exchange(provider, code, attempt) {
|
|
@@ -4201,6 +6183,7 @@ var SubscriptionsAuthController = class {
|
|
|
4201
6183
|
case "codex": return exchangeCodexCode(code, attempt.pkce.verifier, attempt.redirectUri);
|
|
4202
6184
|
case "claude": return exchangeClaudeCode(code, attempt.pkce.verifier, attempt.redirectUri, attempt.state);
|
|
4203
6185
|
case "grok": return exchangeGrokCode(code, attempt.pkce.verifier, attempt.redirectUri, attempt.pkce.challenge);
|
|
6186
|
+
case "copilot": return Promise.reject(/* @__PURE__ */ new Error("copilot uses the device flow; no authorization code to exchange"));
|
|
4204
6187
|
}
|
|
4205
6188
|
}
|
|
4206
6189
|
persist(provider, session) {
|
|
@@ -4208,8 +6191,19 @@ var SubscriptionsAuthController = class {
|
|
|
4208
6191
|
case "codex": return saveSession("codex", session);
|
|
4209
6192
|
case "claude": return saveSession("claude", session);
|
|
4210
6193
|
case "grok": return saveSession("grok", session);
|
|
6194
|
+
case "copilot": return saveSession("copilot", session);
|
|
4211
6195
|
}
|
|
4212
6196
|
}
|
|
6197
|
+
/**
|
|
6198
|
+
* Settle once no OAuth completion is running for a provider.
|
|
6199
|
+
*
|
|
6200
|
+
* @internal Exported for tests only: a login's token exchange outlives the
|
|
6201
|
+
* `login()` call that started it, and a test asserting on what it stored
|
|
6202
|
+
* would otherwise have to guess at a timeout.
|
|
6203
|
+
*/
|
|
6204
|
+
async settled(provider) {
|
|
6205
|
+
await this.completions.get(provider);
|
|
6206
|
+
}
|
|
4213
6207
|
manual(provider, input) {
|
|
4214
6208
|
const attempt = this.flows.pending(provider);
|
|
4215
6209
|
if (attempt === void 0) return Promise.reject(/* @__PURE__ */ new Error(`no ${provider} login attempt is in progress`));
|
|
@@ -4217,11 +6211,15 @@ var SubscriptionsAuthController = class {
|
|
|
4217
6211
|
return Promise.resolve();
|
|
4218
6212
|
}
|
|
4219
6213
|
cancel(provider) {
|
|
6214
|
+
this.claim(provider);
|
|
4220
6215
|
this.flows.pending(provider)?.cancel();
|
|
6216
|
+
this.deviceFlows.pending(provider)?.cancel();
|
|
4221
6217
|
return Promise.resolve();
|
|
4222
6218
|
}
|
|
4223
6219
|
async logout(provider) {
|
|
6220
|
+
this.claim(provider);
|
|
4224
6221
|
this.flows.pending(provider)?.cancel();
|
|
6222
|
+
this.deviceFlows.pending(provider)?.cancel();
|
|
4225
6223
|
await deleteSession(provider);
|
|
4226
6224
|
this.lastError.delete(provider);
|
|
4227
6225
|
this.onAuthChanged(provider);
|
|
@@ -4234,12 +6232,14 @@ function apply(ctx, config) {
|
|
|
4234
6232
|
const catalog = resolveCatalog(config.models);
|
|
4235
6233
|
const overridden = new Set(PROVIDER_IDS.filter((provider) => (config.models?.[provider]?.length ?? 0) > 0));
|
|
4236
6234
|
const flows = new OAuthFlowManager();
|
|
6235
|
+
const deviceFlows = new DeviceFlowManager();
|
|
4237
6236
|
const onWarn = (message) => {
|
|
4238
6237
|
ctx.logger.warn(`dsh-plugin-subscriptions: ${message}`);
|
|
4239
6238
|
};
|
|
4240
6239
|
const resolveAttachments = () => ctx.get("attachments");
|
|
4241
6240
|
const handles = /* @__PURE__ */ new Map();
|
|
4242
6241
|
const authChanged = (provider) => {
|
|
6242
|
+
if (provider === "copilot") copilotAdapter?.clearReplayState();
|
|
4243
6243
|
handles.get(provider)?.replace([provider]);
|
|
4244
6244
|
};
|
|
4245
6245
|
let codexTokens;
|
|
@@ -4248,6 +6248,7 @@ function apply(ctx, config) {
|
|
|
4248
6248
|
const usageFetchers = {};
|
|
4249
6249
|
const speedBySession = /* @__PURE__ */ new Map();
|
|
4250
6250
|
let codexAdapter;
|
|
6251
|
+
let copilotAdapter;
|
|
4251
6252
|
for (const provider of providers) switch (provider) {
|
|
4252
6253
|
case "codex": {
|
|
4253
6254
|
const tokens = new TokenManager({
|
|
@@ -4263,7 +6264,7 @@ function apply(ctx, config) {
|
|
|
4263
6264
|
}
|
|
4264
6265
|
});
|
|
4265
6266
|
codexTokens = tokens;
|
|
4266
|
-
usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(),
|
|
6267
|
+
usageFetchers.codex = async (signal) => fetchCodexUsage(await tokens.session(), proxiedFetch, signal);
|
|
4267
6268
|
let adapter;
|
|
4268
6269
|
adapter = new CodexAdapter({
|
|
4269
6270
|
models: catalog.codex,
|
|
@@ -4293,7 +6294,7 @@ function apply(ctx, config) {
|
|
|
4293
6294
|
}
|
|
4294
6295
|
});
|
|
4295
6296
|
claudeTokens = tokens;
|
|
4296
|
-
usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(),
|
|
6297
|
+
usageFetchers.claude = async (signal) => fetchClaudeUsage(await tokens.session(), proxiedFetch, signal);
|
|
4297
6298
|
handles.set("claude", ctx.llm.registerAdapter(["claude"], new ClaudeAdapter({
|
|
4298
6299
|
models: catalog.claude,
|
|
4299
6300
|
streamIdleTimeoutMs,
|
|
@@ -4320,7 +6321,7 @@ function apply(ctx, config) {
|
|
|
4320
6321
|
}
|
|
4321
6322
|
});
|
|
4322
6323
|
grokTokens = tokens;
|
|
4323
|
-
usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(),
|
|
6324
|
+
usageFetchers.grok = async (signal) => fetchGrokUsage(await tokens.session(), proxiedFetch, signal);
|
|
4324
6325
|
handles.set("grok", ctx.llm.registerAdapter(["grok"], new GrokAdapter({
|
|
4325
6326
|
models: catalog.grok,
|
|
4326
6327
|
streamIdleTimeoutMs,
|
|
@@ -4332,8 +6333,33 @@ function apply(ctx, config) {
|
|
|
4332
6333
|
})));
|
|
4333
6334
|
break;
|
|
4334
6335
|
}
|
|
6336
|
+
case "copilot": {
|
|
6337
|
+
const tokens = new TokenManager({
|
|
6338
|
+
displayName: "GitHub Copilot",
|
|
6339
|
+
preemptMs: COPILOT_PREEMPT_MS,
|
|
6340
|
+
load: () => getSession("copilot"),
|
|
6341
|
+
save: (session) => saveSession("copilot", session),
|
|
6342
|
+
remove: () => deleteSession("copilot"),
|
|
6343
|
+
refresh: refreshCopilot,
|
|
6344
|
+
isPermanent: isCopilotPermanentRefreshError,
|
|
6345
|
+
onRemoved: () => {
|
|
6346
|
+
authChanged("copilot");
|
|
6347
|
+
}
|
|
6348
|
+
});
|
|
6349
|
+
copilotAdapter = new CopilotAdapter({
|
|
6350
|
+
models: catalog.copilot,
|
|
6351
|
+
streamIdleTimeoutMs,
|
|
6352
|
+
tokens,
|
|
6353
|
+
discovery: !overridden.has("copilot"),
|
|
6354
|
+
onWarn,
|
|
6355
|
+
resolveAttachments,
|
|
6356
|
+
catalogStore: catalogStore("copilot")
|
|
6357
|
+
});
|
|
6358
|
+
handles.set("copilot", ctx.llm.registerAdapter(["copilot"], copilotAdapter));
|
|
6359
|
+
break;
|
|
6360
|
+
}
|
|
4335
6361
|
}
|
|
4336
|
-
registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments, usageFetchers), {
|
|
6362
|
+
registerAuthRpc(ctx, new SubscriptionsAuthController(flows, deviceFlows, authChanged, resolveAttachments, usageFetchers), {
|
|
4337
6363
|
async speed(sessionId) {
|
|
4338
6364
|
return {
|
|
4339
6365
|
tier: speedBySession.get(sessionId) ?? "standard",
|
|
@@ -4344,6 +6370,10 @@ function apply(ctx, config) {
|
|
|
4344
6370
|
if (tier === "standard") speedBySession.delete(sessionId);
|
|
4345
6371
|
else speedBySession.set(sessionId, tier);
|
|
4346
6372
|
}
|
|
6373
|
+
}, {
|
|
6374
|
+
get: () => proxyGetConfig(),
|
|
6375
|
+
set: (input) => proxySetConfig(input),
|
|
6376
|
+
test: (payload) => proxyTestConnection(payload.url, payload.proxy)
|
|
4347
6377
|
});
|
|
4348
6378
|
if (claudeTokens !== void 0) {
|
|
4349
6379
|
const syncTimer = setInterval(() => {
|
|
@@ -4368,4 +6398,4 @@ function apply(ctx, config) {
|
|
|
4368
6398
|
}
|
|
4369
6399
|
|
|
4370
6400
|
//#endregion
|
|
4371
|
-
export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, apply, inject, name };
|
|
6401
|
+
export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, SubscriptionsAuthController, apply, inject, name };
|