codex-copilot-dx 0.5.6 → 0.6.0

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/src/copilot.mjs CHANGED
@@ -1,26 +1,7 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
1
  import { randomUUID } from "node:crypto";
4
2
  import { status } from "./status.mjs";
5
3
  import { debugLog } from "./log.mjs";
6
- import {
7
- fetchGithubIdentity,
8
- githubReauthMessage,
9
- githubTokenPath,
10
- importDiscoveredGithubToken,
11
- readGithubTokenMetadata,
12
- writeGithubTokenMetadata,
13
- } from "./auth.mjs";
14
- import {
15
- githubIdentitiesEqual,
16
- githubIdentityLabel,
17
- githubTokenFingerprint,
18
- normalizeGithubIdentity,
19
- } from "./github-identity.mjs";
20
- import { prepareResponsesPayload, summarizeReqBody } from "./image-optimization.mjs";
21
- import { enforceResponsesPayloadByteBudget } from "./responses-byte-budget.mjs";
22
- import { withChatStreamUsage } from "./stream-contract.mjs";
23
- import { markUpstreamStarted } from "./stream-performance.mjs";
4
+ import { createCopilotClientRuntime } from "./copilot-client.mjs";
24
5
 
25
6
  export {
26
7
  optimizeImageDataUrl,
@@ -33,35 +14,6 @@ export {
33
14
  } from "./image-optimization.mjs";
34
15
 
35
16
  export const DEFAULT_API_BASE = "https://api.githubcopilot.com";
36
- let apiBase = DEFAULT_API_BASE;
37
-
38
- // Cache of model id -> supported_endpoints, populated from listModels(). Used to
39
- // route requests to /responses vs /chat/completions based on real model metadata.
40
- let modelEndpointCache = new Map();
41
-
42
- export function cacheModelEndpoints(models) {
43
- const data = Array.isArray(models) ? models : models?.data;
44
- if (!Array.isArray(data)) return false;
45
- const next = new Map();
46
- for (const model of data) {
47
- const id = String(model?.id || "").trim();
48
- if (id && Array.isArray(model?.supported_endpoints)) {
49
- next.set(id, [...model.supported_endpoints]);
50
- }
51
- }
52
- if (!next.size) return false;
53
- modelEndpointCache = next;
54
- return true;
55
- }
56
-
57
- // Returns the cached supported_endpoints for a model id, or null if unknown.
58
- export function getCachedModelEndpoints(modelId) {
59
- return modelEndpointCache.get(String(modelId || "").trim()) || null;
60
- }
61
-
62
- export function resetModelEndpointCacheForTests() {
63
- modelEndpointCache.clear();
64
- }
65
17
  const DEFAULT_UPSTREAM_RETRIES = 2;
66
18
  const MAX_UPSTREAM_RETRIES = 5;
67
19
  const DEFAULT_UPSTREAM_RETRY_DELAY_MS = 300;
@@ -85,14 +37,9 @@ export function parseApiBase(data) {
85
37
  : DEFAULT_API_BASE;
86
38
  }
87
39
 
88
- export function getApiBase() {
89
- return apiBase;
90
- }
91
-
92
40
  export function responsesEndpointPath() {
93
41
  return "/responses";
94
42
  }
95
- const GITHUB_API = "https://api.github.com";
96
43
  const RETRYABLE_UPSTREAM_ERROR_CODES = new Set([
97
44
  "UND_ERR_CONNECT_TIMEOUT",
98
45
  "UND_ERR_HEADERS_TIMEOUT",
@@ -292,359 +239,70 @@ export async function refreshVSCodeVersion() {
292
239
  return cachedVersion;
293
240
  }
294
241
 
295
- let copilotToken = null;
296
- let copilotTokenExpiry = 0;
297
- let copilotTokenRefresh = null;
298
- let copilotTokenSourceKey = null;
299
- let copilotTokenGithubFingerprint = "";
300
- let copilotTokenGithubIdentity = null;
301
- let copilotTokenRefreshRetryAt = 0;
302
- const modelListFlights = new Map();
242
+ // Stateful Copilot data lives inside per-profile clients. These module-level
243
+ // wrappers preserve every existing export as the default Codex compatibility
244
+ // layer while allowing the adapter to use isolated Codex and Claude clients.
245
+ export function createCopilotClient(options = {}) {
246
+ return createCopilotClientRuntime({
247
+ ...options,
248
+ defaultApiBase: DEFAULT_API_BASE,
249
+ parseApiBase,
250
+ fetchCopilotUpstream,
251
+ buildHeaders,
252
+ getVSCodeVersion,
253
+ computeInitiator,
254
+ computeVision,
255
+ responsesEndpointPath,
256
+ });
257
+ }
303
258
 
304
- export function copilotRuntimeStatus(now = Date.now()) {
305
- let upstreamHost = "unknown";
306
- try { upstreamHost = new URL(apiBase).hostname; } catch {}
307
- return {
308
- token_cached: Boolean(copilotToken),
309
- token_expires_in_ms: copilotToken ? Math.max(0, copilotTokenExpiry - now) : null,
310
- token_refresh_in_flight: Boolean(copilotTokenRefresh),
311
- token_refresh_backoff_ms: Math.max(0, copilotTokenRefreshRetryAt - now),
312
- account_bound: Boolean(copilotTokenGithubIdentity),
313
- upstream_host: upstreamHost,
314
- model_endpoint_cache_entries: modelEndpointCache.size,
315
- model_list_flights: modelListFlights.size,
316
- };
259
+ export const defaultCopilotClient = createCopilotClient({ profile: "codex" });
260
+
261
+ export function cacheModelEndpoints(models) {
262
+ return defaultCopilotClient.cacheModelEndpoints(models);
317
263
  }
318
264
 
319
- function getGithubToken({ home = os.homedir() } = {}) {
320
- const GITHUB_TOKEN_PATH = githubTokenPath(home);
321
- if (!fs.existsSync(GITHUB_TOKEN_PATH)) {
322
- throw new Error("GitHub token not found. Run ccdx again to log in.");
323
- }
324
- const token = fs.readFileSync(GITHUB_TOKEN_PATH, "utf-8").trim();
325
- if (!token) throw new Error(githubReauthMessage("GitHub token file is empty."));
326
- return token;
327
- }
328
-
329
- function requestCopilotToken(ghToken, { fetchImpl = fetch, signal, retryOptions } = {}) {
330
- return fetchCopilotUpstream(`${GITHUB_API}/copilot_internal/v2/token`, {
331
- headers: { Authorization: `token ${ghToken}`, Accept: "application/json" },
332
- signal,
333
- }, { fetchImpl, ...retryOptions });
334
- }
335
-
336
- function cacheCopilotTokenData(data, {
337
- sourceKey,
338
- githubToken,
339
- githubIdentity,
340
- allowAccountSwitch = false,
341
- } = {}) {
342
- if (!data.token) throw new Error("Copilot token response missing token field");
343
- const fingerprint = githubTokenFingerprint(githubToken);
344
- const identity = normalizeGithubIdentity(githubIdentity);
345
- const tokenChanged = Boolean(copilotTokenGithubFingerprint && fingerprint !== copilotTokenGithubFingerprint);
346
- if (!allowAccountSwitch && copilotTokenGithubIdentity && identity
347
- && !githubIdentitiesEqual(copilotTokenGithubIdentity, identity)) {
348
- throw new Error(`Refusing to switch GitHub Copilot account from ${githubIdentityLabel(copilotTokenGithubIdentity)} to ${githubIdentityLabel(identity)} while the adapter is running. Restart ccdx to switch accounts intentionally.`);
349
- }
350
- if (!allowAccountSwitch && copilotTokenGithubIdentity && tokenChanged && !identity) {
351
- const error = new Error("The saved GitHub token changed, but its account could not be verified. Keeping the running adapter bound to the existing GitHub account.");
352
- error.transient = true;
353
- throw error;
354
- }
355
- copilotToken = data.token;
356
- apiBase = parseApiBase(data);
357
- copilotTokenExpiry = typeof data.expires_at === "number"
358
- ? data.expires_at * 1000
359
- : Date.now() + 25 * 60 * 1000; // fallback if expires_at absent: refresh in ~25min
360
- copilotTokenSourceKey = sourceKey || copilotTokenSourceKey;
361
- copilotTokenGithubFingerprint = fingerprint;
362
- if (identity) copilotTokenGithubIdentity = identity;
363
- copilotTokenRefreshRetryAt = 0;
364
- console.log(status("ok", "Copilot token refreshed"));
365
- return copilotToken;
366
- }
367
-
368
- async function githubIdentityForToken(token, { home, fetchImpl, signal }) {
369
- const cached = readGithubTokenMetadata(home, token);
370
- if (cached) return cached;
371
- const identity = await fetchGithubIdentity(token, { fetchImpl, signal });
372
- return identity.ok ? identity : null;
373
- }
374
-
375
- function tokenRefreshError(message, { statusCode, transient = false } = {}) {
376
- const error = new Error(message);
377
- if (statusCode) error.statusCode = statusCode;
378
- if (transient) error.transient = true;
379
- return error;
380
- }
381
-
382
- async function refreshCopilotToken({
383
- signal,
384
- home = os.homedir(),
385
- env = process.env,
386
- fetchImpl = fetch,
387
- tokenRetryOptions,
388
- } = {}) {
389
- const ghToken = getGithubToken({ home });
390
- const sourceKey = githubTokenPath(home);
391
- let resp;
392
- try {
393
- resp = await requestCopilotToken(ghToken, {
394
- fetchImpl,
395
- signal,
396
- retryOptions: tokenRetryOptions,
397
- });
398
- } catch (e) {
399
- if (!isAbortError(e, signal)) e.transient = true;
400
- throw e;
401
- }
402
- if (!resp.ok) {
403
- if (resp.status === 401 || resp.status === 403) {
404
- const imported = await importDiscoveredGithubToken({
405
- home,
406
- env,
407
- fetchImpl,
408
- signal,
409
- excludeTokens: [ghToken],
410
- validateSavedToken: true,
411
- });
412
- if (imported?.validation?.copilotTokenData?.token) {
413
- const explicitSource = imported.source?.type === "env" || imported.source?.type === "token-file";
414
- return cacheCopilotTokenData(imported.validation.copilotTokenData, {
415
- sourceKey,
416
- githubToken: imported.token,
417
- githubIdentity: imported.validation,
418
- allowAccountSwitch: explicitSource,
419
- });
420
- }
421
- throw new Error(githubReauthMessage(`Failed to get Copilot token: ${resp.status}. The saved GitHub token may be expired, revoked, or missing Copilot access.`));
422
- }
423
- throw tokenRefreshError(`Failed to get Copilot token: ${resp.status}`, {
424
- statusCode: resp.status,
425
- transient: resp.status === 408 || resp.status === 425 || resp.status === 429 || resp.status >= 500,
426
- });
427
- }
428
- const data = await resp.json();
429
- const identity = await githubIdentityForToken(ghToken, { home, fetchImpl, signal });
430
- const token = cacheCopilotTokenData(data, {
431
- sourceKey,
432
- githubToken: ghToken,
433
- githubIdentity: identity,
434
- });
435
- if (identity) writeGithubTokenMetadata(identity, home, ghToken);
436
- return token;
265
+ export function getCachedModelEndpoints(modelId) {
266
+ return defaultCopilotClient.getCachedModelEndpoints(modelId);
437
267
  }
438
268
 
439
- function canUseCachedToken(sourceKey, now = Date.now(), allowCurrentProcessToken = false) {
440
- const sameSource = copilotTokenSourceKey === sourceKey || allowCurrentProcessToken;
441
- return Boolean(copilotToken && sameSource && now < copilotTokenExpiry);
269
+ export function resetModelEndpointCacheForTests() {
270
+ return defaultCopilotClient.resetModelEndpointCacheForTests();
442
271
  }
443
272
 
444
- export function getCopilotToken(options = {}) {
445
- const home = options.home || os.homedir();
446
- const sourceKey = githubTokenPath(home);
447
- const now = Date.now();
448
- const allowCurrentProcessToken = options.home === undefined;
449
- if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenExpiry - 60000) {
450
- return Promise.resolve(copilotToken);
451
- }
452
- if (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) && now < copilotTokenRefreshRetryAt) {
453
- return Promise.resolve(copilotToken);
454
- }
455
- if (options.signal?.aborted) return Promise.reject(abortError(options.signal));
273
+ export function getApiBase() {
274
+ return defaultCopilotClient.getApiBase();
275
+ }
456
276
 
457
- let flight = copilotTokenRefresh?.key === sourceKey ? copilotTokenRefresh : null;
458
- if (flight?.controller.signal.aborted) {
459
- if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
460
- flight = null;
461
- }
277
+ export function copilotRuntimeStatus(now = Date.now()) {
278
+ return defaultCopilotClient.runtimeStatus(now);
279
+ }
462
280
 
463
- if (!flight) {
464
- const controller = new AbortController();
465
- const tokenRetryOptions = options.tokenRetryOptions
466
- ?? (canUseCachedToken(sourceKey, now, allowCurrentProcessToken) ? { retries: 0 } : undefined);
467
- flight = { key: sourceKey, controller, waiters: 0, settled: false, promise: null };
468
- flight.promise = refreshCopilotToken({
469
- ...options,
470
- home,
471
- signal: controller.signal,
472
- tokenRetryOptions,
473
- })
474
- .catch((error) => {
475
- const fallbackNow = Date.now();
476
- if (error?.transient && canUseCachedToken(sourceKey, fallbackNow)) {
477
- copilotTokenRefreshRetryAt = Math.min(copilotTokenExpiry, fallbackNow + 5000);
478
- console.warn(status("warn", `Copilot token refresh failed temporarily; using the existing token until ${new Date(copilotTokenExpiry).toISOString()} (${error.message})`));
479
- return copilotToken;
480
- }
481
- throw error;
482
- })
483
- .finally(() => {
484
- flight.settled = true;
485
- if (copilotTokenRefresh === flight) copilotTokenRefresh = null;
486
- });
487
- copilotTokenRefresh = flight;
488
- }
489
- return waitForSingleflight(flight, options.signal);
281
+ export function getCopilotToken(options = {}) {
282
+ return defaultCopilotClient.getCopilotToken(options);
490
283
  }
491
284
 
492
285
  export function resetCopilotTokenForTests() {
493
- copilotTokenRefresh?.controller.abort();
494
- copilotToken = null;
495
- copilotTokenExpiry = 0;
496
- copilotTokenRefresh = null;
497
- copilotTokenSourceKey = null;
498
- copilotTokenGithubFingerprint = "";
499
- copilotTokenGithubIdentity = null;
500
- copilotTokenRefreshRetryAt = 0;
501
- apiBase = DEFAULT_API_BASE;
502
- resetModelListSingleflightForTests();
503
- }
504
-
505
- // chatReq is already converted by the adapter. The caller parses the raw Response.
506
- export async function chatCompletions(chatReq, {
507
- signal,
508
- fetchImpl,
509
- retryOptions,
510
- bodyText,
511
- } = {}) {
512
- const token = await getCopilotToken({ signal });
513
- const upstreamReq = chatReq.stream === true ? withChatStreamUsage(chatReq) : chatReq;
514
- const messages = upstreamReq.messages || [];
515
- const serializedBody = typeof bodyText === "string" && upstreamReq === chatReq
516
- ? bodyText
517
- : JSON.stringify(upstreamReq);
518
- const headers = buildHeaders({
519
- token,
520
- version: getVSCodeVersion(),
521
- initiator: computeInitiator(messages),
522
- vision: computeVision(messages),
523
- });
524
- headers["Content-Length"] = String(Buffer.byteLength(serializedBody));
525
- if (upstreamReq.stream === true) markUpstreamStarted();
526
- return fetchCopilotUpstream(`${getApiBase()}/chat/completions`, {
527
- method: "POST",
528
- headers,
529
- body: serializedBody,
530
- signal,
531
- }, { fetchImpl, ...retryOptions });
532
- }
533
-
534
- async function fetchModels({ signal, fetchImpl, retryOptions }) {
535
- const token = await getCopilotToken({ signal });
536
- const headers = buildHeaders({
537
- token, version: getVSCodeVersion(), initiator: "user", vision: false,
538
- });
539
- const resp = await fetchCopilotUpstream(`${getApiBase()}/models`, { headers, signal }, { fetchImpl, ...retryOptions });
540
- const body = await resp.text();
541
- if (resp.ok) {
542
- try { cacheModelEndpoints(JSON.parse(body)); } catch {}
543
- }
544
- return { status: resp.status, body };
286
+ defaultCopilotClient.resetTokenForTests();
287
+ defaultCopilotClient.resetModelListSingleflightForTests();
545
288
  }
546
289
 
547
- function waitForSingleflight(flight, signal) {
548
- if (signal?.aborted) return Promise.reject(abortError(signal));
549
- flight.waiters += 1;
550
-
551
- return new Promise((resolve, reject) => {
552
- let finished = false;
553
- const finish = (handler, value) => {
554
- if (finished) return;
555
- finished = true;
556
- signal?.removeEventListener("abort", onAbort);
557
- flight.waiters -= 1;
558
- if (!flight.settled && flight.waiters === 0) flight.controller.abort();
559
- handler(value);
560
- };
561
- const onAbort = () => finish(reject, abortError(signal));
562
- signal?.addEventListener("abort", onAbort, { once: true });
563
- if (signal?.aborted) onAbort();
564
- flight.promise.then(
565
- (value) => finish(resolve, value),
566
- (error) => finish(reject, error),
567
- );
568
- });
290
+ export function chatCompletions(chatReq, options = {}) {
291
+ return defaultCopilotClient.chatCompletions(chatReq, options);
569
292
  }
570
293
 
571
- export function listModels({ signal, fetchImpl = fetch, retryOptions } = {}) {
572
- if (signal?.aborted) return Promise.reject(abortError(signal));
573
- let flight = modelListFlights.get(fetchImpl);
574
- if (flight?.controller.signal.aborted) {
575
- modelListFlights.delete(fetchImpl);
576
- flight = null;
577
- }
578
-
579
- if (!flight) {
580
- const controller = new AbortController();
581
- flight = { controller, waiters: 0, settled: false, promise: null };
582
- flight.promise = fetchModels({ signal: controller.signal, fetchImpl, retryOptions })
583
- .finally(() => {
584
- flight.settled = true;
585
- if (modelListFlights.get(fetchImpl) === flight) modelListFlights.delete(fetchImpl);
586
- });
587
- modelListFlights.set(fetchImpl, flight);
588
- }
589
-
590
- return waitForSingleflight(flight, signal);
294
+ export function listModels(options = {}) {
295
+ return defaultCopilotClient.listModels(options);
591
296
  }
592
297
 
593
298
  export function resetModelListSingleflightForTests() {
594
- for (const flight of modelListFlights.values()) flight.controller.abort();
595
- modelListFlights.clear();
596
- }
597
-
598
- // Responses-only models go directly to Copilot's /responses endpoint.
599
- export async function responses(reqBody, {
600
- signal,
601
- fetchImpl,
602
- retryOptions,
603
- currentInputStart = 0,
604
- payloadPrepared = false,
605
- payloadOptions = {},
606
- } = {}) {
607
- const token = await getCopilotToken({ signal });
608
- const preparedPayload = await prepareResponsesPayload(reqBody, {
609
- ...payloadOptions,
610
- currentInputStart,
611
- profiles: payloadPrepared ? [] : payloadOptions.profiles,
612
- skipInitialOptimization: payloadPrepared || payloadOptions.skipInitialOptimization,
613
- signal,
614
- });
615
- const finalizedPayload = enforceResponsesPayloadByteBudget(reqBody, preparedPayload);
616
- const { bodyText, bodyBytes, stage, adapted } = finalizedPayload;
617
- const summary = finalizedPayload.stage === preparedPayload.stage
618
- ? preparedPayload.summary
619
- : summarizeReqBody(reqBody);
620
- console.log(status("info", `responses payload bytes=${bodyBytes} input_items=${summary.items} images=${summary.images}`));
621
- if (adapted) debugLog(`responses payload adapted stage=${stage} bytes=${bodyBytes}/${preparedPayload.targetBytes}`);
622
- const headers = buildHeaders({
623
- token,
624
- version: getVSCodeVersion(),
625
- initiator: "user",
626
- vision: false,
627
- });
628
- headers["Content-Type"] = "application/json; charset=utf-8";
629
- headers["Content-Length"] = String(bodyBytes);
630
- headers["Accept"] = reqBody.stream ? "text/event-stream" : "application/json";
299
+ return defaultCopilotClient.resetModelListSingleflightForTests();
300
+ }
631
301
 
632
- try {
633
- if (reqBody.stream === true) markUpstreamStarted();
634
- return await fetchCopilotUpstream(`${getApiBase()}${responsesEndpointPath()}`, {
635
- method: "POST",
636
- headers,
637
- body: bodyText,
638
- signal,
639
- }, { fetchImpl, ...retryOptions });
640
- } catch (e) {
641
- const cause = e?.cause;
642
- const causeText = cause ? ` (${[cause.code, cause.message].filter(Boolean).join(": ")})` : "";
643
- throw new Error(`Copilot responses fetch failed: ${e.message}${causeText}`);
644
- }
302
+ export function responses(reqBody, options = {}) {
303
+ return defaultCopilotClient.responses(reqBody, options);
645
304
  }
646
305
 
647
- export async function responsesCompact(reqBody, options = {}) {
648
- // GitHub Copilot accepts Codex compact payloads on the regular Responses endpoint.
649
- return responses(reqBody, options);
306
+ export function responsesCompact(reqBody, options = {}) {
307
+ return defaultCopilotClient.responsesCompact(reqBody, options);
650
308
  }