dsh-plugin-subscriptions 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -155,7 +155,7 @@ var OAuthFlowManager = class {
155
155
  if (this.attempts.has(provider)) throw new Error(`a ${provider} login attempt is already in progress`);
156
156
  const input = {
157
157
  redirectUri: "",
158
- state: randomToken(16),
158
+ state: randomToken(32),
159
159
  pkce: createPkce(),
160
160
  nonce: randomHex(8)
161
161
  };
@@ -250,6 +250,164 @@ var OAuthFlowManager = class {
250
250
  }
251
251
  };
252
252
 
253
+ //#endregion
254
+ //#region src/auth/device-flow.ts
255
+ /**
256
+ * GitHub OAuth device-authorization flow (RFC 8628) for providers that cannot
257
+ * use the loopback redirect engine: no redirect URI, no PKCE, no client
258
+ * secret. The user opens a verification URL and types a short code while the
259
+ * plugin polls the token endpoint until GitHub releases the access token.
260
+ * The management model (one attempt per provider, `isBusy`/`pending`/`cancel`)
261
+ * mirrors {@link OAuthFlowManager} so the auth controller can treat both
262
+ * engines uniformly.
263
+ */
264
+ /** Default poll interval when the device-code response omits one. */
265
+ const DEFAULT_INTERVAL_SEC = 5;
266
+ /** Default device-code lifetime when the response omits one (GitHub: 15 minutes). */
267
+ const DEFAULT_EXPIRES_IN_SEC = 900;
268
+ /** Sleep for `ms`, rejecting early when the signal aborts. */
269
+ function sleep$1(ms, signal) {
270
+ return new Promise((resolve, reject) => {
271
+ if (signal.aborted) {
272
+ reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("aborted"));
273
+ return;
274
+ }
275
+ const timer = setTimeout(() => {
276
+ signal.removeEventListener("abort", onAbort);
277
+ resolve();
278
+ }, ms);
279
+ timer.unref();
280
+ const onAbort = () => {
281
+ clearTimeout(timer);
282
+ reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("aborted"));
283
+ };
284
+ signal.addEventListener("abort", onAbort, { once: true });
285
+ });
286
+ }
287
+ /**
288
+ * Own the set of in-flight device-flow attempts, keyed by provider. One
289
+ * attempt per provider at a time; an attempt removes itself when it settles.
290
+ */
291
+ var DeviceFlowManager = class {
292
+ attempts = /* @__PURE__ */ new Map();
293
+ /**
294
+ * Whether a device-flow attempt is running for one provider.
295
+ * @param provider - the provider route.
296
+ * @returns true while an attempt is polling.
297
+ */
298
+ isBusy(provider) {
299
+ return this.attempts.has(provider);
300
+ }
301
+ /**
302
+ * The pending attempt for one provider, when any.
303
+ * @param provider - the provider route.
304
+ * @returns the in-flight attempt, or `undefined`.
305
+ */
306
+ pending(provider) {
307
+ return this.attempts.get(provider);
308
+ }
309
+ /**
310
+ * Start a device-flow attempt: request a device code, then poll the token
311
+ * endpoint in the background of `waitToken`.
312
+ * @param provider - the provider route (one attempt at a time).
313
+ * @param spec - static flow facts for this provider.
314
+ * @returns the live attempt; its `waitToken()` settles the login.
315
+ * @throws when an attempt is already running or the device-code request fails.
316
+ */
317
+ async start(provider, spec) {
318
+ if (this.attempts.has(provider)) throw new Error(`a ${provider} login attempt is already in progress`);
319
+ const fetchFn = spec.fetchFn ?? fetch;
320
+ const response = await fetchFn(spec.deviceCodeUrl, {
321
+ method: "POST",
322
+ headers: {
323
+ "accept": "application/json",
324
+ "content-type": "application/x-www-form-urlencoded"
325
+ },
326
+ body: new URLSearchParams({
327
+ client_id: spec.clientId,
328
+ scope: spec.scope
329
+ }).toString()
330
+ });
331
+ if (!response.ok) throw new Error(`${provider} device-code request failed (HTTP ${String(response.status)})`);
332
+ const wire = await response.json();
333
+ 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`);
334
+ const intervalSec = typeof wire.interval === "number" && wire.interval > 0 ? wire.interval : DEFAULT_INTERVAL_SEC;
335
+ const expiresInSec = typeof wire.expires_in === "number" && wire.expires_in > 0 ? wire.expires_in : DEFAULT_EXPIRES_IN_SEC;
336
+ const controller = new AbortController();
337
+ let resolveToken;
338
+ let rejectToken;
339
+ const tokenPromise = new Promise((resolve, reject) => {
340
+ resolveToken = resolve;
341
+ rejectToken = reject;
342
+ });
343
+ tokenPromise.catch(() => void 0);
344
+ const settle = (error, token) => {
345
+ if (this.attempts.get(provider) !== attempt) return;
346
+ this.attempts.delete(provider);
347
+ if (error !== void 0) rejectToken(error);
348
+ else if (token !== void 0) resolveToken(token);
349
+ };
350
+ const poll = async () => {
351
+ let intervalMs = intervalSec * 1e3;
352
+ const deadline = Date.now() + expiresInSec * 1e3;
353
+ while (true) {
354
+ await sleep$1(intervalMs, controller.signal);
355
+ if (Date.now() >= deadline) {
356
+ settle(/* @__PURE__ */ new Error(`login timed out after ${String(Math.round(expiresInSec))}s`));
357
+ return;
358
+ }
359
+ const pollResponse = await fetchFn(spec.tokenUrl, {
360
+ method: "POST",
361
+ headers: {
362
+ "accept": "application/json",
363
+ "content-type": "application/x-www-form-urlencoded"
364
+ },
365
+ body: new URLSearchParams({
366
+ client_id: spec.clientId,
367
+ device_code: wire.device_code,
368
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
369
+ }).toString(),
370
+ signal: controller.signal
371
+ });
372
+ const result = await pollResponse.json();
373
+ if (typeof result.access_token === "string" && result.access_token.length > 0) {
374
+ settle(void 0, result.access_token);
375
+ return;
376
+ }
377
+ switch (result.error) {
378
+ case "authorization_pending": break;
379
+ case "slow_down":
380
+ intervalMs += 5e3;
381
+ break;
382
+ case "access_denied":
383
+ settle(/* @__PURE__ */ new Error("login declined on the GitHub authorization page"));
384
+ return;
385
+ case "expired_token":
386
+ settle(/* @__PURE__ */ new Error("the device code expired before authorization completed"));
387
+ return;
388
+ default:
389
+ settle(/* @__PURE__ */ new Error(`${provider} device-flow polling failed: ${result.error_description ?? result.error ?? `HTTP ${String(pollResponse.status)}`}`));
390
+ return;
391
+ }
392
+ }
393
+ };
394
+ const attempt = {
395
+ verificationUrl: wire.verification_uri,
396
+ userCode: wire.user_code,
397
+ waitToken: () => tokenPromise,
398
+ cancel: () => {
399
+ controller.abort(/* @__PURE__ */ new Error("login cancelled"));
400
+ settle(/* @__PURE__ */ new Error("login cancelled"));
401
+ }
402
+ };
403
+ this.attempts.set(provider, attempt);
404
+ poll().catch((error) => {
405
+ settle(error instanceof Error ? error : new Error(String(error)));
406
+ });
407
+ return attempt;
408
+ }
409
+ };
410
+
253
411
  //#endregion
254
412
  //#region src/auth/claude-code-creds.ts
255
413
  const PRIMARY_SERVICE = "Claude Code-credentials";
@@ -437,7 +595,8 @@ async function refreshClaudeSynced(session, doRefresh) {
437
595
  const PROVIDER_IDS = [
438
596
  "codex",
439
597
  "claude",
440
- "grok"
598
+ "grok",
599
+ "copilot"
441
600
  ];
442
601
  /**
443
602
  * Absolute path of the auth store file.
@@ -513,6 +672,34 @@ async function writeStore(store, path) {
513
672
  }
514
673
  }
515
674
  /**
675
+ * One write chain per store path. Every mutation is a read-modify-write of a
676
+ * single JSON file, and the plugin has several independent writers — a login,
677
+ * a logout, and one token refresh per provider adapter, each on its own
678
+ * schedule. Overlapping them unserialized costs whichever provider read the
679
+ * store first its entry.
680
+ *
681
+ * A chain is dropped once nothing is queued behind it, so the map holds an
682
+ * entry only while writes are in flight.
683
+ */
684
+ const writeChains = /* @__PURE__ */ new Map();
685
+ /**
686
+ * Run one read-modify-write of a store path after every write already queued
687
+ * for it. Callers join the chain synchronously, so call order is write order.
688
+ * @param path - the store file being mutated.
689
+ * @param action - the read-modify-write to run.
690
+ * @returns whatever `action` returns.
691
+ */
692
+ async function serialize(path, action) {
693
+ const next = (writeChains.get(path) ?? Promise.resolve()).then(action, action);
694
+ const tail = next.then(() => void 0, () => void 0);
695
+ writeChains.set(path, tail);
696
+ try {
697
+ return await next;
698
+ } finally {
699
+ if (writeChains.get(path) === tail) writeChains.delete(path);
700
+ }
701
+ }
702
+ /**
516
703
  * Read one provider's session.
517
704
  * @param provider - the provider route.
518
705
  * @param path - store file path; defaults to {@link authFilePath}.
@@ -528,9 +715,11 @@ async function getSession(provider, path = authFilePath()) {
528
715
  * @param path - store file path; defaults to {@link authFilePath}.
529
716
  */
530
717
  async function saveSession(provider, session, path = authFilePath()) {
531
- const store = await loadStore(path);
532
- store[provider] = session;
533
- await writeStore(store, path);
718
+ return serialize(path, async () => {
719
+ const store = await loadStore(path);
720
+ store[provider] = session;
721
+ await writeStore(store, path);
722
+ });
534
723
  }
535
724
  /**
536
725
  * Delete one provider's session (logout).
@@ -538,10 +727,12 @@ async function saveSession(provider, session, path = authFilePath()) {
538
727
  * @param path - store file path; defaults to {@link authFilePath}.
539
728
  */
540
729
  async function deleteSession(provider, path = authFilePath()) {
541
- const store = await loadStore(path);
542
- if (store[provider] === void 0) return;
543
- delete store[provider];
544
- await writeStore(store, path);
730
+ return serialize(path, async () => {
731
+ const store = await loadStore(path);
732
+ if (store[provider] === void 0) return;
733
+ delete store[provider];
734
+ await writeStore(store, path);
735
+ });
545
736
  }
546
737
 
547
738
  //#endregion
@@ -707,6 +898,7 @@ function validateModels(models, label) {
707
898
  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
899
  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
900
  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"`);
901
+ 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
902
  if (seen.has(model.id)) throw new Error(`${label}: duplicate catalog model "${model.id}"`);
711
903
  seen.add(model.id);
712
904
  return {
@@ -714,7 +906,8 @@ function validateModels(models, label) {
714
906
  ...model.name === void 0 ? {} : { name: model.name },
715
907
  ...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
716
908
  ...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens },
717
- ...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] }
909
+ ...model.inputModalities === void 0 ? {} : { inputModalities: [...model.inputModalities] },
910
+ ...model.wire === void 0 ? {} : { wire: model.wire }
718
911
  };
719
912
  });
720
913
  }
@@ -901,7 +1094,7 @@ const DISCOVERY_TTL_MS = 5 * 6e4;
901
1094
  * while a stale entry refreshes in the background, and only awaits the fetch
902
1095
  * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
903
1096
  * last-known state across restarts and receives every successful fetch. A 401
904
- * during a fetch must call {@link invalidate}.
1097
+ * that still fails after a forced token refresh must call {@link invalidate}.
905
1098
  */
906
1099
  var ModelCatalogCache = class {
907
1100
  entry;
@@ -922,6 +1115,14 @@ var ModelCatalogCache = class {
922
1115
  if (this.entry === void 0 || Date.now() - this.entry.at >= this.ttlMs) return void 0;
923
1116
  return this.entry.models;
924
1117
  }
1118
+ /**
1119
+ * The last successfully fetched catalog, ignoring TTL. Used to carry
1120
+ * capability metadata forward when a later fetch cannot re-enrich.
1121
+ * @returns the last-known models, or `undefined` when nothing has been stored.
1122
+ */
1123
+ lastKnown() {
1124
+ return this.entry?.models;
1125
+ }
925
1126
  /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
926
1127
  ensureSeeded() {
927
1128
  if (this.persistence === void 0) return Promise.resolve();
@@ -986,6 +1187,34 @@ var ModelCatalogCache = class {
986
1187
  this.persistence?.clear().catch(() => void 0);
987
1188
  }
988
1189
  };
1190
+ /** Whether discovery failed because the stored login is gone. */
1191
+ function isMissingOrInvalidCredential(error) {
1192
+ return error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL");
1193
+ }
1194
+ /** Whether discovery failed because the access token was rejected. */
1195
+ function isDiscoveryAuthFailure(error) {
1196
+ return error instanceof OAuthEndpointError && error.status === 401 || error instanceof LlmError && error.code === "AUTH";
1197
+ }
1198
+ /**
1199
+ * Run a catalog fetch, retrying once after a forced token refresh when the
1200
+ * first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
1201
+ * when the retry is also an auth failure, so a refresh race cannot erase
1202
+ * last-known capability metadata.
1203
+ */
1204
+ async function discoverOrRetryAuth(session, catalog, run) {
1205
+ try {
1206
+ return await run();
1207
+ } catch (error) {
1208
+ if (isMissingOrInvalidCredential(error) || !isDiscoveryAuthFailure(error)) throw error;
1209
+ try {
1210
+ await session(true);
1211
+ return await run();
1212
+ } catch (retryError) {
1213
+ if (!isMissingOrInvalidCredential(retryError) && isDiscoveryAuthFailure(retryError)) catalog.invalidate();
1214
+ throw retryError;
1215
+ }
1216
+ }
1217
+ }
989
1218
 
990
1219
  //#endregion
991
1220
  //#region src/providers/catalog-store.ts
@@ -1031,6 +1260,12 @@ function sanitizeModel(value) {
1031
1260
  if (thinkingType !== void 0 && thinkingType !== "enabled" && thinkingType !== "adaptive") return void 0;
1032
1261
  const fastTier = raw.fastTier;
1033
1262
  if (fastTier !== void 0 && typeof fastTier !== "boolean") return void 0;
1263
+ const copilotWire = raw.copilotWire;
1264
+ if (copilotWire !== void 0 && copilotWire !== "chat-completions" && copilotWire !== "responses") return;
1265
+ const copilotResponses = raw.copilotResponses;
1266
+ if (copilotResponses !== void 0 && typeof copilotResponses !== "boolean") return void 0;
1267
+ const inputModalities = raw.inputModalities;
1268
+ if (inputModalities !== void 0 && (!Array.isArray(inputModalities) || inputModalities.length === 0 || inputModalities.some((modality) => modality !== "text" && modality !== "image"))) return void 0;
1034
1269
  return {
1035
1270
  id: raw.id,
1036
1271
  name: raw.name,
@@ -1039,7 +1274,10 @@ function sanitizeModel(value) {
1039
1274
  ...raw.priority === void 0 ? {} : { priority: raw.priority },
1040
1275
  ...reasoning === void 0 ? {} : { reasoning },
1041
1276
  ...thinkingType === void 0 ? {} : { thinkingType },
1042
- ...fastTier === void 0 ? {} : { fastTier }
1277
+ ...fastTier === void 0 ? {} : { fastTier },
1278
+ ...copilotWire === void 0 ? {} : { copilotWire },
1279
+ ...copilotResponses === void 0 ? {} : { copilotResponses },
1280
+ ...inputModalities === void 0 ? {} : { inputModalities: [...inputModalities] }
1043
1281
  };
1044
1282
  }
1045
1283
  /**
@@ -1221,22 +1459,31 @@ async function* parseSse(stream, onActivity) {
1221
1459
  //#endregion
1222
1460
  //#region src/translate/responses.ts
1223
1461
  /** Flatten a tool result's content to plain text for `function_call_output`. */
1224
- function toolResultText$1(block) {
1462
+ function toolResultText$2(block) {
1225
1463
  return block.content.map((part) => part.type === "text" ? part.text : "").join("");
1226
1464
  }
1227
1465
  /**
1228
1466
  * Convert harness messages into Responses `instructions` + `input` items.
1229
1467
  * System-role messages become `instructions`; an explicit `system` argument
1230
- * wins over them when both exist. Reasoning blocks are not replayed (v1).
1231
- * Images must arrive pre-resolved ({@link TranslatableMessage}); an unresolved
1232
- * ImageBlock is skipped because its bytes are unreachable here.
1468
+ * wins over them when both exist. Reasoning blocks are never replayed in
1469
+ * their text form: a Responses model continuing past a tool call needs its
1470
+ * reasoning back as the provider's completed reasoning items (id, summary,
1471
+ * and the ENCRYPTED payload), so `reasoningFor` may resolve per-call
1472
+ * captured items, replayed ahead of the matching function_call item. Images
1473
+ * must arrive pre-resolved
1474
+ * ({@link TranslatableMessage}); an unresolved ImageBlock is skipped because
1475
+ * its bytes are unreachable here.
1233
1476
  * @param messages - ordered conversation messages with resolved images.
1234
1477
  * @param system - explicit system prompt, which takes precedence.
1478
+ * @param reasoningFor - resolves one tool call id to the COMPLETED reasoning
1479
+ * items captured for it (id, summary, status, encrypted payload), replayed
1480
+ * ahead of the matching function_call item, when the adapter kept them.
1235
1481
  * @returns request fields ready to merge into the request body.
1236
1482
  */
1237
- function toResponsesInput(messages, system) {
1483
+ function toResponsesInput(messages, system, reasoningFor) {
1238
1484
  const input = [];
1239
1485
  const systemTexts = [];
1486
+ let lastReplay;
1240
1487
  for (const message of messages) {
1241
1488
  if (message.role === "system") {
1242
1489
  for (const block of message.content) if (block.type === "text") systemTexts.push(block.text);
@@ -1260,8 +1507,19 @@ function toResponsesInput(messages, system) {
1260
1507
  text: block.text
1261
1508
  });
1262
1509
  break;
1263
- case "tool-call":
1510
+ case "tool-call": {
1264
1511
  flushMessage();
1512
+ const encrypted = reasoningFor?.(String(block.id));
1513
+ if (encrypted !== void 0 && encrypted !== lastReplay) {
1514
+ for (const item of encrypted) input.push({
1515
+ type: "reasoning",
1516
+ ...item.id === void 0 ? {} : { id: item.id },
1517
+ ...item.summary === void 0 ? {} : { summary: item.summary },
1518
+ ...item.status === void 0 ? {} : { status: item.status },
1519
+ encrypted_content: item.encrypted_content
1520
+ });
1521
+ lastReplay = encrypted;
1522
+ }
1265
1523
  input.push({
1266
1524
  type: "function_call",
1267
1525
  call_id: String(block.id),
@@ -1269,12 +1527,13 @@ function toResponsesInput(messages, system) {
1269
1527
  arguments: block.arguments
1270
1528
  });
1271
1529
  break;
1530
+ }
1272
1531
  case "tool-result":
1273
1532
  flushMessage();
1274
1533
  input.push({
1275
1534
  type: "function_call_output",
1276
1535
  call_id: String(block.toolCallId),
1277
- output: toolResultText$1(block)
1536
+ output: toolResultText$2(block)
1278
1537
  });
1279
1538
  break;
1280
1539
  case "image":
@@ -1336,7 +1595,7 @@ function responsesFailure(code, message) {
1336
1595
  return new LlmError(text, "SERVER");
1337
1596
  }
1338
1597
  /** Assemble the final ContentBlock for one open block. */
1339
- function closeBlock$1(block) {
1598
+ function closeBlock$2(block) {
1340
1599
  switch (block.kind) {
1341
1600
  case "text": return {
1342
1601
  type: "text",
@@ -1398,7 +1657,7 @@ var ResponsesStreamTranslator = class {
1398
1657
  chunks.push({
1399
1658
  type: "block-end",
1400
1659
  index: block.index,
1401
- block: closeBlock$1(block)
1660
+ block: closeBlock$2(block)
1402
1661
  });
1403
1662
  }
1404
1663
  /** Close every still-open block for one output item (prefix match on the key). */
@@ -1415,7 +1674,7 @@ var ResponsesStreamTranslator = class {
1415
1674
  chunks.push({
1416
1675
  type: "block-end",
1417
1676
  index: block.index,
1418
- block: closeBlock$1(block)
1677
+ block: closeBlock$2(block)
1419
1678
  });
1420
1679
  return;
1421
1680
  }
@@ -1540,9 +1799,12 @@ var ResponsesStreamTranslator = class {
1540
1799
  * Consume a Responses SSE byte stream and yield harness StreamChunks.
1541
1800
  * @param stream - raw response body.
1542
1801
  * @param onActivity - transport-activity callback for the idle watchdog.
1802
+ * @param transform - optional per-event rewrite applied before translation
1803
+ * (Copilot's gateway mints a fresh item id per event; the adapter rewrites
1804
+ * them into stable per-item keys).
1543
1805
  * @returns the chunk stream; throws when the stream ends before `response.completed`.
1544
1806
  */
1545
- async function* streamResponses(stream, onActivity) {
1807
+ async function* streamResponses(stream, onActivity, transform) {
1546
1808
  const translator = new ResponsesStreamTranslator();
1547
1809
  for await (const sseEvent of parseSse(stream, onActivity)) {
1548
1810
  let event;
@@ -1551,6 +1813,7 @@ async function* streamResponses(stream, onActivity) {
1551
1813
  } catch {
1552
1814
  throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, "MALFORMED_RESPONSE");
1553
1815
  }
1816
+ if (transform !== void 0) event = transform(event);
1554
1817
  yield* translator.push(event);
1555
1818
  if (translator.terminated) return;
1556
1819
  }
@@ -1874,6 +2137,50 @@ async function fetchCodexModels(session, fetchFn = fetch) {
1874
2137
  if (discovered.length === 0) throw new Error(`codex models endpoint returned an empty catalog (client_version ${CODEX_CLIENT_VERSION})`);
1875
2138
  return discovered;
1876
2139
  }
2140
+ const CODEX_CALL_ID_MAX_LENGTH = 64;
2141
+ const CODEX_CALL_ID_PREFIX = "call_";
2142
+ /**
2143
+ * Bound tool-call ids at the Codex wire boundary without changing the shared
2144
+ * Responses translation used by Grok. Short ids stay verbatim. Oversized ids
2145
+ * become deterministic hashes, and every id already present in this request
2146
+ * is reserved first so a generated id cannot collide with a legitimate short
2147
+ * one (or another oversized id).
2148
+ */
2149
+ function normalizeCodexCallIds(input) {
2150
+ const mapping = /* @__PURE__ */ new Map();
2151
+ const used = /* @__PURE__ */ new Set();
2152
+ const callId = (item) => (item.type === "function_call" || item.type === "function_call_output") && typeof item.call_id === "string" ? item.call_id : void 0;
2153
+ for (const item of input) {
2154
+ const id = callId(item);
2155
+ if (id !== void 0 && id.length <= CODEX_CALL_ID_MAX_LENGTH) {
2156
+ mapping.set(id, id);
2157
+ used.add(id);
2158
+ }
2159
+ }
2160
+ for (const item of input) {
2161
+ const id = callId(item);
2162
+ if (id === void 0 || mapping.has(id)) continue;
2163
+ let attempt = 0;
2164
+ let normalized;
2165
+ do {
2166
+ const hash = createHash("sha256");
2167
+ if (attempt > 0) hash.update(String(attempt)).update("\0");
2168
+ normalized = `${CODEX_CALL_ID_PREFIX}${hash.update(id).digest("hex").slice(0, CODEX_CALL_ID_MAX_LENGTH - 5)}`;
2169
+ attempt += 1;
2170
+ } while (used.has(normalized));
2171
+ mapping.set(id, normalized);
2172
+ used.add(normalized);
2173
+ }
2174
+ return input.map((item) => {
2175
+ const id = callId(item);
2176
+ if (id === void 0) return item;
2177
+ const normalized = mapping.get(id) ?? id;
2178
+ return normalized === id ? item : {
2179
+ ...item,
2180
+ call_id: normalized
2181
+ };
2182
+ });
2183
+ }
1877
2184
  /**
1878
2185
  * The Responses request body for one generation. A fast-tier request (the
1879
2186
  * composer Speed toggle, the codex CLI's fast mode) carries
@@ -1884,7 +2191,7 @@ function codexRequestBody(options, resolved, fast) {
1884
2191
  return {
1885
2192
  model: options.model,
1886
2193
  instructions: resolved.instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
1887
- input: resolved.input,
2194
+ input: normalizeCodexCallIds(resolved.input),
1888
2195
  ...options.tools !== void 0 && options.tools.length > 0 ? { tools: toResponsesTools(options.tools) } : {},
1889
2196
  tool_choice: "auto",
1890
2197
  parallel_tool_calls: true,
@@ -1929,7 +2236,7 @@ var CodexAdapter = class extends LlmAdapter {
1929
2236
  if (await this.options.tokens.peek() === void 0) return [];
1930
2237
  if (!this.options.discovery) return this.staticModels(provider);
1931
2238
  try {
1932
- return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
2239
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
1933
2240
  provider,
1934
2241
  id: model.id,
1935
2242
  name: model.name,
@@ -1937,8 +2244,7 @@ var CodexAdapter = class extends LlmAdapter {
1937
2244
  inputModalities: CODEX_MODALITIES
1938
2245
  }));
1939
2246
  } catch (error) {
1940
- if (error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL")) return [];
1941
- if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
2247
+ if (isMissingOrInvalidCredential(error)) return [];
1942
2248
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
1943
2249
  return this.staticModels(provider);
1944
2250
  }
@@ -2030,8 +2336,27 @@ var CodexAdapter = class extends LlmAdapter {
2030
2336
  * system entry on every request.
2031
2337
  */
2032
2338
  const CLAUDE_CODE_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
2339
+ /** Tags wrapping a mid-conversation system message where it sits in the history. */
2340
+ const SYSTEM_REMINDER_OPEN = "<system-reminder>";
2341
+ const SYSTEM_REMINDER_CLOSE = "</system-reminder>";
2342
+ /**
2343
+ * How far apart consecutive message breakpoints sit, in content blocks.
2344
+ *
2345
+ * A breakpoint looks back at most 20 blocks for an entry an earlier request
2346
+ * wrote, so marks must stay closer than that: one agentic turn can append a
2347
+ * dozen tool_use/tool_result blocks at once, and a single trailing mark would
2348
+ * silently fall out of range and rebuild the whole prefix.
2349
+ */
2350
+ const CACHE_BLOCK_STRIDE = 15;
2351
+ /**
2352
+ * Message breakpoints per request. Anthropic allows four in total and the
2353
+ * last `system` block takes the fourth, so three are left for the history —
2354
+ * enough to tolerate a turn appending roughly {@link CACHE_BLOCK_STRIDE} × 3
2355
+ * blocks before a read is lost.
2356
+ */
2357
+ const MESSAGE_CACHE_BREAKPOINTS = 3;
2033
2358
  /** Flatten a tool result's content to plain text for `tool_result`. */
2034
- function toolResultText(block) {
2359
+ function toolResultText$1(block) {
2035
2360
  return block.content.map((part) => part.type === "text" ? part.text : "").join("");
2036
2361
  }
2037
2362
  /** Parse a tool call's raw JSON arguments into Anthropic's object-shaped `input`. */
@@ -2045,11 +2370,49 @@ function parseToolInput(raw) {
2045
2370
  }
2046
2371
  }
2047
2372
  /**
2373
+ * Move a user message's `tool_result` blocks into one contiguous run at the
2374
+ * front, preserving the relative order of both groups.
2375
+ *
2376
+ * Anthropic answers every `tool_use` against the blocks that *lead* the next
2377
+ * message, so a block of any other kind before or between the results reads
2378
+ * as a call left unanswered and the request is rejected. The harness merges
2379
+ * everything queued for one user turn into a single message, and a parallel
2380
+ * tool batch arrives as one result message per call, so any context spliced
2381
+ * mid-batch lands between two results. Restoring the run here keeps that
2382
+ * independent of delivery order. Order *among* the results does not matter.
2383
+ * @param message - one assembled user message, reordered in place.
2384
+ */
2385
+ function leadWithToolResults(message) {
2386
+ const firstOther = message.content.findIndex((block) => block.type !== "tool_result");
2387
+ if (firstOther === -1) return;
2388
+ if (!message.content.slice(firstOther).some((block) => block.type === "tool_result")) return;
2389
+ message.content = [...message.content.filter((block) => block.type === "tool_result"), ...message.content.filter((block) => block.type !== "tool_result")];
2390
+ }
2391
+ /**
2392
+ * Index of the first non-system message; `messages.length` when every message
2393
+ * is a system one.
2394
+ *
2395
+ * A system message before the conversation starts is the operator's opening
2396
+ * instruction and belongs in the `system` slot. One that arrives later is
2397
+ * mid-conversation context, and hoisting it into `system` would move bytes in
2398
+ * front of the whole history — invalidating every cached turn behind it — so
2399
+ * it stays where it is, as a reminder block in `messages`.
2400
+ * @param messages - ordered conversation messages.
2401
+ * @returns the boundary index separating the two.
2402
+ */
2403
+ function conversationStart(messages) {
2404
+ const index = messages.findIndex((message) => message.role !== "system");
2405
+ return index === -1 ? messages.length : index;
2406
+ }
2407
+ /**
2048
2408
  * Convert harness messages into Anthropic messages. Consecutive same-role
2049
2409
  * messages merge into one message with multiple content blocks; tool results
2050
- * arrive as user messages with `tool_result` blocks; system-role messages are
2051
- * handled by {@link toAnthropicSystem} and skipped here. Reasoning blocks are
2052
- * not replayed (v1). Images must arrive pre-resolved
2410
+ * arrive as user messages with `tool_result` blocks, which a merged user
2411
+ * message keeps in one leading run ({@link leadWithToolResults}); system-role
2412
+ * messages before the conversation starts are handled by
2413
+ * {@link toAnthropicSystem} and skipped here, while a later one rides in
2414
+ * place as a user-role `<system-reminder>` block.
2415
+ * Reasoning blocks are not replayed (v1). Images must arrive pre-resolved
2053
2416
  * ({@link TranslatableMessage}); an unresolved ImageBlock is skipped because
2054
2417
  * its bytes are unreachable here.
2055
2418
  * @param messages - ordered conversation messages with resolved images.
@@ -2057,30 +2420,34 @@ function parseToolInput(raw) {
2057
2420
  */
2058
2421
  function toAnthropicMessages(messages) {
2059
2422
  const out = [];
2060
- for (const message of messages) {
2061
- if (message.role === "system") continue;
2062
- const role = message.role;
2423
+ const start = conversationStart(messages);
2424
+ for (const [index, message] of messages.entries()) {
2425
+ if (message.role === "system" && index < start) continue;
2426
+ const role = message.role === "system" ? "user" : message.role;
2063
2427
  const blocks = [];
2064
2428
  for (const block of message.content) switch (block.type) {
2065
2429
  case "text":
2066
2430
  blocks.push({
2067
2431
  type: "text",
2068
- text: block.text
2432
+ text: message.role === "system" ? `${SYSTEM_REMINDER_OPEN}${block.text}${SYSTEM_REMINDER_CLOSE}` : block.text
2069
2433
  });
2070
2434
  break;
2071
2435
  case "tool-call":
2072
- blocks.push({
2436
+ blocks.push(role === "assistant" ? {
2073
2437
  type: "tool_use",
2074
2438
  id: String(block.id),
2075
2439
  name: block.name,
2076
2440
  input: parseToolInput(block.arguments)
2441
+ } : {
2442
+ type: "text",
2443
+ text: `[tool call ${block.name}: ${block.arguments}]`
2077
2444
  });
2078
2445
  break;
2079
2446
  case "tool-result":
2080
2447
  blocks.push({
2081
2448
  type: "tool_result",
2082
2449
  tool_use_id: String(block.toolCallId),
2083
- content: toolResultText(block),
2450
+ content: toolResultText$1(block),
2084
2451
  ...block.isError === true ? { is_error: true } : {}
2085
2452
  });
2086
2453
  break;
@@ -2104,13 +2471,34 @@ function toAnthropicMessages(messages) {
2104
2471
  content: blocks
2105
2472
  });
2106
2473
  }
2474
+ for (const message of out) if (message.role === "user") leadWithToolResults(message);
2107
2475
  return out;
2108
2476
  }
2109
2477
  /**
2478
+ * Mark the conversation's cache breakpoints in place: the last content block,
2479
+ * then one every {@link CACHE_BLOCK_STRIDE} blocks backwards, {@link
2480
+ * MESSAGE_CACHE_BREAKPOINTS} in total.
2481
+ *
2482
+ * The history is append-only, so the block one request marks last is
2483
+ * byte-identical in the next — that entry is what the next request reads.
2484
+ * Marks are counted across the flattened block sequence, not per message,
2485
+ * because the lookback window Anthropic walks counts blocks the same way.
2486
+ * @param messages - assembled Anthropic messages, marked in place.
2487
+ */
2488
+ function markMessageCache(messages) {
2489
+ const blocks = messages.flatMap((message) => message.content);
2490
+ for (let mark = 0; mark < MESSAGE_CACHE_BREAKPOINTS; mark++) {
2491
+ const at = blocks.length - 1 - mark * CACHE_BLOCK_STRIDE;
2492
+ if (at < 0) return;
2493
+ blocks[at].cache_control = { type: "ephemeral" };
2494
+ }
2495
+ }
2496
+ /**
2110
2497
  * Build the Anthropic `system` array: the mandatory Claude Code identity
2111
2498
  * block, then the explicit system prompt, then any system-role messages.
2112
2499
  * @param system - explicit system prompt, when set.
2113
- * @param messages - conversation messages; their system-role text is appended.
2500
+ * @param messages - conversation messages; the system-role text preceding the
2501
+ * conversation is appended, and a later one is left to {@link toAnthropicMessages}.
2114
2502
  * @returns the system content blocks.
2115
2503
  */
2116
2504
  function toAnthropicSystem(system, messages) {
@@ -2122,29 +2510,34 @@ function toAnthropicSystem(system, messages) {
2122
2510
  type: "text",
2123
2511
  text: system
2124
2512
  });
2125
- for (const message of messages ?? []) {
2126
- if (message.role !== "system") continue;
2127
- for (const block of message.content) if (block.type === "text") blocks.push({
2128
- type: "text",
2129
- text: block.text
2130
- });
2131
- }
2513
+ const history = messages ?? [];
2514
+ for (const message of history.slice(0, conversationStart(history))) for (const block of message.content) if (block.type === "text") blocks.push({
2515
+ type: "text",
2516
+ text: block.text
2517
+ });
2518
+ blocks[blocks.length - 1].cache_control = { type: "ephemeral" };
2132
2519
  return blocks;
2133
2520
  }
2134
2521
  /**
2135
- * Map harness tool schemas to Anthropic tools.
2522
+ * Map harness tool schemas to Anthropic tools, in name order.
2523
+ *
2524
+ * `tools` renders at position 0 of the cached prefix, so any reordering
2525
+ * invalidates every cache entry behind it — `system` and the whole
2526
+ * conversation included. Registration order belongs to the caller and plugin
2527
+ * load order can differ between processes, so the wire order is fixed here
2528
+ * instead. Anthropic selects a tool by name; the array order carries nothing.
2136
2529
  * @param tools - tool schemas from the request.
2137
- * @returns Anthropic `tools` array entries.
2530
+ * @returns Anthropic `tools` array entries, ordered by tool name.
2138
2531
  */
2139
2532
  function toAnthropicTools(tools) {
2140
- return tools.map((tool) => ({
2533
+ return [...tools].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0).map((tool) => ({
2141
2534
  name: tool.name,
2142
2535
  description: tool.description,
2143
2536
  input_schema: tool.parameters
2144
2537
  }));
2145
2538
  }
2146
2539
  /** Assemble the final ContentBlock for one open block. */
2147
- function closeBlock(block) {
2540
+ function closeBlock$1(block) {
2148
2541
  switch (block.kind) {
2149
2542
  case "text": return {
2150
2543
  type: "text",
@@ -2313,7 +2706,7 @@ var AnthropicStreamTranslator = class {
2313
2706
  chunks.push({
2314
2707
  type: "block-end",
2315
2708
  index: block.index,
2316
- block: closeBlock(block)
2709
+ block: closeBlock$1(block)
2317
2710
  });
2318
2711
  return chunks;
2319
2712
  }
@@ -2340,7 +2733,7 @@ var AnthropicStreamTranslator = class {
2340
2733
  chunks.push({
2341
2734
  type: "block-end",
2342
2735
  index: block.index,
2343
- block: closeBlock(block)
2736
+ block: closeBlock$1(block)
2344
2737
  });
2345
2738
  }
2346
2739
  this.emitUsage(chunks);
@@ -2388,11 +2781,13 @@ async function* streamAnthropic(stream, onActivity) {
2388
2781
  //#endregion
2389
2782
  //#region src/providers/claude.ts
2390
2783
  const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
2784
+ const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
2391
2785
  const CLAUDE_TOKEN_URL = "https://claude.ai/v1/oauth/token";
2392
2786
  const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
2393
2787
  const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
2394
2788
  const CLAUDE_MODELS_URL = "https://api.anthropic.com/v1/models?beta=true";
2395
2789
  const CLAUDE_SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
2790
+ const CLAUDE_CALLBACK_PATH = "/callback";
2396
2791
  const CLAUDE_CONTEXT_WINDOW = 2e5;
2397
2792
  const CLAUDE_DEFAULT_MAX_TOKENS = 32e3;
2398
2793
  /** Refresh when the access token has less than this much life left. */
@@ -2428,6 +2823,26 @@ const CLAUDE_BETA_FALLBACK = [
2428
2823
  "files-api-2025-04-14"
2429
2824
  ].join(",");
2430
2825
  const CLAUDE_BETA_FLAGS = CLAUDE_BETA_FALLBACK;
2826
+ /** Static claude flow facts for the OAuth flow engine. */
2827
+ const claudeFlow = {
2828
+ callbackPath: CLAUDE_CALLBACK_PATH,
2829
+ listen: {
2830
+ host: "localhost",
2831
+ ports: [0]
2832
+ },
2833
+ buildAuthorizeUrl({ redirectUri, state, pkce }) {
2834
+ return `${CLAUDE_AUTHORIZE_URL}?${new URLSearchParams({
2835
+ code: "true",
2836
+ client_id: CLAUDE_CLIENT_ID,
2837
+ response_type: "code",
2838
+ redirect_uri: redirectUri,
2839
+ scope: CLAUDE_SCOPE,
2840
+ code_challenge: pkce.challenge,
2841
+ code_challenge_method: "S256",
2842
+ state
2843
+ }).toString()}`;
2844
+ }
2845
+ };
2431
2846
  /** Best-effort account profile; login must not fail when this does. */
2432
2847
  async function fetchClaudeProfile(accessToken) {
2433
2848
  try {
@@ -2652,6 +3067,36 @@ const CLAUDE_RETRY_MAX_DELAY_MS = 6e4;
2652
3067
  const CLAUDE_RETRY_JITTER_RATIO = .2;
2653
3068
  /** The Claude 4.5 family accepts image input. */
2654
3069
  const CLAUDE_MODALITIES = ["text", "image"];
3070
+ /**
3071
+ * Assemble the Anthropic request body.
3072
+ *
3073
+ * Extracted from the adapter so the wire shape — cache breakpoints above all —
3074
+ * is testable without a network round trip. The message array is marked before
3075
+ * it is placed so the breakpoints land on the blocks the body ships: one on the
3076
+ * last `system` block (covering `tools` + `system`, which render ahead of it)
3077
+ * and up to three across the history, Anthropic's four-slot maximum.
3078
+ * @param options - the generate request.
3079
+ * @param messages - conversation messages with images already resolved.
3080
+ * @param maxTokens - the resolved output cap.
3081
+ * @param thinking - the thinking parameter, when the model takes one.
3082
+ * @param effort - the reasoning effort, when the model advertises efforts.
3083
+ * @returns the JSON body to POST.
3084
+ */
3085
+ function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
3086
+ const anthropicMessages = toAnthropicMessages(messages);
3087
+ markMessageCache(anthropicMessages);
3088
+ return {
3089
+ model: options.model,
3090
+ max_tokens: maxTokens,
3091
+ system: toAnthropicSystem(options.system, messages),
3092
+ messages: anthropicMessages,
3093
+ ...options.tools !== void 0 && options.tools.length > 0 ? { tools: toAnthropicTools(options.tools) } : {},
3094
+ ...thinking === void 0 ? {} : { thinking },
3095
+ ...effort === void 0 ? {} : { output_config: { effort } },
3096
+ stream: true,
3097
+ ...options.sessionId !== void 0 ? { metadata: { user_id: String(options.sessionId) } } : {}
3098
+ };
3099
+ }
2655
3100
  /** Claude wire adapter: one instance serves the `claude` provider route. */
2656
3101
  var ClaudeAdapter = class extends LlmAdapter {
2657
3102
  catalog;
@@ -2697,15 +3142,14 @@ var ClaudeAdapter = class extends LlmAdapter {
2697
3142
  if (await this.options.tokens.peek() === void 0) return [];
2698
3143
  if (!this.options.discovery) return this.staticModels(provider);
2699
3144
  try {
2700
- return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
3145
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
2701
3146
  provider,
2702
3147
  id: model.id,
2703
3148
  name: model.name,
2704
3149
  inputModalities: CLAUDE_MODALITIES
2705
3150
  }));
2706
3151
  } catch (error) {
2707
- if (error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL")) return [];
2708
- if (error instanceof LlmError && error.code === "AUTH") this.catalog.invalidate();
3152
+ if (isMissingOrInvalidCredential(error)) return [];
2709
3153
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
2710
3154
  return this.staticModels(provider);
2711
3155
  }
@@ -2770,19 +3214,7 @@ var ClaudeAdapter = class extends LlmAdapter {
2770
3214
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
2771
3215
  const maxTokens = options.maxTokens ?? this.options.models.find((entry) => entry.id === options.model)?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS;
2772
3216
  const disc = await this.discovered(options.model);
2773
- const thinking = this.thinkingParam(disc?.thinkingType, maxTokens);
2774
- const effort = options.reasoningEffort !== void 0 && disc?.reasoning !== void 0 ? { output_config: { effort: String(options.reasoningEffort) } } : {};
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
- };
3217
+ const body = claudeRequestBody(options, messages, maxTokens, this.thinkingParam(disc?.thinkingType, maxTokens), options.reasoningEffort !== void 0 && disc?.reasoning !== void 0 ? String(options.reasoningEffort) : void 0);
2786
3218
  return fetch(CLAUDE_API_URL, {
2787
3219
  method: "POST",
2788
3220
  headers: {
@@ -3109,23 +3541,42 @@ function isChatModel(id) {
3109
3541
  return !/imagine|image-|video|embed/i.test(id);
3110
3542
  }
3111
3543
  /**
3544
+ * CLI-contributed fields carried forward from a previously discovered model.
3545
+ * @param prior - the last-known entry for this id, if any.
3546
+ * @returns enrichment to apply when the live CLI catalog cannot contribute.
3547
+ */
3548
+ function grokPriorMeta(prior) {
3549
+ if (prior === void 0) return {};
3550
+ return {
3551
+ ...prior.name.length > 0 ? { name: prior.name } : {},
3552
+ ...prior.description === void 0 ? {} : { description: prior.description },
3553
+ ...prior.contextWindow === void 0 ? {} : { contextWindow: prior.contextWindow },
3554
+ ...prior.reasoning === void 0 ? {} : { reasoning: prior.reasoning }
3555
+ };
3556
+ }
3557
+ /**
3112
3558
  * Fetch the live grok model list, enriched with the CLI catalog's per-model
3113
3559
  * metadata (display name, context window, reasoning efforts). The api.x.ai
3114
3560
  * list stays authoritative for which models exist; the CLI catalog is
3115
3561
  * enrichment only, so its failure degrades to a plain list instead of taking
3116
- * discovery down models it does not cover simply expose no efforts.
3562
+ * discovery down. When enrichment is missing, last-known capability metadata
3563
+ * is carried forward so a transient CLI outage cannot strip efforts a
3564
+ * session already selected.
3117
3565
  * @param session - the stored session (used as-is; never refreshed here).
3118
3566
  * @param fetchFn - fetch implementation (injectable for tests).
3119
3567
  * @param onWarn - warning sink for a failed CLI catalog fetch.
3568
+ * @param previous - last-known catalog used to keep enrichment when the CLI
3569
+ * catalog is down or omits a model.
3120
3570
  * @returns discovered chat models in endpoint order.
3121
3571
  */
3122
- async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
3572
+ async function fetchGrokModels(session, fetchFn = fetch, onWarn, previous) {
3573
+ const previousById = previous === void 0 || previous.length === 0 ? void 0 : new Map(previous.map((model) => [model.id, model]));
3123
3574
  const [response, cliCatalog] = await Promise.all([fetchFn(GROK_MODELS_URL, { headers: {
3124
3575
  "authorization": `Bearer ${session.accessToken}`,
3125
3576
  "accept": "application/json",
3126
3577
  ...attributionHeaders()
3127
3578
  } }), fetchGrokCliCatalog(session, fetchFn).catch((error) => {
3128
- onWarn?.(`grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})`);
3579
+ 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)})`);
3129
3580
  })]);
3130
3581
  if (!response.ok) throw await oauthEndpointError(response, "grok models");
3131
3582
  const payload = await response.json();
@@ -3136,10 +3587,11 @@ async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
3136
3587
  if (typeof entry.id !== "string" || entry.id.length === 0 || seen.has(entry.id)) continue;
3137
3588
  if (!isChatModel(entry.id)) continue;
3138
3589
  seen.add(entry.id);
3590
+ const cli = cliCatalog?.get(entry.id);
3139
3591
  discovered.push({
3140
3592
  id: entry.id,
3141
3593
  name: entry.id,
3142
- ...cliCatalog?.get(entry.id)
3594
+ ...cli ?? grokPriorMeta(previousById?.get(entry.id))
3143
3595
  });
3144
3596
  }
3145
3597
  if (discovered.length === 0) throw new Error("grok models endpoint returned an empty catalog");
@@ -3155,7 +3607,16 @@ var GrokAdapter = class extends LlmAdapter {
3155
3607
  }
3156
3608
  /** Discovery fetcher: resolves the session through the refresh-aware path. */
3157
3609
  async fetchCatalog() {
3158
- return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn);
3610
+ return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn, this.catalog.lastKnown());
3611
+ }
3612
+ listed(provider, discovered) {
3613
+ return discovered.map((model) => ({
3614
+ provider,
3615
+ id: model.id,
3616
+ name: model.name,
3617
+ ...model.description === void 0 ? {} : { description: model.description },
3618
+ inputModalities: grokModalities(model.id)
3619
+ }));
3159
3620
  }
3160
3621
  providerInfo(provider) {
3161
3622
  return {
@@ -3175,16 +3636,9 @@ var GrokAdapter = class extends LlmAdapter {
3175
3636
  if (await this.options.tokens.peek() === void 0) return [];
3176
3637
  if (!this.options.discovery) return this.staticModels(provider);
3177
3638
  try {
3178
- return (await this.catalog.get(() => this.fetchCatalog())).map((model) => ({
3179
- provider,
3180
- id: model.id,
3181
- name: model.name,
3182
- ...model.description === void 0 ? {} : { description: model.description },
3183
- inputModalities: grokModalities(model.id)
3184
- }));
3639
+ return this.listed(provider, await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog())));
3185
3640
  } catch (error) {
3186
- if (error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL")) return [];
3187
- if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
3641
+ if (isMissingOrInvalidCredential(error)) return [];
3188
3642
  this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
3189
3643
  return this.staticModels(provider);
3190
3644
  }
@@ -3264,70 +3718,1088 @@ var GrokAdapter = class extends LlmAdapter {
3264
3718
  };
3265
3719
 
3266
3720
  //#endregion
3267
- //#region src/tools/x-search.ts
3268
- /** Endpoint the search request is posted to. */
3269
- const X_SEARCH_URL = "https://api.x.ai/v1/responses";
3270
- /** Grok model the search runs on (a catalog model of the grok provider). */
3271
- const X_SEARCH_MODEL = "grok-4";
3272
- /** xAI caps each handle filter list at ten entries. */
3273
- const MAX_HANDLES = 10;
3274
- /**
3275
- * Validate and assemble the request facts from tool arguments. Throws plain
3276
- * Errors for argument problems the schema DSL cannot express (non-empty
3277
- * query, handle caps, mutually exclusive filters).
3278
- */
3279
- function buildXSearchRequest(args) {
3280
- const query = args.query.trim();
3281
- if (query.length === 0) throw new Error("x_search: query must be a non-empty string");
3282
- const allowed = normalizeHandles(args.allowed_x_handles, "allowed_x_handles");
3283
- const excluded = normalizeHandles(args.excluded_x_handles, "excluded_x_handles");
3284
- if (allowed.length > 0 && excluded.length > 0) throw new Error("x_search: allowed_x_handles and excluded_x_handles cannot be used together");
3285
- const tool = { type: "x_search" };
3286
- if (allowed.length > 0) tool.allowed_x_handles = allowed;
3287
- if (excluded.length > 0) tool.excluded_x_handles = excluded;
3288
- if (args.from_date !== void 0 && args.from_date.trim().length > 0) tool.from_date = args.from_date.trim();
3289
- if (args.to_date !== void 0 && args.to_date.trim().length > 0) tool.to_date = args.to_date.trim();
3290
- if (args.enable_image_understanding === true) tool.enable_image_understanding = true;
3291
- if (args.enable_video_understanding === true) tool.enable_video_understanding = true;
3292
- return {
3293
- query,
3294
- tool
3295
- };
3296
- }
3297
- /** Strip `@` prefixes, drop blanks, and enforce the provider's handle cap. */
3298
- function normalizeHandles(value, field) {
3299
- if (value === void 0) return [];
3300
- const handles = value.map((handle) => handle.trim().replace(/^@+/, "")).filter((handle) => handle.length > 0);
3301
- if (handles.length > MAX_HANDLES) throw new Error(`x_search: ${field} supports at most ${MAX_HANDLES} handles`);
3302
- return handles;
3303
- }
3304
- function isRecord$1(value) {
3305
- return typeof value === "object" && value !== null && !Array.isArray(value);
3721
+ //#region src/translate/chat-completions.ts
3722
+ /** Flatten a tool result's content to plain text for a `tool` message. */
3723
+ function toolResultText(block) {
3724
+ return block.content.map((part) => part.type === "text" ? part.text : "").join("");
3306
3725
  }
3307
3726
  /**
3308
- * Extract the answer text and citation URLs from a Responses payload: the
3309
- * `output_text` shortcut or message output parts for the answer, and both
3310
- * top-level `citations` and inline `url_citation` annotations for sources.
3727
+ * Convert harness messages into chat completions `messages`. System-role
3728
+ * messages become one leading `system` message; an explicit `system` argument
3729
+ * wins over them when both exist. Reasoning blocks are not replayed (matching
3730
+ * the Responses translator). Images must arrive pre-resolved; an unresolved
3731
+ * ImageBlock is skipped because its bytes are unreachable here. A user message
3732
+ * carrying only text collapses to a plain string body (some endpoints still
3733
+ * reject content-part arrays); tool results become separate `tool` messages.
3734
+ * @param messages - ordered conversation messages with resolved images.
3735
+ * @param system - explicit system prompt, which takes precedence.
3736
+ * @returns the wire `messages` array.
3311
3737
  */
3312
- function parseXSearchResponse(payload) {
3313
- const body = isRecord$1(payload) ? payload : {};
3314
- let answer = typeof body.output_text === "string" ? body.output_text.trim() : "";
3315
- const citations = [];
3316
- const push = (url) => {
3317
- if (typeof url === "string" && url.length > 0 && !citations.includes(url)) citations.push(url);
3318
- };
3319
- if (Array.isArray(body.citations)) for (const citation of body.citations) push(citation);
3320
- const parts = [];
3321
- if (Array.isArray(body.output)) for (const item of body.output) {
3322
- if (!isRecord$1(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
3323
- for (const part of item.content) {
3324
- if (!isRecord$1(part)) continue;
3325
- if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string" && part.text.trim().length > 0) parts.push(part.text.trim());
3326
- if (Array.isArray(part.annotations)) {
3327
- for (const annotation of part.annotations) if (isRecord$1(annotation) && annotation.type === "url_citation") push(annotation.url);
3738
+ function toChatMessages(messages, system) {
3739
+ const out = [];
3740
+ const systemTexts = [];
3741
+ for (const message of messages) {
3742
+ if (message.role === "system") {
3743
+ for (const block of message.content) if (block.type === "text") systemTexts.push(block.text);
3744
+ continue;
3745
+ }
3746
+ if (message.role === "user") {
3747
+ let texts$1 = [];
3748
+ let parts = [];
3749
+ const flushUser = () => {
3750
+ if (parts.length > 0) {
3751
+ if (texts$1.length > 0) parts.unshift({
3752
+ type: "text",
3753
+ text: texts$1.join("\n")
3754
+ });
3755
+ out.push({
3756
+ role: "user",
3757
+ content: parts
3758
+ });
3759
+ } else if (texts$1.length > 0) out.push({
3760
+ role: "user",
3761
+ content: texts$1.join("\n")
3762
+ });
3763
+ texts$1 = [];
3764
+ parts = [];
3765
+ };
3766
+ for (const block of message.content) switch (block.type) {
3767
+ case "text":
3768
+ texts$1.push(block.text);
3769
+ break;
3770
+ case "image":
3771
+ if ("dataBase64" in block) parts.push({
3772
+ type: "image_url",
3773
+ image_url: { url: `data:${block.mediaType};base64,${block.dataBase64}` }
3774
+ });
3775
+ break;
3776
+ case "tool-result":
3777
+ flushUser();
3778
+ out.push({
3779
+ role: "tool",
3780
+ tool_call_id: String(block.toolCallId),
3781
+ content: toolResultText(block)
3782
+ });
3783
+ break;
3784
+ default: break;
3328
3785
  }
3786
+ flushUser();
3787
+ continue;
3329
3788
  }
3330
- }
3789
+ const texts = [];
3790
+ const toolCalls = [];
3791
+ for (const block of message.content) switch (block.type) {
3792
+ case "text":
3793
+ texts.push(block.text);
3794
+ break;
3795
+ case "tool-call":
3796
+ toolCalls.push({
3797
+ id: String(block.id),
3798
+ type: "function",
3799
+ function: {
3800
+ name: block.name,
3801
+ arguments: block.arguments
3802
+ }
3803
+ });
3804
+ break;
3805
+ default: break;
3806
+ }
3807
+ if (texts.length === 0 && toolCalls.length === 0) continue;
3808
+ out.push({
3809
+ role: "assistant",
3810
+ content: texts.join("\n"),
3811
+ ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
3812
+ });
3813
+ }
3814
+ const systemText = system ?? (systemTexts.length > 0 ? systemTexts.join("\n\n") : void 0);
3815
+ if (systemText !== void 0) out.unshift({
3816
+ role: "system",
3817
+ content: systemText
3818
+ });
3819
+ return out;
3820
+ }
3821
+ /**
3822
+ * Map harness tool schemas to chat completions function tools.
3823
+ * @param tools - tool schemas from the request.
3824
+ * @returns the wire `tools` array.
3825
+ */
3826
+ function toChatTools(tools) {
3827
+ return tools.map((tool) => ({
3828
+ type: "function",
3829
+ function: {
3830
+ name: tool.name,
3831
+ description: tool.description,
3832
+ parameters: tool.parameters
3833
+ }
3834
+ }));
3835
+ }
3836
+ /**
3837
+ * Map chat completions usage to disjoint harness counts (cached input is
3838
+ * subtracted out of `inputTokens` and reported as `cacheReadTokens`).
3839
+ * @param usage - wire usage from the terminal chunk.
3840
+ * @returns harness token usage.
3841
+ */
3842
+ function mapChatCompletionsUsage(usage) {
3843
+ const cached = usage.prompt_tokens_details?.cached_tokens;
3844
+ const reasoning = usage.completion_tokens_details?.reasoning_tokens;
3845
+ return {
3846
+ inputTokens: usage.prompt_tokens - (cached ?? 0),
3847
+ outputTokens: usage.completion_tokens,
3848
+ ...cached !== void 0 ? { cacheReadTokens: cached } : {},
3849
+ ...reasoning !== void 0 ? { reasoningTokens: reasoning } : {}
3850
+ };
3851
+ }
3852
+ /** Assemble the final ContentBlock for one open block. */
3853
+ function closeBlock(block) {
3854
+ switch (block.kind) {
3855
+ case "text": return {
3856
+ type: "text",
3857
+ text: block.text
3858
+ };
3859
+ case "reasoning": return {
3860
+ type: "reasoning",
3861
+ text: block.text
3862
+ };
3863
+ case "tool-call": return {
3864
+ type: "tool-call",
3865
+ id: CallId(block.callId),
3866
+ name: block.name ?? "",
3867
+ arguments: block.text
3868
+ };
3869
+ }
3870
+ }
3871
+ /**
3872
+ * Push-model chat completions SSE translator: feed each parsed chunk object
3873
+ * to {@link push} and collect the emitted harness StreamChunks. The terminal
3874
+ * `finish_reason` chunk closes every block but only ARMS the finish chunk —
3875
+ * usage must precede the terminal finish, and where usage lives differs by
3876
+ * upstream: OpenAI-style streams send a trailing usage-only chunk
3877
+ * (stream_options.include_usage), while Copilot's Gemini models attach a
3878
+ * (zero) usage object to EVERY chunk and fold the real usage into the
3879
+ * finish chunk itself. A chunk therefore never early-returns on `usage`
3880
+ * alone: its deltas are always processed, and the terminal pair is drained
3881
+ * when the finish is armed and usage arrived (or when a usage-only chunk
3882
+ * follows an armed finish). `flush()` emits whatever remains when the
3883
+ * stream's `[DONE]` (or EOF) arrives.
3884
+ */
3885
+ var ChatCompletionsStreamTranslator = class {
3886
+ /** Text/reasoning blocks keyed by kind; tool calls keyed by their wire index. */
3887
+ blocks = /* @__PURE__ */ new Map();
3888
+ order = [];
3889
+ nextIndex = 0;
3890
+ sawToolCall = false;
3891
+ pendingUsage;
3892
+ armedFinish;
3893
+ /** Set once the terminal finish chunk was emitted. */
3894
+ terminated = false;
3895
+ open(key, kind, chunks, callId = "", name$1) {
3896
+ const block = {
3897
+ index: this.nextIndex++,
3898
+ kind,
3899
+ text: "",
3900
+ callId,
3901
+ ...name$1 === void 0 ? {} : { name: name$1 }
3902
+ };
3903
+ this.blocks.set(key, block);
3904
+ this.order.push(block);
3905
+ chunks.push({
3906
+ type: "block-start",
3907
+ index: block.index,
3908
+ blockType: kind
3909
+ });
3910
+ return block;
3911
+ }
3912
+ close(key, chunks) {
3913
+ const block = this.blocks.get(key);
3914
+ if (block === void 0) return;
3915
+ this.blocks.delete(key);
3916
+ chunks.push({
3917
+ type: "block-end",
3918
+ index: block.index,
3919
+ block: closeBlock(block)
3920
+ });
3921
+ }
3922
+ closeAll(chunks) {
3923
+ for (const key of [...this.blocks.keys()]) this.close(key, chunks);
3924
+ }
3925
+ /** Build the terminal finish chunk for one wire finish reason. */
3926
+ finishChunk(finishReason) {
3927
+ if (this.order.length === 0) return {
3928
+ type: "finish",
3929
+ reason: {
3930
+ kind: "error",
3931
+ failure: {
3932
+ message: "model returned a completed response with no content",
3933
+ code: EMPTY_RESPONSE_CODE
3934
+ }
3935
+ }
3936
+ };
3937
+ switch (finishReason) {
3938
+ case "tool_calls": return {
3939
+ type: "finish",
3940
+ reason: { kind: "tool-calls" }
3941
+ };
3942
+ case "length": return {
3943
+ type: "finish",
3944
+ reason: { kind: "max-tokens" }
3945
+ };
3946
+ case "content_filter": return {
3947
+ type: "finish",
3948
+ reason: {
3949
+ kind: "error",
3950
+ failure: {
3951
+ message: "the response was blocked by the provider content filter",
3952
+ code: "CONTENT_FILTER"
3953
+ }
3954
+ }
3955
+ };
3956
+ default: return {
3957
+ type: "finish",
3958
+ reason: { kind: this.sawToolCall ? "tool-calls" : "stop" }
3959
+ };
3960
+ }
3961
+ }
3962
+ /** Usage, then the armed finish: the only order the harness accepts. */
3963
+ drainTerminal(chunks) {
3964
+ if (this.pendingUsage !== void 0) {
3965
+ chunks.push({
3966
+ type: "usage",
3967
+ usage: mapChatCompletionsUsage(this.pendingUsage)
3968
+ });
3969
+ this.pendingUsage = void 0;
3970
+ }
3971
+ if (this.armedFinish !== void 0) {
3972
+ chunks.push(this.armedFinish);
3973
+ this.armedFinish = void 0;
3974
+ this.terminated = true;
3975
+ }
3976
+ }
3977
+ /**
3978
+ * Process one parsed chat-completion chunk.
3979
+ * @param event - the parsed chunk object.
3980
+ * @returns the StreamChunks this event produced (possibly none).
3981
+ */
3982
+ push(event) {
3983
+ if (this.terminated) return [];
3984
+ const chunks = [];
3985
+ const usage = event.usage;
3986
+ const hasUsage = usage !== void 0 && usage !== null;
3987
+ if (hasUsage) this.pendingUsage = usage;
3988
+ const choice = event.choices?.[0];
3989
+ const delta = choice?.delta;
3990
+ if (delta !== void 0) {
3991
+ if (typeof delta.content === "string" && delta.content.length > 0) {
3992
+ const block = this.blocks.get("content") ?? this.open("content", "text", chunks);
3993
+ block.text += delta.content;
3994
+ chunks.push({
3995
+ type: "text-delta",
3996
+ index: block.index,
3997
+ text: delta.content
3998
+ });
3999
+ }
4000
+ const reasoning = typeof delta.reasoning_content === "string" ? delta.reasoning_content : typeof delta.reasoning_text === "string" ? delta.reasoning_text : void 0;
4001
+ if (reasoning !== void 0 && reasoning.length > 0) {
4002
+ const block = this.blocks.get("reasoning") ?? this.open("reasoning", "reasoning", chunks);
4003
+ block.text += reasoning;
4004
+ chunks.push({
4005
+ type: "reasoning-delta",
4006
+ index: block.index,
4007
+ text: reasoning
4008
+ });
4009
+ }
4010
+ for (const call of delta.tool_calls ?? []) {
4011
+ const key = `call:${String(call.index ?? 0)}`;
4012
+ let block = this.blocks.get(key);
4013
+ if (block === void 0) {
4014
+ this.sawToolCall = true;
4015
+ block = this.open(key, "tool-call", chunks, call.id ?? "", call.function?.name);
4016
+ chunks.push({
4017
+ type: "tool-call-delta",
4018
+ index: block.index,
4019
+ id: CallId(block.callId),
4020
+ ...block.name === void 0 ? {} : { name: block.name },
4021
+ argumentsDelta: ""
4022
+ });
4023
+ }
4024
+ if (call.function?.arguments !== void 0 && call.function.arguments.length > 0) {
4025
+ block.text += call.function.arguments;
4026
+ chunks.push({
4027
+ type: "tool-call-delta",
4028
+ index: block.index,
4029
+ id: CallId(block.callId),
4030
+ argumentsDelta: call.function.arguments
4031
+ });
4032
+ }
4033
+ }
4034
+ }
4035
+ if (choice?.finish_reason !== void 0 && choice.finish_reason !== null) {
4036
+ this.closeAll(chunks);
4037
+ if (this.armedFinish === void 0) this.armedFinish = this.finishChunk(choice.finish_reason);
4038
+ }
4039
+ if (hasUsage && (this.armedFinish !== void 0 || choice === void 0)) this.drainTerminal(chunks);
4040
+ return chunks;
4041
+ }
4042
+ /**
4043
+ * Emit whatever the stream left pending (`[DONE]` or EOF without a final
4044
+ * usage chunk). Safe to call repeatedly.
4045
+ * @returns the remaining terminal chunks.
4046
+ */
4047
+ flush() {
4048
+ const chunks = [];
4049
+ this.drainTerminal(chunks);
4050
+ return chunks;
4051
+ }
4052
+ };
4053
+ /**
4054
+ * Consume a chat completions SSE byte stream and yield harness StreamChunks.
4055
+ * @param stream - raw response body.
4056
+ * @param onActivity - transport-activity callback for the idle watchdog.
4057
+ * @returns the chunk stream; throws when the stream ends before any finish chunk.
4058
+ */
4059
+ async function* streamChatCompletions(stream, onActivity) {
4060
+ const translator = new ChatCompletionsStreamTranslator();
4061
+ for await (const sseEvent of parseSse(stream, onActivity)) {
4062
+ if (sseEvent.data === "[DONE]") {
4063
+ yield* translator.flush();
4064
+ return;
4065
+ }
4066
+ let event;
4067
+ try {
4068
+ event = JSON.parse(sseEvent.data);
4069
+ } catch {
4070
+ throw new LlmError(`malformed SSE payload: ${sseEvent.data.slice(0, 120)}`, "MALFORMED_RESPONSE");
4071
+ }
4072
+ yield* translator.push(event);
4073
+ if (translator.terminated) return;
4074
+ }
4075
+ yield* translator.flush();
4076
+ if (!translator.terminated) throw new LlmError("chat completions SSE stream ended before a finish chunk", "STREAM_CLOSED");
4077
+ }
4078
+
4079
+ //#endregion
4080
+ //#region src/providers/copilot.ts
4081
+ /**
4082
+ * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
4083
+ * copilot2api-go use the same value): the app is pre-authorized for the
4084
+ * Copilot internal token exchange, a self-registered OAuth App is not.
4085
+ */
4086
+ const COPILOT_CLIENT_ID = "Iv1.b507a08c87ecfe98";
4087
+ const COPILOT_DEVICE_CODE_URL = "https://github.com/login/device/code";
4088
+ const COPILOT_DEVICE_TOKEN_URL = "https://github.com/login/oauth/access_token";
4089
+ const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
4090
+ const GITHUB_USER_URL = "https://api.github.com/user";
4091
+ const COPILOT_API_URL = "https://api.githubcopilot.com/chat/completions";
4092
+ /** Responses endpoint for models whose catalog entry only lists `/responses`. */
4093
+ const COPILOT_RESPONSES_URL = "https://api.githubcopilot.com/responses";
4094
+ const COPILOT_MODELS_URL = "https://api.githubcopilot.com/models";
4095
+ const COPILOT_SCOPE = "read:user";
4096
+ const COPILOT_CONTEXT_WINDOW = 128e3;
4097
+ const COPILOT_DEFAULT_MAX_TOKENS = 16e3;
4098
+ /** Refresh when the Copilot API token has less than this much life left. */
4099
+ const COPILOT_PREEMPT_MS = 5 * 6e4;
4100
+ /**
4101
+ * The VS Code update feed answers a JSON array of version strings, latest
4102
+ * stable first. The Copilot API rejects requests whose Editor-Version is too
4103
+ * old with `401 IDE token expired`, so the version is resolved live (cached
4104
+ * for a day) instead of hardcoded — a stale hardcode bricks every request.
4105
+ */
4106
+ const VSCODE_RELEASES_URL = "https://update.code.visualstudio.com/api/releases/stable";
4107
+ /** Last-known-good VS Code version when the feed is unreachable. */
4108
+ const FALLBACK_VSCODE_VERSION = "1.107.0";
4109
+ const VSCODE_VERSION_TTL_MS = 24 * 36e5;
4110
+ let vscodeVersionCache;
4111
+ let vscodeVersionInflight;
4112
+ /**
4113
+ * Resolve the VS Code version presented as Editor-Version: the latest stable
4114
+ * from the update feed, cached for a day, falling back to a pinned version
4115
+ * when the feed fails. Concurrent resolves coalesce behind one fetch.
4116
+ * @param fetchFn - fetch implementation (injectable for tests).
4117
+ * @param forceRefresh - bypass the cache (a 401 `IDE token expired` retry).
4118
+ * @returns a `major.minor.patch` version string.
4119
+ */
4120
+ async function latestVsCodeVersion(fetchFn = fetch, forceRefresh = false) {
4121
+ if (!forceRefresh && vscodeVersionCache !== void 0 && Date.now() - vscodeVersionCache.at < VSCODE_VERSION_TTL_MS) return vscodeVersionCache.version;
4122
+ vscodeVersionInflight ??= (async () => {
4123
+ try {
4124
+ const response = await fetchFn(VSCODE_RELEASES_URL, { headers: { accept: "application/json" } });
4125
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
4126
+ const releases = await response.json();
4127
+ const version = Array.isArray(releases) ? releases.find((entry) => typeof entry === "string" && /^\d+\.\d+\.\d+$/.test(entry)) : void 0;
4128
+ if (version === void 0) throw new Error("no version string in the feed");
4129
+ vscodeVersionCache = {
4130
+ version,
4131
+ at: Date.now()
4132
+ };
4133
+ return version;
4134
+ } catch {
4135
+ return vscodeVersionCache?.version ?? FALLBACK_VSCODE_VERSION;
4136
+ }
4137
+ })().finally(() => {
4138
+ vscodeVersionInflight = void 0;
4139
+ });
4140
+ return vscodeVersionInflight;
4141
+ }
4142
+ /**
4143
+ * The device-flow facts for the auth controller's DeviceFlowManager.
4144
+ * @returns the flow spec for one attempt.
4145
+ */
4146
+ function copilotDeviceFlow() {
4147
+ return {
4148
+ clientId: COPILOT_CLIENT_ID,
4149
+ scope: COPILOT_SCOPE,
4150
+ deviceCodeUrl: COPILOT_DEVICE_CODE_URL,
4151
+ tokenUrl: COPILOT_DEVICE_TOKEN_URL
4152
+ };
4153
+ }
4154
+ /**
4155
+ * Header set presenting requests as the VS Code Copilot Chat extension; the
4156
+ * Copilot API rejects traffic without an editor identity.
4157
+ * @param hasVision - whether the request carries image input.
4158
+ * @param vscodeVersion - Editor-Version value from {@link latestVsCodeVersion}.
4159
+ * @returns headers to merge into Copilot API requests.
4160
+ */
4161
+ function copilotHeaders(hasVision = false, vscodeVersion = FALLBACK_VSCODE_VERSION) {
4162
+ return {
4163
+ "user-agent": "GitHubCopilotChat/0.35.0",
4164
+ "editor-version": `vscode/${vscodeVersion}`,
4165
+ "editor-plugin-version": "copilot-chat/0.35.0",
4166
+ "copilot-integration-id": "vscode-chat",
4167
+ "openai-intent": "conversation-edits",
4168
+ "x-github-api-version": "2026-06-01",
4169
+ ...hasVision ? { "copilot-vision-request": "true" } : {}
4170
+ };
4171
+ }
4172
+ /**
4173
+ * Exchange a long-lived GitHub OAuth token for a short-lived Copilot API
4174
+ * token. A 401/403 means the GitHub token is revoked or the account lost its
4175
+ * Copilot subscription — permanent, re-login required.
4176
+ * @param githubToken - the GitHub OAuth token from the device flow.
4177
+ * @param fetchFn - fetch implementation (injectable for tests).
4178
+ * @returns the Copilot API token and its expiry.
4179
+ */
4180
+ async function exchangeCopilotToken(githubToken, fetchFn = fetch) {
4181
+ const response = await fetchFn(COPILOT_TOKEN_URL, { headers: {
4182
+ "authorization": `Bearer ${githubToken}`,
4183
+ "accept": "application/json",
4184
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
4185
+ } });
4186
+ if (!response.ok) throw await oauthEndpointError(response, "copilot");
4187
+ const wire = await response.json();
4188
+ if (typeof wire.token !== "string" || wire.token.length === 0) throw new Error("copilot token endpoint returned no token");
4189
+ return {
4190
+ accessToken: wire.token,
4191
+ expiresAt: typeof wire.expires_at === "number" && wire.expires_at > 0 ? wire.expires_at * 1e3 : Date.now() + 25 * 6e4
4192
+ };
4193
+ }
4194
+ /**
4195
+ * Complete a device-flow login: exchange the GitHub token for a Copilot API
4196
+ * token and read the GitHub login name for the status display.
4197
+ * @param githubToken - the GitHub OAuth token the device flow released.
4198
+ * @param fetchFn - fetch implementation (injectable for tests).
4199
+ * @returns the session to store.
4200
+ */
4201
+ async function completeCopilotLogin(githubToken, fetchFn = fetch) {
4202
+ const pair = await exchangeCopilotToken(githubToken, fetchFn);
4203
+ let account;
4204
+ try {
4205
+ const response = await fetchFn(GITHUB_USER_URL, { headers: {
4206
+ "authorization": `Bearer ${githubToken}`,
4207
+ "accept": "application/json",
4208
+ "user-agent": "GitHubCopilotChat/0.35.0"
4209
+ } });
4210
+ if (response.ok) {
4211
+ const profile = await response.json();
4212
+ if (typeof profile.login === "string" && profile.login.length > 0) account = profile.login;
4213
+ }
4214
+ } catch {}
4215
+ return {
4216
+ accessToken: pair.accessToken,
4217
+ refreshToken: githubToken,
4218
+ expiresAt: pair.expiresAt,
4219
+ ...account === void 0 ? {} : { account }
4220
+ };
4221
+ }
4222
+ /**
4223
+ * Refresh a copilot session: re-exchange the long-lived GitHub token for a
4224
+ * fresh Copilot API token.
4225
+ * @param session - the stored session.
4226
+ * @param fetchFn - fetch implementation (injectable for tests).
4227
+ * @returns the fresh session to store.
4228
+ */
4229
+ async function refreshCopilot(session, fetchFn = fetch) {
4230
+ const pair = await exchangeCopilotToken(session.refreshToken, fetchFn);
4231
+ return {
4232
+ accessToken: pair.accessToken,
4233
+ refreshToken: session.refreshToken,
4234
+ expiresAt: pair.expiresAt,
4235
+ ...session.account === void 0 ? {} : { account: session.account }
4236
+ };
4237
+ }
4238
+ /**
4239
+ * Whether a copilot refresh failure means the login is permanently gone.
4240
+ * @param error - the thrown refresh error.
4241
+ * @returns true when re-login is the only fix (GitHub token revoked or the subscription lost).
4242
+ */
4243
+ function isCopilotPermanentRefreshError(error) {
4244
+ return error instanceof OAuthEndpointError && (error.status === 401 || error.status === 403);
4245
+ }
4246
+ /** Display name for one Copilot wire reasoning-effort value. */
4247
+ function copilotEffortName(effort) {
4248
+ return effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1);
4249
+ }
4250
+ /**
4251
+ * Map a catalog entry's `supports.reasoning_effort` array into selectable
4252
+ * efforts. The endpoint discloses no default effort, so none is claimed
4253
+ * (absence preserves the provider's own default). Duplicates and non-string
4254
+ * entries are dropped: the harness rejects duplicate effort ids outright.
4255
+ */
4256
+ function copilotReasoning(entry) {
4257
+ const wire = entry.capabilities?.supports?.reasoning_effort;
4258
+ if (!Array.isArray(wire)) return void 0;
4259
+ const seen = /* @__PURE__ */ new Set();
4260
+ const efforts = [];
4261
+ for (const value of wire) {
4262
+ if (typeof value !== "string" || value.length === 0 || seen.has(value)) continue;
4263
+ seen.add(value);
4264
+ efforts.push({
4265
+ id: ReasoningEffortId(value),
4266
+ name: copilotEffortName(value)
4267
+ });
4268
+ }
4269
+ return efforts.length > 0 ? { efforts } : void 0;
4270
+ }
4271
+ /**
4272
+ * Fetch the live Copilot model list. Models hidden from the picker or
4273
+ * disabled by policy are excluded, as are models able to speak neither
4274
+ * protocol this adapter knows: an entry listing `/chat/completions` speaks
4275
+ * the chat wire, one listing only `/responses` (the newer GPT families,
4276
+ * e.g. gpt-5.6) speaks the Responses wire, and the choice is recorded on the
4277
+ * discovered entry so requests pick the matching endpoint; an entry listing
4278
+ * BOTH endpoints additionally records `/responses` availability, which
4279
+ * {@link copilotRequestWire} uses to reroute tools+effort requests. Vision
4280
+ * support from the catalog becomes the model's input modalities, and a
4281
+ * non-empty `supports.reasoning_effort` array becomes the model's selectable
4282
+ * reasoning efforts (the endpoint discloses no default, so none is claimed).
4283
+ * @param session - the stored session (used as-is; never refreshed here).
4284
+ * @param fetchFn - fetch implementation (injectable for tests).
4285
+ * @returns discovered chat models in endpoint order.
4286
+ */
4287
+ async function fetchCopilotModels(session, fetchFn = fetch) {
4288
+ const response = await fetchFn(COPILOT_MODELS_URL, { headers: {
4289
+ "authorization": `Bearer ${session.accessToken}`,
4290
+ "accept": "application/json",
4291
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn))
4292
+ } });
4293
+ if (!response.ok) throw await oauthEndpointError(response, "copilot models");
4294
+ const payload = await response.json();
4295
+ if (!Array.isArray(payload.data)) throw new Error("copilot models endpoint returned no data array");
4296
+ const seen = /* @__PURE__ */ new Set();
4297
+ const discovered = [];
4298
+ for (const entry of payload.data) {
4299
+ if (typeof entry.id !== "string" || entry.id.length === 0 || seen.has(entry.id)) continue;
4300
+ if (entry.model_picker_enabled !== true || entry.policy?.state === "disabled") continue;
4301
+ let wire;
4302
+ let responsesSupported = false;
4303
+ if (Array.isArray(entry.supported_endpoints)) {
4304
+ responsesSupported = entry.supported_endpoints.includes("/responses");
4305
+ if (entry.supported_endpoints.includes("/chat/completions")) wire = "chat-completions";
4306
+ else if (responsesSupported) wire = "responses";
4307
+ else continue;
4308
+ }
4309
+ seen.add(entry.id);
4310
+ const reasoning = copilotReasoning(entry);
4311
+ discovered.push({
4312
+ id: entry.id,
4313
+ name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : entry.id,
4314
+ ...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 } : {},
4315
+ inputModalities: entry.capabilities?.supports?.vision === true ? ["text", "image"] : ["text"],
4316
+ ...reasoning === void 0 ? {} : { reasoning },
4317
+ ...wire === void 0 ? {} : { copilotWire: wire },
4318
+ ...responsesSupported ? { copilotResponses: true } : {}
4319
+ });
4320
+ }
4321
+ if (discovered.length === 0) throw new Error("copilot models endpoint returned an empty catalog");
4322
+ return discovered;
4323
+ }
4324
+ /**
4325
+ * The wire protocol for one model: the discovered catalog entry's recorded
4326
+ * choice, defaulting to chat completions for unknown models (static-catalog
4327
+ * and no-discovery configurations, and models listing both endpoints).
4328
+ * @param entry - the discovered catalog entry, when known.
4329
+ * @returns the protocol the request for this model must speak.
4330
+ */
4331
+ function copilotWireFor(entry) {
4332
+ return entry?.copilotWire === "responses" ? "responses" : "chat-completions";
4333
+ }
4334
+ /**
4335
+ * The upstream protocol for ONE REQUEST: the model's default wire, except
4336
+ * that a dual-protocol model defaulting to chat completions must reroute to
4337
+ * Responses when the request combines function tools with a reasoning effort
4338
+ * — Copilot rejects exactly that combination on /chat/completions with
4339
+ * HTTP 400 invalid_request_body ("Function tools with reasoning_effort are
4340
+ * not supported … use /v1/responses or set reasoning_effort to 'none'",
4341
+ * observed on gpt-5.4) while /responses serves it. Effort 'none' stays on
4342
+ * the chat wire (the API allows the combination there), and models not
4343
+ * listing /responses never reroute.
4344
+ * @param entry - the discovered catalog entry, when known.
4345
+ * @param options - the harness generate options (tools + effort only).
4346
+ * @returns the protocol the request for this model must speak.
4347
+ */
4348
+ function copilotRequestWire(entry, options) {
4349
+ const wire = copilotWireFor(entry);
4350
+ if (wire !== "chat-completions") return wire;
4351
+ if (entry?.copilotResponses !== true) return wire;
4352
+ if (options.tools === void 0 || options.tools.length === 0) return wire;
4353
+ if (options.reasoningEffort === void 0 || options.reasoningEffort === "none") return wire;
4354
+ return "responses";
4355
+ }
4356
+ /**
4357
+ * The chat completions request body for one generation. The output cap rides
4358
+ * `max_completion_tokens` — the newer OpenAI-family models on Copilot reject
4359
+ * the legacy `max_tokens` parameter outright (HTTP 400 "Unsupported
4360
+ * parameter"), and the rest of the catalog accepts the new spelling.
4361
+ * @param options - the harness generate options.
4362
+ * @param messages - translated wire messages (images pre-resolved).
4363
+ * @returns the JSON body.
4364
+ */
4365
+ function copilotChatRequestBody(options, messages) {
4366
+ return {
4367
+ model: options.model,
4368
+ messages,
4369
+ ...options.tools !== void 0 && options.tools.length > 0 ? {
4370
+ tools: toChatTools(options.tools),
4371
+ tool_choice: "auto"
4372
+ } : {},
4373
+ ...options.maxTokens !== void 0 ? { max_completion_tokens: options.maxTokens } : {},
4374
+ ...options.reasoningEffort !== void 0 ? { reasoning_effort: String(options.reasoningEffort) } : {},
4375
+ stream: true,
4376
+ stream_options: { include_usage: true }
4377
+ };
4378
+ }
4379
+ /**
4380
+ * The Responses request body for one generation (the wire the `/responses`-
4381
+ * only model families speak). Usage arrives on `response.completed`.
4382
+ * @param options - the harness generate options.
4383
+ * @param resolved - translated instructions + input (images pre-resolved).
4384
+ * @returns the JSON body.
4385
+ */
4386
+ function copilotResponsesRequestBody(options, resolved) {
4387
+ return {
4388
+ model: options.model,
4389
+ ...resolved.instructions !== void 0 ? { instructions: resolved.instructions } : {},
4390
+ input: resolved.input,
4391
+ ...options.tools !== void 0 && options.tools.length > 0 ? {
4392
+ tools: toResponsesTools(options.tools),
4393
+ tool_choice: "auto"
4394
+ } : {},
4395
+ ...options.maxTokens !== void 0 ? { max_output_tokens: options.maxTokens } : {},
4396
+ ...options.reasoningEffort !== void 0 ? { reasoning: { effort: String(options.reasoningEffort) } } : {},
4397
+ include: ["reasoning.encrypted_content"],
4398
+ stream: true
4399
+ };
4400
+ }
4401
+ /**
4402
+ * The replayable form of one completed reasoning item: the COMPLETE item as
4403
+ * the gateway delivered it on `response.output_item.done` — its ORIGINAL id
4404
+ * (captured before the stable-key rewrite), summary parts, status, and the
4405
+ * encrypted payload. A reasoning item's `id` and `summary` are not optional
4406
+ * in the Responses input schema, so an item missing its id or its blob is
4407
+ * not replayable and degrades to the no-replay path instead of risking an
4408
+ * invalid input item.
4409
+ */
4410
+ function completedReasoningItem(item) {
4411
+ if (typeof item.encrypted_content !== "string" || item.encrypted_content.length === 0) return void 0;
4412
+ if (typeof item.id !== "string" || item.id.length === 0) return void 0;
4413
+ return {
4414
+ type: "reasoning",
4415
+ id: item.id,
4416
+ ...Array.isArray(item.summary) ? { summary: item.summary } : {},
4417
+ ...typeof item.status === "string" && item.status.length > 0 ? { status: item.status } : {},
4418
+ encrypted_content: item.encrypted_content
4419
+ };
4420
+ }
4421
+ /**
4422
+ * Rewrite Copilot's Responses-gateway item ids into stable per-item keys.
4423
+ * Unlike chatgpt.com's Responses backend, the Copilot gateway mints a FRESH
4424
+ * opaque `item.id`/`item_id` on every event of one response (the `added`,
4425
+ * each delta, and the `done` all differ), which defeats id-keyed block
4426
+ * assembly in the shared translator: text fragments would each open their
4427
+ * own block, `done` would synthesize duplicates, and a function call whose
4428
+ * arguments arrive whole only on `done` (the deltas carry empty strings)
4429
+ * would close empty. The stable key derives from the event's `output_index`
4430
+ * — the item's position in the response's output array, which survives the
4431
+ * gateway's per-event id churn even when two items' events interleave on
4432
+ * the wire (parallel tool calls do exactly that). Events without an
4433
+ * `output_index` fall back to the key of the last `output_item.added`, which
4434
+ * is only correct while one item's events stay contiguous — the pre-
4435
+ * interleaving behavior, kept for gateways that omit the field; with no
4436
+ * `added` seen yet they key to `copilot-item-0` as before. Function-call
4437
+ * identity additionally rides the gateway-stable `call_id`.
4438
+ */
4439
+ var CopilotResponsesItemNormalizer = class {
4440
+ adds = 0;
4441
+ lastKey = "copilot-item-0";
4442
+ /** Call ids and completed reasoning items collected for the open response. */
4443
+ capturedCallIds = [];
4444
+ capturedReasoning = [];
4445
+ /**
4446
+ * @param onCaptured - fired at each `response.completed` that produced BOTH
4447
+ * function calls and completed reasoning items, receiving the response's
4448
+ * call ids and replayable reasoning items so the adapter can replay them
4449
+ * on the next request.
4450
+ */
4451
+ constructor(onCaptured) {
4452
+ this.onCaptured = onCaptured;
4453
+ }
4454
+ /**
4455
+ * [2026-08-23]-[a single arrival-order ordinal mis-buckets every event after
4456
+ * a second item's `added`, mangling interleaved parallel tool calls;
4457
+ * output_index is the only correlator the gateway keeps stable]-[changes
4458
+ * keys only for streams that carry output_index; no-index streams keep the
4459
+ * old last-added-key behavior byte for byte]
4460
+ */
4461
+ keyFor(event) {
4462
+ return event.output_index !== void 0 ? `copilot-item-${String(event.output_index)}` : this.lastKey;
4463
+ }
4464
+ /**
4465
+ * Rewrite one parsed Responses event.
4466
+ * @param event - the event as parsed off the wire.
4467
+ * @returns the event with a stable item key.
4468
+ */
4469
+ push(event) {
4470
+ if (event.type === "response.output_item.added") {
4471
+ this.adds += 1;
4472
+ const key = event.output_index !== void 0 ? `copilot-item-${String(event.output_index)}` : `copilot-item-${String(this.adds)}`;
4473
+ this.lastKey = key;
4474
+ const item = event.item;
4475
+ if (item?.type === "function_call" && typeof item.call_id === "string" && item.call_id.length > 0) this.capturedCallIds.push(item.call_id);
4476
+ return item === void 0 ? event : {
4477
+ ...event,
4478
+ item: {
4479
+ ...item,
4480
+ id: key
4481
+ }
4482
+ };
4483
+ }
4484
+ if (event.type === "response.output_item.done") {
4485
+ const item = event.item;
4486
+ if (item?.type === "reasoning") {
4487
+ const captured = completedReasoningItem(item);
4488
+ if (captured !== void 0) this.capturedReasoning.push(captured);
4489
+ }
4490
+ return item === void 0 ? event : {
4491
+ ...event,
4492
+ item: {
4493
+ ...item,
4494
+ id: this.keyFor(event)
4495
+ }
4496
+ };
4497
+ }
4498
+ if (event.type === "response.completed") {
4499
+ if (this.capturedCallIds.length > 0 && this.capturedReasoning.length > 0) this.onCaptured?.(this.capturedCallIds, this.capturedReasoning);
4500
+ this.capturedCallIds = [];
4501
+ this.capturedReasoning = [];
4502
+ return event;
4503
+ }
4504
+ if (event.item_id === void 0) return event;
4505
+ return {
4506
+ ...event,
4507
+ item_id: this.keyFor(event)
4508
+ };
4509
+ }
4510
+ };
4511
+ /** Copilot wire adapter: one instance serves the `copilot` provider route. */
4512
+ var CopilotAdapter = class CopilotAdapter extends LlmAdapter {
4513
+ catalog;
4514
+ /**
4515
+ * [2026-08-23]-[a reasoning model continuing a tool chain must get its
4516
+ * reasoning back or it restarts from scratch every tool round trip; the
4517
+ * items live in ADAPTER memory because dsh-llm's reasoning ContentBlock is
4518
+ * a closed shape that cannot carry them through the harness]-[entries are
4519
+ * namespaced per ACCOUNT × CONVERSATION × MODEL, idle out via a sliding
4520
+ * TTL, and the whole store is dropped on auth transitions, so replay
4521
+ * degrades to the old behavior instead of leaking across contexts]
4522
+ */
4523
+ replayByScope = /* @__PURE__ */ new Map();
4524
+ /** Call-id entries kept per scope; see {@link captureReasoning}. */
4525
+ static REPLAY_CALL_LIMIT = 64;
4526
+ /** Conversation scopes kept at once; bounds memory when many sessions interleave. */
4527
+ static REPLAY_SCOPE_LIMIT = 32;
4528
+ /** How long a captured entry stays replayable; tool round trips take minutes, not hours. */
4529
+ static REPLAY_TTL_MS = 30 * 6e4;
4530
+ constructor(options) {
4531
+ super();
4532
+ this.options = options;
4533
+ this.catalog = new ModelCatalogCache(options.catalogStore);
4534
+ }
4535
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
4536
+ async fetchCatalog() {
4537
+ return fetchCopilotModels(await this.options.tokens.session(), this.options.fetchFn);
4538
+ }
4539
+ providerInfo(provider) {
4540
+ return {
4541
+ id: provider,
4542
+ name: "GitHub Copilot"
4543
+ };
4544
+ }
4545
+ staticModels(provider) {
4546
+ return this.options.models.map((model) => ({
4547
+ provider,
4548
+ id: model.id,
4549
+ name: model.name ?? model.id,
4550
+ inputModalities: model.inputModalities ?? ["text"]
4551
+ }));
4552
+ }
4553
+ async listModels(provider) {
4554
+ if (await this.options.tokens.peek() === void 0) return [];
4555
+ if (!this.options.discovery) return this.staticModels(provider);
4556
+ try {
4557
+ return (await discoverOrRetryAuth((force) => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()))).map((model) => ({
4558
+ provider,
4559
+ id: model.id,
4560
+ name: model.name,
4561
+ ...model.description === void 0 ? {} : { description: model.description },
4562
+ ...model.inputModalities === void 0 ? {} : { inputModalities: model.inputModalities }
4563
+ }));
4564
+ } catch (error) {
4565
+ if (isMissingOrInvalidCredential(error)) return [];
4566
+ this.options.onWarn?.(`copilot model discovery failed; using the built-in catalog (${errorChain(error)})`);
4567
+ return this.staticModels(provider);
4568
+ }
4569
+ }
4570
+ /**
4571
+ * The discovered entry for one model. Resolved through the cache's
4572
+ * stale-while-revalidate path: capability metadata must stay stable across
4573
+ * a long conversation — a mid-turn refetch must neither block nor fail the
4574
+ * call before provider I/O.
4575
+ */
4576
+ async discovered(model) {
4577
+ if (!this.options.discovery) return void 0;
4578
+ return (await this.catalog.resolve(() => this.fetchCatalog()))?.find((entry) => entry.id === model);
4579
+ }
4580
+ /**
4581
+ * [2026-08-23]-[a manually configured responses-only model combined with
4582
+ * `discovery:false` left discovered() undefined, so copilotRequestWire
4583
+ * silently defaulted to /chat/completions and the request 404/400'd at the
4584
+ * gateway; an explicit config wire must win over catalog inference]-[config
4585
+ * `models[].wire` now routes the request even without discovery]
4586
+ */
4587
+ configuredWireEntry(model) {
4588
+ const configured = this.options.models.find((entry) => entry.id === model);
4589
+ return configured?.wire === void 0 ? void 0 : {
4590
+ id: configured.id,
4591
+ name: configured.name ?? configured.id,
4592
+ copilotWire: configured.wire
4593
+ };
4594
+ }
4595
+ /**
4596
+ * The replay scope isolating one ACCOUNT × CONVERSATION × MODEL. The
4597
+ * account identity is the session's long-lived GitHub token (stable across
4598
+ * Copilot-token refreshes, different per GitHub login); the conversation is
4599
+ * the loop-stamped `sessionId`, falling back to the first message's id
4600
+ * when a hand-built request carries no session stamp; the model separates
4601
+ * wire families. A call id captured in one scope is invisible to every
4602
+ * other scope, so reused ids cannot leak reasoning across accounts,
4603
+ * conversations, or models.
4604
+ */
4605
+ replayScope(tokenKey, options) {
4606
+ 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}`;
4607
+ }
4608
+ /**
4609
+ * Store one response's completed reasoning items behind every call id it
4610
+ * produced, inside one replay scope. Retention: a CONSUMED entry is kept —
4611
+ * every later round of the same conversation replays ALL its earlier
4612
+ * function_calls — until it idles out of the TTL (see {@link replayFor})
4613
+ * or the per-scope entry cap evicts it oldest-first. All calls of one
4614
+ * response share ONE entry object: toResponsesInput dedupes replays by
4615
+ * array reference, so parallel calls replay the items once instead of once
4616
+ * per call.
4617
+ */
4618
+ captureReasoning(scope, callIds, items) {
4619
+ let entries = this.replayByScope.get(scope);
4620
+ if (entries === void 0) {
4621
+ entries = /* @__PURE__ */ new Map();
4622
+ this.replayByScope.set(scope, entries);
4623
+ } else {
4624
+ this.replayByScope.delete(scope);
4625
+ this.replayByScope.set(scope, entries);
4626
+ }
4627
+ const now = Date.now();
4628
+ for (const [callId, entry$1] of entries) if (now - entry$1.at >= CopilotAdapter.REPLAY_TTL_MS) entries.delete(callId);
4629
+ const entry = {
4630
+ items: [...items],
4631
+ at: now
4632
+ };
4633
+ for (const callId of callIds) entries.set(callId, entry);
4634
+ while (entries.size > CopilotAdapter.REPLAY_CALL_LIMIT) {
4635
+ const oldest = entries.keys().next().value;
4636
+ if (oldest === void 0) break;
4637
+ entries.delete(oldest);
4638
+ }
4639
+ while (this.replayByScope.size > CopilotAdapter.REPLAY_SCOPE_LIMIT) {
4640
+ const oldest = this.replayByScope.keys().next().value;
4641
+ if (oldest === void 0) break;
4642
+ this.replayByScope.delete(oldest);
4643
+ }
4644
+ }
4645
+ /**
4646
+ * The replay items for one call id in one scope, when still fresh. The TTL
4647
+ * bounds IDLE time, not total age: a hit refreshes the entry (and its
4648
+ * eviction recency), so an ongoing conversation keeps its chain alive
4649
+ * while a conversation that stopped asking forgets within the TTL. An
4650
+ * absent or aged-out entry answers `undefined` — the no-replay
4651
+ * degradation, never an error.
4652
+ */
4653
+ replayFor(scope, callId) {
4654
+ const entries = this.replayByScope.get(scope);
4655
+ const entry = entries?.get(callId);
4656
+ if (entries === void 0 || entry === void 0) return void 0;
4657
+ const now = Date.now();
4658
+ if (now - entry.at >= CopilotAdapter.REPLAY_TTL_MS) return void 0;
4659
+ entry.at = now;
4660
+ entries.delete(callId);
4661
+ entries.set(callId, entry);
4662
+ this.replayByScope.delete(scope);
4663
+ this.replayByScope.set(scope, entries);
4664
+ return entry.items;
4665
+ }
4666
+ /**
4667
+ * Drop every captured replay entry. Lookup correctness never depends on
4668
+ * the call — the scope already carries the account identity — but the host
4669
+ * wiring invokes this on every copilot auth transition (login, logout,
4670
+ * credential death) so a switched account's memory never holds the
4671
+ * previous account's encrypted reasoning at all; conversation teardown is
4672
+ * bounded by the TTL and the caps.
4673
+ */
4674
+ clearReplayState() {
4675
+ this.replayByScope.clear();
4676
+ }
4677
+ async resolveModel(provider, model) {
4678
+ const discovered = await this.discovered(model);
4679
+ const configured = this.options.models.find((entry) => entry.id === model);
4680
+ return {
4681
+ provider,
4682
+ id: model,
4683
+ name: discovered?.name ?? configured?.name ?? model,
4684
+ ...discovered?.description === void 0 ? {} : { description: discovered.description },
4685
+ inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ["text"],
4686
+ context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? COPILOT_CONTEXT_WINDOW },
4687
+ defaultMaxTokens: configured?.maxTokens ?? COPILOT_DEFAULT_MAX_TOKENS,
4688
+ ...discovered?.reasoning === void 0 ? {} : { reasoning: discovered.reasoning }
4689
+ };
4690
+ }
4691
+ async *stream(options) {
4692
+ const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
4693
+ try {
4694
+ const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
4695
+ let session = await this.options.tokens.session();
4696
+ const scope = this.replayScope(session.refreshToken, options);
4697
+ let response = await this.request(options, session, watchdog.signal, wire, scope);
4698
+ if (response.status === 401) {
4699
+ await latestVsCodeVersion(this.options.fetchFn ?? fetch, true);
4700
+ session = await this.options.tokens.session(true);
4701
+ response = await this.request(options, session, watchdog.signal, wire, scope);
4702
+ }
4703
+ if (!response.ok) throw await httpLlmError(response, "copilot API");
4704
+ if (response.body === null) throw new LlmError("copilot API returned no response body", EMPTY_RESPONSE_CODE);
4705
+ const pulse = () => {
4706
+ watchdog.pulse();
4707
+ };
4708
+ if (wire === "responses") {
4709
+ const normalizer = new CopilotResponsesItemNormalizer((callIds, items) => {
4710
+ this.captureReasoning(scope, callIds, items);
4711
+ });
4712
+ yield* streamResponses(response.body, pulse, (event) => normalizer.push(event));
4713
+ } else yield* streamChatCompletions(response.body, pulse);
4714
+ } catch (error) {
4715
+ throw mapFetchFailure("copilot API", error, watchdog, options.signal);
4716
+ } finally {
4717
+ watchdog.stop();
4718
+ }
4719
+ }
4720
+ async request(options, session, signal, wire, replayScopeKey) {
4721
+ const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
4722
+ const hasVision = messages.some((message) => message.content.some((block) => block.type === "image"));
4723
+ const body = wire === "responses" ? copilotResponsesRequestBody(options, toResponsesInput(messages, options.system, (callId) => this.replayFor(replayScopeKey, callId))) : copilotChatRequestBody(options, toChatMessages(messages, options.system));
4724
+ return fetch(wire === "responses" ? COPILOT_RESPONSES_URL : COPILOT_API_URL, {
4725
+ method: "POST",
4726
+ headers: {
4727
+ "authorization": `Bearer ${session.accessToken}`,
4728
+ "accept": "text/event-stream",
4729
+ "content-type": "application/json",
4730
+ ...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? fetch))
4731
+ },
4732
+ body: JSON.stringify(body),
4733
+ signal
4734
+ });
4735
+ }
4736
+ };
4737
+
4738
+ //#endregion
4739
+ //#region src/tools/x-search.ts
4740
+ /** Endpoint the search request is posted to. */
4741
+ const X_SEARCH_URL = "https://api.x.ai/v1/responses";
4742
+ /** Grok model the search runs on (a catalog model of the grok provider). */
4743
+ const X_SEARCH_MODEL = "grok-4";
4744
+ /** xAI caps each handle filter list at ten entries. */
4745
+ const MAX_HANDLES = 10;
4746
+ /**
4747
+ * Validate and assemble the request facts from tool arguments. Throws plain
4748
+ * Errors for argument problems the schema DSL cannot express (non-empty
4749
+ * query, handle caps, mutually exclusive filters).
4750
+ */
4751
+ function buildXSearchRequest(args) {
4752
+ const query = args.query.trim();
4753
+ if (query.length === 0) throw new Error("x_search: query must be a non-empty string");
4754
+ const allowed = normalizeHandles(args.allowed_x_handles, "allowed_x_handles");
4755
+ const excluded = normalizeHandles(args.excluded_x_handles, "excluded_x_handles");
4756
+ if (allowed.length > 0 && excluded.length > 0) throw new Error("x_search: allowed_x_handles and excluded_x_handles cannot be used together");
4757
+ const tool = { type: "x_search" };
4758
+ if (allowed.length > 0) tool.allowed_x_handles = allowed;
4759
+ if (excluded.length > 0) tool.excluded_x_handles = excluded;
4760
+ if (args.from_date !== void 0 && args.from_date.trim().length > 0) tool.from_date = args.from_date.trim();
4761
+ if (args.to_date !== void 0 && args.to_date.trim().length > 0) tool.to_date = args.to_date.trim();
4762
+ if (args.enable_image_understanding === true) tool.enable_image_understanding = true;
4763
+ if (args.enable_video_understanding === true) tool.enable_video_understanding = true;
4764
+ return {
4765
+ query,
4766
+ tool
4767
+ };
4768
+ }
4769
+ /** Strip `@` prefixes, drop blanks, and enforce the provider's handle cap. */
4770
+ function normalizeHandles(value, field) {
4771
+ if (value === void 0) return [];
4772
+ const handles = value.map((handle) => handle.trim().replace(/^@+/, "")).filter((handle) => handle.length > 0);
4773
+ if (handles.length > MAX_HANDLES) throw new Error(`x_search: ${field} supports at most ${MAX_HANDLES} handles`);
4774
+ return handles;
4775
+ }
4776
+ function isRecord$1(value) {
4777
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4778
+ }
4779
+ /**
4780
+ * Extract the answer text and citation URLs from a Responses payload: the
4781
+ * `output_text` shortcut or message output parts for the answer, and both
4782
+ * top-level `citations` and inline `url_citation` annotations for sources.
4783
+ */
4784
+ function parseXSearchResponse(payload) {
4785
+ const body = isRecord$1(payload) ? payload : {};
4786
+ let answer = typeof body.output_text === "string" ? body.output_text.trim() : "";
4787
+ const citations = [];
4788
+ const push = (url) => {
4789
+ if (typeof url === "string" && url.length > 0 && !citations.includes(url)) citations.push(url);
4790
+ };
4791
+ if (Array.isArray(body.citations)) for (const citation of body.citations) push(citation);
4792
+ const parts = [];
4793
+ if (Array.isArray(body.output)) for (const item of body.output) {
4794
+ if (!isRecord$1(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
4795
+ for (const part of item.content) {
4796
+ if (!isRecord$1(part)) continue;
4797
+ if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string" && part.text.trim().length > 0) parts.push(part.text.trim());
4798
+ if (Array.isArray(part.annotations)) {
4799
+ for (const annotation of part.annotations) if (isRecord$1(annotation) && annotation.type === "url_citation") push(annotation.url);
4800
+ }
4801
+ }
4802
+ }
3331
4803
  if (answer.length === 0) answer = parts.join("\n\n");
3332
4804
  return {
3333
4805
  answer,
@@ -4020,26 +5492,30 @@ const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
4020
5492
  const providerIdSchema = z.union([
4021
5493
  "codex",
4022
5494
  "claude",
4023
- "grok"
5495
+ "grok",
5496
+ "copilot"
4024
5497
  ]);
4025
5498
  const modelEntrySchema = z.object({
4026
5499
  id: z.string().required(),
4027
5500
  name: z.string(),
4028
5501
  contextWindow: z.number().step(1).min(1),
4029
5502
  maxTokens: z.number().step(1).min(1),
4030
- inputModalities: z.array(z.union(["text", "image"]))
5503
+ inputModalities: z.array(z.union(["text", "image"])),
5504
+ wire: z.union(["chat-completions", "responses"])
4031
5505
  });
4032
5506
  const Config = z.object({
4033
5507
  providers: z.array(providerIdSchema).default([
4034
5508
  "codex",
4035
5509
  "claude",
4036
- "grok"
5510
+ "grok",
5511
+ "copilot"
4037
5512
  ]),
4038
5513
  streamIdleTimeoutMs: z.number().min(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
4039
5514
  models: z.object({
4040
5515
  codex: z.array(modelEntrySchema),
4041
5516
  claude: z.array(modelEntrySchema),
4042
- grok: z.array(modelEntrySchema)
5517
+ grok: z.array(modelEntrySchema),
5518
+ copilot: z.array(modelEntrySchema)
4043
5519
  })
4044
5520
  });
4045
5521
  /** Built-in catalogs used when the config does not override a provider's models. */
@@ -4096,6 +5572,28 @@ const DEFAULT_MODELS = {
4096
5572
  id: "grok-code-fast-1",
4097
5573
  name: "Grok Code Fast 1"
4098
5574
  }
5575
+ ],
5576
+ copilot: [
5577
+ {
5578
+ id: "gpt-4.1",
5579
+ name: "GPT-4.1",
5580
+ inputModalities: ["text", "image"]
5581
+ },
5582
+ {
5583
+ id: "gpt-4o",
5584
+ name: "GPT-4o",
5585
+ inputModalities: ["text", "image"]
5586
+ },
5587
+ {
5588
+ id: "claude-sonnet-4.5",
5589
+ name: "Claude Sonnet 4.5",
5590
+ inputModalities: ["text", "image"]
5591
+ },
5592
+ {
5593
+ id: "gemini-2.5-pro",
5594
+ name: "Gemini 2.5 Pro",
5595
+ inputModalities: ["text", "image"]
5596
+ }
4099
5597
  ]
4100
5598
  };
4101
5599
  /** Validate and detach the model catalog for every provider. */
@@ -4107,7 +5605,8 @@ function resolveCatalog(models) {
4107
5605
  return {
4108
5606
  codex: resolve("codex"),
4109
5607
  claude: resolve("claude"),
4110
- grok: resolve("grok")
5608
+ grok: resolve("grok"),
5609
+ copilot: resolve("copilot")
4111
5610
  };
4112
5611
  }
4113
5612
  /** The display account of a stored session, for the status endpoint. */
@@ -4120,21 +5619,49 @@ function accountOf(provider, session) {
4120
5619
  }
4121
5620
  case "claude": return session.emailAddress;
4122
5621
  case "grok": return session.account;
5622
+ case "copilot": return session.account;
4123
5623
  }
4124
5624
  }
4125
5625
  /**
4126
5626
  * Auth operations behind the `/subscriptions-auth` RPC channel: start/complete
4127
5627
  * OAuth attempts in the background, feed pasted codes, cancel, log out, and
4128
5628
  * answer usage lookups.
5629
+ *
5630
+ * @internal Exported for tests only; not part of the plugin's public surface.
4129
5631
  */
4130
5632
  var SubscriptionsAuthController = class {
4131
5633
  /** Last login failure per provider, surfaced as `detail` until the next success. */
4132
5634
  lastError = /* @__PURE__ */ new Map();
4133
- constructor(flows, onAuthChanged, resolveAttachments, usageFetchers = {}) {
5635
+ /**
5636
+ * Device-flow logins whose poll already settled but whose token exchange +
5637
+ * persist is still running. Between those two moments the attempt is gone
5638
+ * from the flow manager (busy=false) while no session exists yet
5639
+ * (loggedIn=false) — counting this window as busy keeps the Settings page
5640
+ * polling until the card can show the real outcome.
5641
+ */
5642
+ finalizing = /* @__PURE__ */ new Set();
5643
+ /** In-flight OAuth completions, one per provider at most. */
5644
+ completions = /* @__PURE__ */ new Map();
5645
+ /**
5646
+ * Per-provider claim counter. Everything that takes ownership of a
5647
+ * provider's session — starting a login, importing Claude Code credentials,
5648
+ * cancelling, logging out — bumps it, and a session write carrying an older
5649
+ * number has been superseded and is dropped.
5650
+ *
5651
+ * The counter is what makes a late OAuth completion safe: an attempt leaves
5652
+ * `OAuthFlowManager`'s pending map the moment its callback delivers the
5653
+ * code, while the token exchange that follows can still run for seconds. For
5654
+ * that whole window `pending(provider)?.cancel()` is a no-op, so ownership
5655
+ * cannot be read off the flow manager.
5656
+ */
5657
+ claims = /* @__PURE__ */ new Map();
5658
+ constructor(flows, deviceFlows, onAuthChanged, resolveAttachments, usageFetchers = {}, readClaudeCreds = readClaudeCodeCredentials) {
4134
5659
  this.flows = flows;
5660
+ this.deviceFlows = deviceFlows;
4135
5661
  this.onAuthChanged = onAuthChanged;
4136
5662
  this.resolveAttachments = resolveAttachments;
4137
5663
  this.usageFetchers = usageFetchers;
5664
+ this.readClaudeCreds = readClaudeCreds;
4138
5665
  }
4139
5666
  usage(provider, signal) {
4140
5667
  const fetcher = this.usageFetchers[provider];
@@ -4162,7 +5689,7 @@ var SubscriptionsAuthController = class {
4162
5689
  const detail = this.lastError.get(provider);
4163
5690
  return {
4164
5691
  loggedIn: session !== void 0,
4165
- busy: this.flows.isBusy(provider),
5692
+ busy: this.flows.isBusy(provider) || this.deviceFlows.isBusy(provider) || this.finalizing.has(provider),
4166
5693
  ...session === void 0 ? {} : { expiresAt: session.expiresAt },
4167
5694
  ...account === void 0 ? {} : { account },
4168
5695
  ...detail === void 0 ? {} : { detail }
@@ -4170,30 +5697,73 @@ var SubscriptionsAuthController = class {
4170
5697
  }
4171
5698
  async login(provider) {
4172
5699
  if (provider === "claude") {
4173
- const session = readClaudeCodeCredentials();
4174
- if (session) {
4175
- await this.persist("claude", session);
5700
+ const imported = this.readClaudeCreds();
5701
+ if (imported !== void 0) {
5702
+ this.claim("claude");
5703
+ this.flows.pending("claude")?.cancel();
5704
+ await this.persist("claude", imported);
4176
5705
  this.lastError.delete("claude");
4177
5706
  this.onAuthChanged("claude");
4178
5707
  return { authorizeUrl: "" };
4179
5708
  }
4180
- throw new Error("Claude Code credentials not found. Run \"claude\" first to log in.");
5709
+ const attempt$1 = await this.flows.start("claude", claudeFlow);
5710
+ this.completions.set("claude", this.complete("claude", attempt$1, this.claim("claude")));
5711
+ return { authorizeUrl: attempt$1.authorizeUrl };
5712
+ }
5713
+ if (provider === "copilot") {
5714
+ const attempt$1 = await this.deviceFlows.start(provider, copilotDeviceFlow());
5715
+ this.finalizing.add(provider);
5716
+ this.completeDevice(provider, attempt$1);
5717
+ return {
5718
+ authorizeUrl: attempt$1.verificationUrl,
5719
+ userCode: attempt$1.userCode
5720
+ };
4181
5721
  }
4182
5722
  const spec = provider === "grok" ? await grokFlow() : codexFlow;
4183
5723
  const attempt = await this.flows.start(provider, spec);
4184
- this.complete(provider, attempt);
5724
+ this.completions.set(provider, this.complete(provider, attempt, this.claim(provider)));
4185
5725
  return { authorizeUrl: attempt.authorizeUrl };
4186
5726
  }
4187
- /** Drive one attempt to a stored session; records failures for the status endpoint. */
4188
- async complete(provider, attempt) {
5727
+ /**
5728
+ * Take ownership of a provider's session, superseding every older claim.
5729
+ * @param provider - the provider route.
5730
+ * @returns the claim number a later write checks itself against.
5731
+ */
5732
+ claim(provider) {
5733
+ const next = (this.claims.get(provider) ?? 0) + 1;
5734
+ this.claims.set(provider, next);
5735
+ return next;
5736
+ }
5737
+ /**
5738
+ * Drive one attempt to a stored session; records failures for the status
5739
+ * endpoint. The exchange runs unsupervised — the attempt is gone from the
5740
+ * flow manager as soon as its code arrives — so the result is stored only
5741
+ * while `claim` still owns the provider's session.
5742
+ */
5743
+ async complete(provider, attempt, claim) {
4189
5744
  try {
4190
5745
  const code = await attempt.waitCode();
4191
5746
  const session = await this.exchange(provider, code, attempt);
5747
+ if (this.claims.get(provider) !== claim) return;
5748
+ await this.persist(provider, session);
5749
+ this.lastError.delete(provider);
5750
+ this.onAuthChanged(provider);
5751
+ } catch (error) {
5752
+ if (this.claims.get(provider) !== claim) return;
5753
+ if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
5754
+ }
5755
+ }
5756
+ /** Drive one device-flow attempt to a stored session (the copilot path of {@link complete}). */
5757
+ async completeDevice(provider, attempt) {
5758
+ try {
5759
+ const session = await completeCopilotLogin(await attempt.waitToken());
4192
5760
  await this.persist(provider, session);
4193
5761
  this.lastError.delete(provider);
4194
5762
  this.onAuthChanged(provider);
4195
5763
  } catch (error) {
4196
5764
  if (!(error instanceof Error && error.message === "login cancelled")) this.lastError.set(provider, errorChain(error));
5765
+ } finally {
5766
+ this.finalizing.delete(provider);
4197
5767
  }
4198
5768
  }
4199
5769
  exchange(provider, code, attempt) {
@@ -4201,6 +5771,7 @@ var SubscriptionsAuthController = class {
4201
5771
  case "codex": return exchangeCodexCode(code, attempt.pkce.verifier, attempt.redirectUri);
4202
5772
  case "claude": return exchangeClaudeCode(code, attempt.pkce.verifier, attempt.redirectUri, attempt.state);
4203
5773
  case "grok": return exchangeGrokCode(code, attempt.pkce.verifier, attempt.redirectUri, attempt.pkce.challenge);
5774
+ case "copilot": return Promise.reject(/* @__PURE__ */ new Error("copilot uses the device flow; no authorization code to exchange"));
4204
5775
  }
4205
5776
  }
4206
5777
  persist(provider, session) {
@@ -4208,8 +5779,19 @@ var SubscriptionsAuthController = class {
4208
5779
  case "codex": return saveSession("codex", session);
4209
5780
  case "claude": return saveSession("claude", session);
4210
5781
  case "grok": return saveSession("grok", session);
5782
+ case "copilot": return saveSession("copilot", session);
4211
5783
  }
4212
5784
  }
5785
+ /**
5786
+ * Settle once no OAuth completion is running for a provider.
5787
+ *
5788
+ * @internal Exported for tests only: a login's token exchange outlives the
5789
+ * `login()` call that started it, and a test asserting on what it stored
5790
+ * would otherwise have to guess at a timeout.
5791
+ */
5792
+ async settled(provider) {
5793
+ await this.completions.get(provider);
5794
+ }
4213
5795
  manual(provider, input) {
4214
5796
  const attempt = this.flows.pending(provider);
4215
5797
  if (attempt === void 0) return Promise.reject(/* @__PURE__ */ new Error(`no ${provider} login attempt is in progress`));
@@ -4217,11 +5799,15 @@ var SubscriptionsAuthController = class {
4217
5799
  return Promise.resolve();
4218
5800
  }
4219
5801
  cancel(provider) {
5802
+ this.claim(provider);
4220
5803
  this.flows.pending(provider)?.cancel();
5804
+ this.deviceFlows.pending(provider)?.cancel();
4221
5805
  return Promise.resolve();
4222
5806
  }
4223
5807
  async logout(provider) {
5808
+ this.claim(provider);
4224
5809
  this.flows.pending(provider)?.cancel();
5810
+ this.deviceFlows.pending(provider)?.cancel();
4225
5811
  await deleteSession(provider);
4226
5812
  this.lastError.delete(provider);
4227
5813
  this.onAuthChanged(provider);
@@ -4234,12 +5820,14 @@ function apply(ctx, config) {
4234
5820
  const catalog = resolveCatalog(config.models);
4235
5821
  const overridden = new Set(PROVIDER_IDS.filter((provider) => (config.models?.[provider]?.length ?? 0) > 0));
4236
5822
  const flows = new OAuthFlowManager();
5823
+ const deviceFlows = new DeviceFlowManager();
4237
5824
  const onWarn = (message) => {
4238
5825
  ctx.logger.warn(`dsh-plugin-subscriptions: ${message}`);
4239
5826
  };
4240
5827
  const resolveAttachments = () => ctx.get("attachments");
4241
5828
  const handles = /* @__PURE__ */ new Map();
4242
5829
  const authChanged = (provider) => {
5830
+ if (provider === "copilot") copilotAdapter?.clearReplayState();
4243
5831
  handles.get(provider)?.replace([provider]);
4244
5832
  };
4245
5833
  let codexTokens;
@@ -4248,6 +5836,7 @@ function apply(ctx, config) {
4248
5836
  const usageFetchers = {};
4249
5837
  const speedBySession = /* @__PURE__ */ new Map();
4250
5838
  let codexAdapter;
5839
+ let copilotAdapter;
4251
5840
  for (const provider of providers) switch (provider) {
4252
5841
  case "codex": {
4253
5842
  const tokens = new TokenManager({
@@ -4332,8 +5921,33 @@ function apply(ctx, config) {
4332
5921
  })));
4333
5922
  break;
4334
5923
  }
5924
+ case "copilot": {
5925
+ const tokens = new TokenManager({
5926
+ displayName: "GitHub Copilot",
5927
+ preemptMs: COPILOT_PREEMPT_MS,
5928
+ load: () => getSession("copilot"),
5929
+ save: (session) => saveSession("copilot", session),
5930
+ remove: () => deleteSession("copilot"),
5931
+ refresh: refreshCopilot,
5932
+ isPermanent: isCopilotPermanentRefreshError,
5933
+ onRemoved: () => {
5934
+ authChanged("copilot");
5935
+ }
5936
+ });
5937
+ copilotAdapter = new CopilotAdapter({
5938
+ models: catalog.copilot,
5939
+ streamIdleTimeoutMs,
5940
+ tokens,
5941
+ discovery: !overridden.has("copilot"),
5942
+ onWarn,
5943
+ resolveAttachments,
5944
+ catalogStore: catalogStore("copilot")
5945
+ });
5946
+ handles.set("copilot", ctx.llm.registerAdapter(["copilot"], copilotAdapter));
5947
+ break;
5948
+ }
4335
5949
  }
4336
- registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments, usageFetchers), {
5950
+ registerAuthRpc(ctx, new SubscriptionsAuthController(flows, deviceFlows, authChanged, resolveAttachments, usageFetchers), {
4337
5951
  async speed(sessionId) {
4338
5952
  return {
4339
5953
  tier: speedBySession.get(sessionId) ?? "standard",
@@ -4368,4 +5982,4 @@ function apply(ctx, config) {
4368
5982
  }
4369
5983
 
4370
5984
  //#endregion
4371
- export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, apply, inject, name };
5985
+ export { Config, DEFAULT_STREAM_IDLE_TIMEOUT_MS, SubscriptionsAuthController, apply, inject, name };