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/README.md +15 -6
- package/README.zh.md +14 -6
- package/lib/auth/device-flow.d.ts +64 -0
- package/lib/auth/device-flow.js +176 -0
- package/lib/auth/oauth-flow.js +1 -1
- package/lib/auth/rpc.d.ts +3 -1
- package/lib/auth/store.d.ts +20 -2
- package/lib/auth/store.js +45 -9
- package/lib/client/SubscriptionsSection.d.ts +1 -1
- package/lib/client/SubscriptionsSection.js +46 -4
- package/lib/client/locales.d.ts +8 -0
- package/lib/client/locales.js +8 -0
- package/lib/client.js +94 -4
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +3 -2
- package/lib/index.js +1770 -156
- package/lib/providers/catalog-store.js +15 -0
- package/lib/providers/claude.d.ts +20 -1
- package/lib/providers/claude.js +44 -27
- package/lib/providers/codex.js +52 -8
- package/lib/providers/common.d.ts +32 -1
- package/lib/providers/common.js +48 -1
- package/lib/providers/copilot.d.ts +315 -0
- package/lib/providers/copilot.js +786 -0
- package/lib/providers/grok.d.ts +7 -2
- package/lib/providers/grok.js +46 -18
- package/lib/translate/anthropic.d.ts +47 -6
- package/lib/translate/anthropic.js +135 -20
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +363 -0
- package/lib/translate/responses.d.ts +49 -5
- package/lib/translate/responses.js +40 -7
- package/package.json +9 -10
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(
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
*
|
|
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$
|
|
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
|
|
1231
|
-
*
|
|
1232
|
-
*
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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 (
|
|
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
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
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
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
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;
|
|
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
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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()))
|
|
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 (
|
|
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/
|
|
3268
|
-
/**
|
|
3269
|
-
|
|
3270
|
-
|
|
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
|
-
*
|
|
3309
|
-
*
|
|
3310
|
-
*
|
|
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
|
|
3313
|
-
const
|
|
3314
|
-
|
|
3315
|
-
const
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
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
|
-
|
|
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
|
|
4174
|
-
if (
|
|
4175
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
4188
|
-
|
|
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 };
|