dsh-plugin-subscription-usage 0.0.0-stage → 0.2.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.
Files changed (169) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +50 -2
  3. package/cordis.patch.yml +5 -0
  4. package/icon.svg +9 -0
  5. package/lib/auth/claude-code-creds.d.ts +23 -0
  6. package/lib/auth/claude-code-creds.js +172 -0
  7. package/lib/auth/device-flow.d.ts +55 -0
  8. package/lib/auth/device-flow.js +177 -0
  9. package/lib/auth/jwt.d.ts +10 -0
  10. package/lib/auth/jwt.js +25 -0
  11. package/lib/auth/oauth-flow.d.ts +91 -0
  12. package/lib/auth/oauth-flow.js +227 -0
  13. package/lib/auth/pkce.d.ts +31 -0
  14. package/lib/auth/pkce.js +35 -0
  15. package/lib/auth/rpc.d.ts +233 -0
  16. package/lib/auth/rpc.js +501 -0
  17. package/lib/auth/store.d.ts +198 -0
  18. package/lib/auth/store.js +473 -0
  19. package/lib/client/Footer.d.ts +3 -0
  20. package/lib/client/Footer.js +73 -0
  21. package/lib/client/Gauge.d.ts +28 -0
  22. package/lib/client/Gauge.js +139 -0
  23. package/lib/client/ImageGallery.d.ts +54 -0
  24. package/lib/client/ImageGallery.js +112 -0
  25. package/lib/client/ImageGenerateToolview.d.ts +48 -0
  26. package/lib/client/ImageGenerateToolview.js +130 -0
  27. package/lib/client/Notice.d.ts +15 -0
  28. package/lib/client/Notice.js +93 -0
  29. package/lib/client/ProviderCard.d.ts +35 -0
  30. package/lib/client/ProviderCard.js +240 -0
  31. package/lib/client/SpeedSelect.d.ts +72 -0
  32. package/lib/client/SpeedSelect.js +214 -0
  33. package/lib/client/Usage.d.ts +7 -0
  34. package/lib/client/Usage.js +83 -0
  35. package/lib/client/VideoGenerateToolview.d.ts +42 -0
  36. package/lib/client/VideoGenerateToolview.js +172 -0
  37. package/lib/client/context.d.ts +16 -0
  38. package/lib/client/context.js +11 -0
  39. package/lib/client/fast-command.d.ts +2 -0
  40. package/lib/client/fast-command.js +4 -0
  41. package/lib/client/hooks.d.ts +2 -0
  42. package/lib/client/hooks.js +10 -0
  43. package/lib/client/index.d.ts +3 -0
  44. package/lib/client/index.js +113 -0
  45. package/lib/client/locales.d.ts +139 -0
  46. package/lib/client/locales.js +267 -0
  47. package/lib/client/meter.d.ts +91 -0
  48. package/lib/client/meter.js +196 -0
  49. package/lib/client/prefs.d.ts +51 -0
  50. package/lib/client/prefs.js +71 -0
  51. package/lib/client/rpc.d.ts +161 -0
  52. package/lib/client/rpc.js +31 -0
  53. package/lib/client/store.d.ts +67 -0
  54. package/lib/client/store.js +129 -0
  55. package/lib/client/styles.d.ts +7 -0
  56. package/lib/client/styles.js +176 -0
  57. package/lib/client.js +3933 -0
  58. package/lib/client.js.map +1 -0
  59. package/lib/compat.d.ts +55 -0
  60. package/lib/compat.js +20 -0
  61. package/lib/http.d.ts +134 -0
  62. package/lib/http.js +431 -0
  63. package/lib/index.d.ts +121 -0
  64. package/lib/index.js +13399 -0
  65. package/lib/json-text.d.ts +2 -0
  66. package/lib/json-text.js +4 -0
  67. package/lib/model-defaults.d.ts +23 -0
  68. package/lib/model-defaults.js +238 -0
  69. package/lib/model-speeds.d.ts +10 -0
  70. package/lib/model-speeds.js +48 -0
  71. package/lib/models-directory.d.ts +17 -0
  72. package/lib/models-directory.js +25 -0
  73. package/lib/provider-settings.d.ts +55 -0
  74. package/lib/provider-settings.js +215 -0
  75. package/lib/providers/account-preferences.d.ts +46 -0
  76. package/lib/providers/account-preferences.js +188 -0
  77. package/lib/providers/accounts.d.ts +113 -0
  78. package/lib/providers/accounts.js +142 -0
  79. package/lib/providers/antigravity-oauth-client.d.ts +7 -0
  80. package/lib/providers/antigravity-oauth-client.js +7 -0
  81. package/lib/providers/antigravity.d.ts +119 -0
  82. package/lib/providers/antigravity.js +552 -0
  83. package/lib/providers/catalog-refresh.d.ts +4 -0
  84. package/lib/providers/catalog-refresh.js +30 -0
  85. package/lib/providers/catalog-store.d.ts +47 -0
  86. package/lib/providers/catalog-store.js +283 -0
  87. package/lib/providers/claude-cli-version.d.ts +15 -0
  88. package/lib/providers/claude-cli-version.js +24 -0
  89. package/lib/providers/claude.d.ts +212 -0
  90. package/lib/providers/claude.js +726 -0
  91. package/lib/providers/codex-client-version.d.ts +8 -0
  92. package/lib/providers/codex-client-version.js +17 -0
  93. package/lib/providers/codex-search.d.ts +29 -0
  94. package/lib/providers/codex-search.js +198 -0
  95. package/lib/providers/codex.d.ts +253 -0
  96. package/lib/providers/codex.js +930 -0
  97. package/lib/providers/common.d.ts +415 -0
  98. package/lib/providers/common.js +679 -0
  99. package/lib/providers/copilot.d.ts +365 -0
  100. package/lib/providers/copilot.js +956 -0
  101. package/lib/providers/grok.d.ts +214 -0
  102. package/lib/providers/grok.js +750 -0
  103. package/lib/providers/image-pool.d.ts +30 -0
  104. package/lib/providers/image-pool.js +89 -0
  105. package/lib/providers/npm-cli-version.d.ts +56 -0
  106. package/lib/providers/npm-cli-version.js +98 -0
  107. package/lib/providers/pool-family.d.ts +56 -0
  108. package/lib/providers/pool-family.js +45 -0
  109. package/lib/providers/pool-health.d.ts +91 -0
  110. package/lib/providers/pool-health.js +164 -0
  111. package/lib/providers/pool-usage.d.ts +106 -0
  112. package/lib/providers/pool-usage.js +302 -0
  113. package/lib/providers/pool.d.ts +115 -0
  114. package/lib/providers/pool.js +461 -0
  115. package/lib/providers/rate-limit.d.ts +192 -0
  116. package/lib/providers/rate-limit.js +338 -0
  117. package/lib/providers/replay.d.ts +4 -0
  118. package/lib/providers/replay.js +37 -0
  119. package/lib/providers/reset-redemption.d.ts +20 -0
  120. package/lib/providers/reset-redemption.js +70 -0
  121. package/lib/tools/image-generate.d.ts +128 -0
  122. package/lib/tools/image-generate.js +438 -0
  123. package/lib/tools/registration.d.ts +16 -0
  124. package/lib/tools/registration.js +25 -0
  125. package/lib/tools/usage.d.ts +62 -0
  126. package/lib/tools/usage.js +89 -0
  127. package/lib/tools/video-generate.d.ts +89 -0
  128. package/lib/tools/video-generate.js +257 -0
  129. package/lib/tools/x-search.d.ts +58 -0
  130. package/lib/tools/x-search.js +197 -0
  131. package/lib/translate/anthropic.d.ts +180 -0
  132. package/lib/translate/anthropic.js +534 -0
  133. package/lib/translate/antigravity-images.d.ts +11 -0
  134. package/lib/translate/antigravity-images.js +62 -0
  135. package/lib/translate/antigravity-schema.d.ts +4 -0
  136. package/lib/translate/antigravity-schema.js +145 -0
  137. package/lib/translate/antigravity-thinking.d.ts +5 -0
  138. package/lib/translate/antigravity-thinking.js +44 -0
  139. package/lib/translate/antigravity.d.ts +120 -0
  140. package/lib/translate/antigravity.js +412 -0
  141. package/lib/translate/chat-completions.d.ts +120 -0
  142. package/lib/translate/chat-completions.js +382 -0
  143. package/lib/translate/resolved.d.ts +98 -0
  144. package/lib/translate/resolved.js +186 -0
  145. package/lib/translate/responses.d.ts +192 -0
  146. package/lib/translate/responses.js +443 -0
  147. package/lib/translate/sse.d.ts +21 -0
  148. package/lib/translate/sse.js +62 -0
  149. package/lib/usage/headers.d.ts +21 -0
  150. package/lib/usage/headers.js +64 -0
  151. package/lib/usage/pace.d.ts +53 -0
  152. package/lib/usage/pace.js +48 -0
  153. package/lib/usage/service.d.ts +64 -0
  154. package/lib/usage/service.js +104 -0
  155. package/lib/usage-providers/common.d.ts +23 -0
  156. package/lib/usage-providers/common.js +28 -0
  157. package/lib/usage-providers/index.d.ts +42 -0
  158. package/lib/usage-providers/index.js +71 -0
  159. package/lib/usage-providers/keys.d.ts +11 -0
  160. package/lib/usage-providers/keys.js +52 -0
  161. package/lib/usage-providers/kimi.d.ts +4 -0
  162. package/lib/usage-providers/kimi.js +52 -0
  163. package/lib/usage-providers/ollama-cloud.d.ts +4 -0
  164. package/lib/usage-providers/ollama-cloud.js +42 -0
  165. package/lib/usage-providers/zai.d.ts +5 -0
  166. package/lib/usage-providers/zai.js +53 -0
  167. package/locale/en.json +6 -0
  168. package/locale/zh.json +6 -0
  169. package/package.json +115 -4
@@ -0,0 +1,956 @@
1
+ /**
2
+ * GitHub Copilot subscription provider: OAuth device-authorization flow with
3
+ * the VS Code Copilot Chat client id, a GitHub-token → Copilot-token exchange
4
+ * against `copilot_internal/v2/token`, and streaming against two upstream
5
+ * protocols chosen per model: the OpenAI-compatible chat completions endpoint
6
+ * for models whose catalog entry lists `/chat/completions`, and the Responses
7
+ * endpoint for the newer model families (gpt-5.5/5.6, …) that only list
8
+ * `/responses`. Both upstreams are stream-only.
9
+ *
10
+ * Two token generations are in play: the long-lived GitHub OAuth token (kept
11
+ * as the session's `refreshToken`) and the ~30-minute Copilot API token it
12
+ * exchanges into (the session's `accessToken`). A TokenManager "refresh" is a
13
+ * fresh exchange, so the standard preempt/401-retry machinery applies
14
+ * unchanged.
15
+ */
16
+ import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
17
+ import { resolveImages } from '../translate/resolved.js';
18
+ import { streamChatCompletions, toChatMessages, toChatTools, } from '../translate/chat-completions.js';
19
+ import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
20
+ import { httpLlmError, idleWatchdog, mapFetchFailure, mergeReasoning, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
21
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
22
+ import { proxiedFetch } from '../http.js';
23
+ import { DEFAULT_RATE_LIMIT_WAIT, DEFAULT_RETRY, subscriptionRetryPolicy, } from './rate-limit.js';
24
+ /**
25
+ * Client id of the VS Code Copilot Chat GitHub App (pi-mono and
26
+ * copilot2api-go use the same value): the app is pre-authorized for the
27
+ * Copilot internal token exchange, a self-registered OAuth App is not.
28
+ */
29
+ export const COPILOT_CLIENT_ID = 'Iv1.b507a08c87ecfe98';
30
+ export const COPILOT_DEVICE_CODE_URL = 'https://github.com/login/device/code';
31
+ export const COPILOT_DEVICE_TOKEN_URL = 'https://github.com/login/oauth/access_token';
32
+ export const COPILOT_TOKEN_URL = 'https://api.github.com/copilot_internal/v2/token';
33
+ export const GITHUB_USER_URL = 'https://api.github.com/user';
34
+ export const COPILOT_API_URL = 'https://api.githubcopilot.com/chat/completions';
35
+ /** Responses endpoint for models whose catalog entry only lists `/responses`. */
36
+ export const COPILOT_RESPONSES_URL = 'https://api.githubcopilot.com/responses';
37
+ export const COPILOT_MODELS_URL = 'https://api.githubcopilot.com/models';
38
+ const COPILOT_SCOPE = 'read:user';
39
+ const COPILOT_CONTEXT_WINDOW = 128_000;
40
+ const COPILOT_DEFAULT_MAX_TOKENS = 16_000;
41
+ /** Refresh when the Copilot API token has less than this much life left. */
42
+ export const COPILOT_PREEMPT_MS = 5 * 60_000;
43
+ /**
44
+ * The VS Code update feed answers a JSON array of version strings, latest
45
+ * stable first. The Copilot API rejects requests whose Editor-Version is too
46
+ * old with `401 IDE token expired`, so the version is resolved live (cached
47
+ * for a day) instead of hardcoded — a stale hardcode bricks every request.
48
+ */
49
+ export const VSCODE_RELEASES_URL = 'https://update.code.visualstudio.com/api/releases/stable';
50
+ /** Last-known-good VS Code version when the feed is unreachable. */
51
+ export const FALLBACK_VSCODE_VERSION = '1.107.0';
52
+ const VSCODE_VERSION_TTL_MS = 24 * 3_600_000;
53
+ let vscodeVersionCache;
54
+ let vscodeVersionInflight;
55
+ /**
56
+ * Resolve the VS Code version presented as Editor-Version: the latest stable
57
+ * from the update feed, cached for a day, falling back to a pinned version
58
+ * when the feed fails. Concurrent resolves coalesce behind one fetch.
59
+ * @param fetchFn - fetch implementation (injectable for tests).
60
+ * @param forceRefresh - bypass the cache (a 401 `IDE token expired` retry).
61
+ * @returns a `major.minor.patch` version string.
62
+ */
63
+ export async function latestVsCodeVersion(fetchFn = proxiedFetch, forceRefresh = false) {
64
+ if (!forceRefresh && vscodeVersionCache !== undefined
65
+ && Date.now() - vscodeVersionCache.at < VSCODE_VERSION_TTL_MS) {
66
+ return vscodeVersionCache.version;
67
+ }
68
+ vscodeVersionInflight ??= (async () => {
69
+ try {
70
+ const response = await fetchFn(VSCODE_RELEASES_URL, { headers: { accept: 'application/json' } });
71
+ if (!response.ok)
72
+ throw new Error(`HTTP ${String(response.status)}`);
73
+ const releases = await response.json();
74
+ const version = Array.isArray(releases)
75
+ ? releases.find(entry => typeof entry === 'string' && /^\d+\.\d+\.\d+$/.test(entry))
76
+ : undefined;
77
+ if (version === undefined)
78
+ throw new Error('no version string in the feed');
79
+ vscodeVersionCache = { version: version, at: Date.now() };
80
+ return version;
81
+ }
82
+ catch {
83
+ // A feed failure must never break provider traffic: serve the stale
84
+ // cache, else the pinned fallback.
85
+ return vscodeVersionCache?.version ?? FALLBACK_VSCODE_VERSION;
86
+ }
87
+ })().finally(() => { vscodeVersionInflight = undefined; });
88
+ return vscodeVersionInflight;
89
+ }
90
+ /**
91
+ * The device-flow facts for the auth controller's DeviceFlowManager.
92
+ * @returns the flow spec for one attempt.
93
+ */
94
+ export function copilotDeviceFlow() {
95
+ return {
96
+ clientId: COPILOT_CLIENT_ID,
97
+ scope: COPILOT_SCOPE,
98
+ deviceCodeUrl: COPILOT_DEVICE_CODE_URL,
99
+ tokenUrl: COPILOT_DEVICE_TOKEN_URL,
100
+ };
101
+ }
102
+ /**
103
+ * Header set presenting requests as the VS Code Copilot Chat extension; the
104
+ * Copilot API rejects traffic without an editor identity.
105
+ * @param hasVision - whether the request carries image input.
106
+ * @param vscodeVersion - Editor-Version value from {@link latestVsCodeVersion}.
107
+ * @returns headers to merge into Copilot API requests.
108
+ */
109
+ export function copilotHeaders(hasVision = false, vscodeVersion = FALLBACK_VSCODE_VERSION) {
110
+ return {
111
+ 'user-agent': 'GitHubCopilotChat/0.35.0',
112
+ 'editor-version': `vscode/${vscodeVersion}`,
113
+ 'editor-plugin-version': 'copilot-chat/0.35.0',
114
+ 'copilot-integration-id': 'vscode-chat',
115
+ 'openai-intent': 'conversation-edits',
116
+ 'x-github-api-version': '2026-06-01',
117
+ ...hasVision ? { 'copilot-vision-request': 'true' } : {},
118
+ };
119
+ }
120
+ /**
121
+ * Exchange a long-lived GitHub OAuth token for a short-lived Copilot API
122
+ * token. A 401/403 means the GitHub token is revoked or the account lost its
123
+ * Copilot subscription — permanent, re-login required.
124
+ * @param githubToken - the GitHub OAuth token from the device flow.
125
+ * @param fetchFn - fetch implementation (injectable for tests).
126
+ * @returns the Copilot API token and its expiry.
127
+ */
128
+ export async function exchangeCopilotToken(githubToken, fetchFn = proxiedFetch) {
129
+ const response = await fetchFn(COPILOT_TOKEN_URL, {
130
+ headers: {
131
+ 'authorization': `Bearer ${githubToken}`,
132
+ 'accept': 'application/json',
133
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn)),
134
+ },
135
+ });
136
+ if (!response.ok)
137
+ throw await oauthEndpointError(response, 'copilot');
138
+ const wire = await response.json();
139
+ if (typeof wire.token !== 'string' || wire.token.length === 0) {
140
+ throw new Error('copilot token endpoint returned no token');
141
+ }
142
+ return {
143
+ accessToken: wire.token,
144
+ // A missing expiry falls back to a conservative 25 minutes (the tokens
145
+ // typically live ~30).
146
+ expiresAt: typeof wire.expires_at === 'number' && wire.expires_at > 0
147
+ ? wire.expires_at * 1000
148
+ : Date.now() + 25 * 60_000,
149
+ };
150
+ }
151
+ /** GitHub's Copilot account endpoint; it reports the monthly premium-request quota. */
152
+ export const COPILOT_USER_URL = 'https://api.github.com/copilot_internal/user';
153
+ const isRecord = (value) => typeof value === 'object' && value !== null;
154
+ /**
155
+ * Monthly premium-request usage of one Copilot account. Chat and completions
156
+ * report `unlimited` on paid plans and are skipped; a plan without a premium
157
+ * quota returns no windows.
158
+ * @param githubToken - the GitHub OAuth token from the device flow (not the Copilot API token).
159
+ */
160
+ export async function fetchCopilotUsage(githubToken, fetchFn = proxiedFetch, signal) {
161
+ const response = await fetchFn(COPILOT_USER_URL, {
162
+ headers: {
163
+ 'authorization': `token ${githubToken}`,
164
+ 'accept': 'application/json',
165
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn)),
166
+ },
167
+ ...signal === undefined ? {} : { signal },
168
+ });
169
+ if (!response.ok)
170
+ throw await oauthEndpointError(response, 'copilot usage');
171
+ const body = await response.json();
172
+ if (!isRecord(body))
173
+ throw new Error('copilot usage endpoint returned no object');
174
+ const plan = typeof body.copilot_plan === 'string' ? body.copilot_plan : undefined;
175
+ const snapshots = isRecord(body.quota_snapshots) ? body.quota_snapshots : {};
176
+ const premium = snapshots.premium_interactions;
177
+ const resetText = typeof body.quota_reset_date_utc === 'string' ? body.quota_reset_date_utc : undefined;
178
+ const resetsAt = resetText === undefined ? undefined : Date.parse(resetText);
179
+ const windows = [];
180
+ if (isRecord(premium) && premium.unlimited !== true
181
+ && typeof premium.entitlement === 'number' && premium.entitlement > 0
182
+ && typeof premium.remaining === 'number') {
183
+ const limit = premium.entitlement;
184
+ const used = Math.max(0, limit - premium.remaining);
185
+ const known = resetsAt !== undefined && Number.isFinite(resetsAt);
186
+ windows.push({
187
+ kind: 'other',
188
+ usedPercent: Math.min(100, used / limit * 100),
189
+ used,
190
+ limit,
191
+ ...known ? { resetsAt, durationMs: monthBefore(resetsAt) } : {},
192
+ });
193
+ }
194
+ return { supported: true, windows, ...plan === undefined ? {} : { plan } };
195
+ }
196
+ /** Length of the calendar month that ends at `resetsAt` (the quota resets on the 1st, UTC). */
197
+ export function monthBefore(resetsAt) {
198
+ const end = new Date(resetsAt);
199
+ return resetsAt - Date.UTC(end.getUTCFullYear(), end.getUTCMonth() - 1, end.getUTCDate());
200
+ }
201
+ /**
202
+ * Complete a device-flow login: exchange the GitHub token for a Copilot API
203
+ * token and read the GitHub login name for the status display.
204
+ * @param githubToken - the GitHub OAuth token the device flow released.
205
+ * @param fetchFn - fetch implementation (injectable for tests).
206
+ * @returns the session to store.
207
+ */
208
+ export async function completeCopilotLogin(githubToken, fetchFn = proxiedFetch) {
209
+ const pair = await exchangeCopilotToken(githubToken, fetchFn);
210
+ let account;
211
+ try {
212
+ const response = await fetchFn(GITHUB_USER_URL, {
213
+ headers: {
214
+ 'authorization': `Bearer ${githubToken}`,
215
+ 'accept': 'application/json',
216
+ // api.github.com only demands a user agent; no editor disguise needed.
217
+ 'user-agent': 'GitHubCopilotChat/0.35.0',
218
+ },
219
+ });
220
+ if (response.ok) {
221
+ const profile = await response.json();
222
+ if (typeof profile.login === 'string' && profile.login.length > 0)
223
+ account = profile.login;
224
+ }
225
+ }
226
+ catch {
227
+ // A profile lookup failure must not fail the login; the session works without a display name.
228
+ }
229
+ return {
230
+ accessToken: pair.accessToken,
231
+ refreshToken: githubToken,
232
+ expiresAt: pair.expiresAt,
233
+ ...account === undefined ? {} : { account },
234
+ };
235
+ }
236
+ /**
237
+ * Refresh a copilot session: re-exchange the long-lived GitHub token for a
238
+ * fresh Copilot API token.
239
+ * @param session - the stored session.
240
+ * @param fetchFn - fetch implementation (injectable for tests).
241
+ * @returns the fresh session to store.
242
+ */
243
+ export async function refreshCopilot(session, fetchFn = proxiedFetch) {
244
+ const pair = await exchangeCopilotToken(session.refreshToken, fetchFn);
245
+ return {
246
+ accessToken: pair.accessToken,
247
+ refreshToken: session.refreshToken,
248
+ expiresAt: pair.expiresAt,
249
+ ...session.account === undefined ? {} : { account: session.account },
250
+ };
251
+ }
252
+ /**
253
+ * Whether a copilot refresh failure means the login is permanently gone.
254
+ * @param error - the thrown refresh error.
255
+ * @returns true when re-login is the only fix (GitHub token revoked or the subscription lost).
256
+ */
257
+ export function isCopilotPermanentRefreshError(error) {
258
+ return error instanceof OAuthEndpointError && (error.status === 401 || error.status === 403);
259
+ }
260
+ /** Display name for one Copilot wire reasoning-effort value. */
261
+ function copilotEffortName(effort) {
262
+ return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
263
+ }
264
+ /**
265
+ * Map a catalog entry's `supports.reasoning_effort` array into selectable
266
+ * efforts. The endpoint discloses no default effort, so none is claimed
267
+ * (absence preserves the provider's own default). Duplicates and non-string
268
+ * entries are dropped: the harness rejects duplicate effort ids outright.
269
+ */
270
+ function copilotReasoning(entry) {
271
+ const wire = entry.capabilities?.supports?.reasoning_effort;
272
+ if (!Array.isArray(wire))
273
+ return undefined;
274
+ const seen = new Set();
275
+ const efforts = [];
276
+ for (const value of wire) {
277
+ if (typeof value !== 'string' || value.length === 0 || seen.has(value))
278
+ continue;
279
+ seen.add(value);
280
+ efforts.push({ id: ReasoningEffortId(value), name: copilotEffortName(value) });
281
+ }
282
+ return efforts.length > 0 ? { efforts } : undefined;
283
+ }
284
+ /**
285
+ * Fetch the live Copilot model list. Models hidden from the picker or
286
+ * disabled by policy are excluded, as are models able to speak neither
287
+ * protocol this adapter knows: an entry listing `/chat/completions` speaks
288
+ * the chat wire, one listing only `/responses` (the newer GPT families,
289
+ * e.g. gpt-5.6) speaks the Responses wire, and the choice is recorded on the
290
+ * discovered entry so requests pick the matching endpoint; an entry listing
291
+ * BOTH endpoints additionally records `/responses` availability, which
292
+ * {@link copilotRequestWire} uses to reroute tools+effort requests. Vision
293
+ * support from the catalog becomes the model's input modalities, and a
294
+ * non-empty `supports.reasoning_effort` array becomes the model's selectable
295
+ * reasoning efforts (the endpoint discloses no default, so none is claimed).
296
+ * @param session - the stored session (used as-is; never refreshed here).
297
+ * @param fetchFn - fetch implementation (injectable for tests).
298
+ * @param signal - caller cancellation (pool-assembly timeout).
299
+ * @returns discovered chat models in endpoint order.
300
+ */
301
+ export async function fetchCopilotModels(session, fetchFn = proxiedFetch, signal) {
302
+ const response = await fetchFn(COPILOT_MODELS_URL, {
303
+ headers: {
304
+ 'authorization': `Bearer ${session.accessToken}`,
305
+ 'accept': 'application/json',
306
+ ...copilotHeaders(false, await latestVsCodeVersion(fetchFn)),
307
+ },
308
+ ...signal === undefined ? {} : { signal },
309
+ });
310
+ if (!response.ok)
311
+ throw await oauthEndpointError(response, 'copilot models');
312
+ const payload = await response.json();
313
+ if (!Array.isArray(payload.data))
314
+ throw new Error('copilot models endpoint returned no data array');
315
+ const seen = new Set();
316
+ const discovered = [];
317
+ for (const entry of payload.data) {
318
+ if (typeof entry.id !== 'string' || entry.id.length === 0 || seen.has(entry.id))
319
+ continue;
320
+ if (entry.model_picker_enabled !== true || entry.policy?.state === 'disabled')
321
+ continue;
322
+ let wire;
323
+ let responsesSupported = false;
324
+ if (Array.isArray(entry.supported_endpoints)) {
325
+ responsesSupported = entry.supported_endpoints.includes('/responses');
326
+ if (entry.supported_endpoints.includes('/chat/completions'))
327
+ wire = 'chat-completions';
328
+ else if (responsesSupported)
329
+ wire = 'responses';
330
+ else
331
+ continue;
332
+ }
333
+ seen.add(entry.id);
334
+ const reasoning = copilotReasoning(entry);
335
+ discovered.push({
336
+ id: entry.id,
337
+ name: typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : entry.id,
338
+ ...typeof entry.capabilities?.limits?.max_context_window_tokens === 'number'
339
+ && entry.capabilities.limits.max_context_window_tokens > 0
340
+ ? { contextWindow: entry.capabilities.limits.max_context_window_tokens }
341
+ : {},
342
+ inputModalities: entry.capabilities?.supports?.vision === true ? ['text', 'image'] : ['text'],
343
+ ...reasoning === undefined ? {} : { reasoning },
344
+ ...wire === undefined ? {} : { copilotWire: wire },
345
+ ...responsesSupported ? { copilotResponses: true } : {},
346
+ });
347
+ }
348
+ // An empty catalog from a 200 response is treated as a discovery failure so
349
+ // the adapter falls back to the static catalog instead of vanishing from
350
+ // the picker.
351
+ if (discovered.length === 0)
352
+ throw new Error('copilot models endpoint returned an empty catalog');
353
+ return discovered;
354
+ }
355
+ /**
356
+ * The wire protocol for one model: the discovered catalog entry's recorded
357
+ * choice, defaulting to chat completions for unknown models (static-catalog
358
+ * and no-discovery configurations, and models listing both endpoints).
359
+ * @param entry - the discovered catalog entry, when known.
360
+ * @returns the protocol the request for this model must speak.
361
+ */
362
+ export function copilotWireFor(entry) {
363
+ return entry?.copilotWire === 'responses' ? 'responses' : 'chat-completions';
364
+ }
365
+ /**
366
+ * The upstream protocol for ONE REQUEST: the model's default wire, except
367
+ * that a dual-protocol model defaulting to chat completions must reroute to
368
+ * Responses when the request combines function tools with a reasoning effort
369
+ * — Copilot rejects exactly that combination on /chat/completions with
370
+ * HTTP 400 invalid_request_body ("Function tools with reasoning_effort are
371
+ * not supported … use /v1/responses or set reasoning_effort to 'none'",
372
+ * observed on gpt-5.4) while /responses serves it. Effort 'none' stays on
373
+ * the chat wire (the API allows the combination there), and models not
374
+ * listing /responses never reroute.
375
+ * @param entry - the discovered catalog entry, when known.
376
+ * @param options - the harness generate options (tools + effort only).
377
+ * @returns the protocol the request for this model must speak.
378
+ */
379
+ export function copilotRequestWire(entry, options) {
380
+ const wire = copilotWireFor(entry);
381
+ if (wire !== 'chat-completions')
382
+ return wire;
383
+ if (entry?.copilotResponses !== true)
384
+ return wire;
385
+ if (options.tools === undefined || options.tools.length === 0)
386
+ return wire;
387
+ if (options.reasoningEffort === undefined || options.reasoningEffort === 'none')
388
+ return wire;
389
+ return 'responses';
390
+ }
391
+ /**
392
+ * The chat completions request body for one generation. The output cap rides
393
+ * `max_completion_tokens` — the newer OpenAI-family models on Copilot reject
394
+ * the legacy `max_tokens` parameter outright (HTTP 400 "Unsupported
395
+ * parameter"), and the rest of the catalog accepts the new spelling.
396
+ * @param options - the harness generate options.
397
+ * @param messages - translated wire messages (images pre-resolved).
398
+ * @returns the JSON body.
399
+ */
400
+ export function copilotChatRequestBody(options, messages) {
401
+ return {
402
+ model: options.model,
403
+ messages,
404
+ ...options.tools !== undefined && options.tools.length > 0
405
+ ? { tools: toChatTools(options.tools), tool_choice: 'auto' }
406
+ : {},
407
+ ...options.maxTokens !== undefined ? { max_completion_tokens: options.maxTokens } : {},
408
+ // The harness only passes an effort the resolved model advertised (the
409
+ // catalog's reasoning_effort array), and copilotRequestWire keeps the
410
+ // tools+effort combination off this wire for models that reject it.
411
+ ...options.reasoningEffort !== undefined
412
+ ? { reasoning_effort: String(options.reasoningEffort) }
413
+ : {},
414
+ // The upstream is stream-only; usage arrives on the terminal chunk.
415
+ stream: true,
416
+ stream_options: { include_usage: true },
417
+ };
418
+ }
419
+ /**
420
+ * The Responses request body for one generation (the wire the `/responses`-
421
+ * only model families speak). Usage arrives on `response.completed`.
422
+ * @param options - the harness generate options.
423
+ * @param resolved - translated instructions + input (images pre-resolved).
424
+ * @returns the JSON body.
425
+ */
426
+ export function copilotResponsesRequestBody(options, resolved) {
427
+ return {
428
+ model: options.model,
429
+ ...resolved.instructions !== undefined ? { instructions: resolved.instructions } : {},
430
+ input: resolved.input,
431
+ ...options.tools !== undefined && options.tools.length > 0
432
+ ? { tools: toResponsesTools(options.tools, { strict: false }), tool_choice: 'auto' }
433
+ : {},
434
+ ...options.maxTokens !== undefined ? { max_output_tokens: options.maxTokens } : {},
435
+ // The Responses wire spells the effort nested; only advertised efforts
436
+ // ever reach this branch (see copilotChatRequestBody).
437
+ ...options.reasoningEffort !== undefined
438
+ ? { reasoning: { effort: String(options.reasoningEffort) } }
439
+ : {},
440
+ // [2026-08-23]-[a reasoning model continuing a tool chain must replay its
441
+ // encrypted reasoning on the next request, and the blobs only arrive when
442
+ // asked for; for non-reasoning models the include is a no-op]
443
+ include: ['reasoning.encrypted_content'],
444
+ stream: true,
445
+ };
446
+ }
447
+ /**
448
+ * The replayable form of one completed reasoning item: the COMPLETE item as
449
+ * the gateway delivered it on `response.output_item.done` — its ORIGINAL id
450
+ * (captured before the stable-key rewrite), summary parts, status, and the
451
+ * encrypted payload. A reasoning item's `id` and `summary` are not optional
452
+ * in the Responses input schema, so an item missing its id or its blob is
453
+ * not replayable and degrades to the no-replay path instead of risking an
454
+ * invalid input item.
455
+ */
456
+ function completedReasoningItem(item) {
457
+ if (typeof item.encrypted_content !== 'string' || item.encrypted_content.length === 0)
458
+ return undefined;
459
+ if (typeof item.id !== 'string' || item.id.length === 0)
460
+ return undefined;
461
+ return {
462
+ type: 'reasoning',
463
+ id: item.id,
464
+ ...Array.isArray(item.summary) ? { summary: item.summary } : {},
465
+ ...typeof item.status === 'string' && item.status.length > 0 ? { status: item.status } : {},
466
+ encrypted_content: item.encrypted_content,
467
+ };
468
+ }
469
+ /**
470
+ * Rewrite Copilot's Responses-gateway item ids into stable per-item keys.
471
+ * Unlike chatgpt.com's Responses backend, the Copilot gateway mints a FRESH
472
+ * opaque `item.id`/`item_id` on every event of one response (the `added`,
473
+ * each delta, and the `done` all differ), which defeats id-keyed block
474
+ * assembly in the shared translator: text fragments would each open their
475
+ * own block, `done` would synthesize duplicates, and a function call whose
476
+ * arguments arrive whole only on `done` (the deltas carry empty strings)
477
+ * would close empty. The stable key derives from the event's `output_index`
478
+ * — the item's position in the response's output array, which survives the
479
+ * gateway's per-event id churn even when two items' events interleave on
480
+ * the wire (parallel tool calls do exactly that). Events without an
481
+ * `output_index` fall back to the key of the last `output_item.added`, which
482
+ * is only correct while one item's events stay contiguous — the pre-
483
+ * interleaving behavior, kept for gateways that omit the field; with no
484
+ * `added` seen yet they key to `copilot-item-0` as before. Function-call
485
+ * identity additionally rides the gateway-stable `call_id`.
486
+ */
487
+ export class CopilotResponsesItemNormalizer {
488
+ onCaptured;
489
+ adds = 0;
490
+ lastKey = 'copilot-item-0';
491
+ /** Call ids and completed reasoning items collected for the open response. */
492
+ capturedCallIds = [];
493
+ capturedReasoning = [];
494
+ /**
495
+ * @param onCaptured - fired at each `response.completed` that produced BOTH
496
+ * function calls and completed reasoning items, receiving the response's
497
+ * call ids and replayable reasoning items so the adapter can replay them
498
+ * on the next request.
499
+ */
500
+ constructor(onCaptured) {
501
+ this.onCaptured = onCaptured;
502
+ }
503
+ /**
504
+ * [2026-08-23]-[a single arrival-order ordinal mis-buckets every event after
505
+ * a second item's `added`, mangling interleaved parallel tool calls;
506
+ * output_index is the only correlator the gateway keeps stable]-[changes
507
+ * keys only for streams that carry output_index; no-index streams keep the
508
+ * old last-added-key behavior byte for byte]
509
+ */
510
+ keyFor(event) {
511
+ return event.output_index !== undefined
512
+ ? `copilot-item-${String(event.output_index)}`
513
+ : this.lastKey;
514
+ }
515
+ /**
516
+ * Rewrite one parsed Responses event.
517
+ * @param event - the event as parsed off the wire.
518
+ * @returns the event with a stable item key.
519
+ */
520
+ push(event) {
521
+ if (event.type === 'response.output_item.added') {
522
+ this.adds += 1;
523
+ const key = event.output_index !== undefined
524
+ ? `copilot-item-${String(event.output_index)}`
525
+ : `copilot-item-${String(this.adds)}`;
526
+ this.lastKey = key;
527
+ const item = event.item;
528
+ if (item?.type === 'function_call' && typeof item.call_id === 'string' && item.call_id.length > 0) {
529
+ this.capturedCallIds.push(item.call_id);
530
+ }
531
+ return item === undefined
532
+ ? event
533
+ : { ...event, item: { ...item, id: key } };
534
+ }
535
+ if (event.type === 'response.output_item.done') {
536
+ const item = event.item;
537
+ if (item?.type === 'reasoning') {
538
+ // Capture runs BEFORE the stable-key rewrite: the replay item must
539
+ // carry the item's original gateway id, not the translator key.
540
+ const captured = completedReasoningItem(item);
541
+ if (captured !== undefined)
542
+ this.capturedReasoning.push(captured);
543
+ }
544
+ return item === undefined
545
+ ? event
546
+ : { ...event, item: { ...item, id: this.keyFor(event) } };
547
+ }
548
+ if (event.type === 'response.completed') {
549
+ // Both sides present is the only replayable response; clear either way —
550
+ // one SSE stream may carry multiple responses.
551
+ if (this.capturedCallIds.length > 0 && this.capturedReasoning.length > 0) {
552
+ this.onCaptured?.(this.capturedCallIds, this.capturedReasoning);
553
+ }
554
+ this.capturedCallIds = [];
555
+ this.capturedReasoning = [];
556
+ return event;
557
+ }
558
+ if (event.item_id === undefined)
559
+ return event;
560
+ return { ...event, item_id: this.keyFor(event) };
561
+ }
562
+ }
563
+ /** Copilot wire adapter: one instance serves the `copilot` provider route. */
564
+ export class CopilotAdapter extends LlmAdapter {
565
+ options;
566
+ catalog;
567
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
568
+ accountCatalogs = new Map();
569
+ accountCatalogsInvalidated = false;
570
+ invalidatedAccounts = new Set();
571
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
572
+ catalogOwner;
573
+ /**
574
+ * [2026-08-23]-[a reasoning model continuing a tool chain must get its
575
+ * reasoning back or it restarts from scratch every tool round trip; the
576
+ * items live in ADAPTER memory because dsh-llm's reasoning ContentBlock is
577
+ * a closed shape that cannot carry them through the harness]-[entries are
578
+ * namespaced per ACCOUNT × CONVERSATION × MODEL, idle out via a sliding
579
+ * TTL, and the whole store is dropped on auth transitions, so replay
580
+ * degrades to the old behavior instead of leaking across contexts]
581
+ */
582
+ replayByScope = new Map();
583
+ /** Call-id entries kept per scope; see {@link captureReasoning}. */
584
+ static REPLAY_CALL_LIMIT = 64;
585
+ /** Conversation scopes kept at once; bounds memory when many sessions interleave. */
586
+ static REPLAY_SCOPE_LIMIT = 32;
587
+ /** How long a captured entry stays replayable; tool round trips take minutes, not hours. */
588
+ static REPLAY_TTL_MS = 30 * 60_000;
589
+ constructor(options) {
590
+ super();
591
+ this.options = options;
592
+ this.catalog = new ModelCatalogCache(options.catalogStore, undefined, options.onCatalogChanged);
593
+ }
594
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
595
+ async fetchCatalog(account, signal) {
596
+ return fetchCopilotModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
597
+ }
598
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
599
+ clearAccountCatalog(account) {
600
+ if (account === undefined) {
601
+ this.accountCatalogsInvalidated = true;
602
+ this.invalidatedAccounts.clear();
603
+ }
604
+ else
605
+ this.invalidatedAccounts.add(account);
606
+ if (account === undefined) {
607
+ for (const cache of this.accountCatalogs.values())
608
+ cache.invalidate();
609
+ this.accountCatalogs.clear();
610
+ }
611
+ else {
612
+ this.accountCatalogs.get(account)?.invalidate();
613
+ this.accountCatalogs.delete(account);
614
+ }
615
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
616
+ this.catalogOwner = undefined;
617
+ this.catalog.invalidate();
618
+ }
619
+ }
620
+ /** Default and secondary accounts each seed their own persisted catalog. */
621
+ async catalogFor(account) {
622
+ const defaultKey = await this.options.tokens.defaultAccount();
623
+ const key = account ?? defaultKey;
624
+ if (key === undefined || key === defaultKey) {
625
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
626
+ this.catalog.invalidate();
627
+ }
628
+ this.catalogOwner = defaultKey;
629
+ return this.catalog;
630
+ }
631
+ let cache = this.accountCatalogs.get(key);
632
+ if (cache === undefined) {
633
+ cache = new ModelCatalogCache(this.options.accountCatalogStore?.(key), undefined, this.options.onCatalogChanged);
634
+ const invalidated = this.invalidatedAccounts.delete(key);
635
+ if (this.accountCatalogsInvalidated || invalidated)
636
+ cache.invalidate();
637
+ this.accountCatalogs.set(key, cache);
638
+ }
639
+ return cache;
640
+ }
641
+ providerInfo(provider) {
642
+ return { id: provider, name: 'GitHub Copilot' };
643
+ }
644
+ providerRetryPolicy(provider) {
645
+ return subscriptionRetryPolicy(DEFAULT_RETRY, this.options.rateLimit ?? DEFAULT_RATE_LIMIT_WAIT, `copilot: provider "${provider}" retryPolicy`);
646
+ }
647
+ staticModels(provider) {
648
+ return this.options.models.map(model => ({
649
+ provider,
650
+ id: model.id,
651
+ name: model.name ?? model.id,
652
+ inputModalities: model.inputModalities ?? ['text'],
653
+ }));
654
+ }
655
+ async listModels(provider) {
656
+ const own = await this.listOwnModels(provider, undefined, undefined, true);
657
+ const pool = this.options.pool?.();
658
+ if (pool === undefined)
659
+ return own;
660
+ const extra = await pool.modelsForProvider(provider);
661
+ const seen = new Set(own.map(model => model.id));
662
+ // Account pools reuse the catalog row; only configured tiers are extra.
663
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
664
+ }
665
+ /** The provider's own catalog: union of every account, or one account when named. */
666
+ async listOwnModels(provider, account, signal, preferCached = false) {
667
+ if (account === undefined) {
668
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
669
+ if (accounts.length === 0)
670
+ return [];
671
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal, preferCached), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
672
+ }
673
+ if (!await this.options.tokens.hasSession(account)) {
674
+ return [];
675
+ }
676
+ if (!this.options.discovery)
677
+ return this.staticModels(provider);
678
+ const catalog = await this.catalogFor(account);
679
+ try {
680
+ // The fetcher runs only on a cache miss, and resolves the session
681
+ // through the refresh-aware path so an expired access token renews here
682
+ // instead of failing discovery into the static fallback.
683
+ const discovered = await catalog.get(() => discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => this.fetchCatalog(account, signal)), preferCached);
684
+ return discovered.map(model => ({
685
+ provider,
686
+ id: model.id,
687
+ name: model.name,
688
+ ...model.description === undefined ? {} : { description: model.description },
689
+ ...model.inputModalities === undefined ? {} : { inputModalities: model.inputModalities },
690
+ }));
691
+ }
692
+ catch (error) {
693
+ if (isDiscoveryAborted(error, signal))
694
+ throw error;
695
+ // A permanent refresh failure deletes the stored session: the provider
696
+ // is logged out, so hide it instead of showing a stale static catalog.
697
+ if (isMissingOrInvalidCredential(error))
698
+ return [];
699
+ this.options.onWarn?.(`copilot model discovery failed; using the built-in catalog (${errorChain(error)})`);
700
+ return this.staticModels(provider);
701
+ }
702
+ }
703
+ /**
704
+ * The discovered entry for one model. Resolved through the cache's
705
+ * stale-while-revalidate path: capability metadata must stay stable across
706
+ * a long conversation — a mid-turn refetch must neither block nor fail the
707
+ * call before provider I/O.
708
+ */
709
+ async discovered(model) {
710
+ if (!this.options.discovery)
711
+ return undefined;
712
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
713
+ return discoverAcrossAccounts(accounts, async (account) => {
714
+ const catalog = await this.catalogFor(account);
715
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
716
+ return models?.find(entry => entry.id === model);
717
+ });
718
+ }
719
+ /**
720
+ * [2026-08-23]-[a manually configured responses-only model combined with
721
+ * `discovery:false` left discovered() undefined, so copilotRequestWire
722
+ * silently defaulted to /chat/completions and the request 404/400'd at the
723
+ * gateway; an explicit config wire must win over catalog inference]-[config
724
+ * `models[].wire` now routes the request even without discovery]
725
+ */
726
+ configuredWireEntry(model) {
727
+ const configured = this.options.models.find(entry => entry.id === model);
728
+ return configured?.wire === undefined
729
+ ? undefined
730
+ : { id: configured.id, name: configured.name ?? configured.id, copilotWire: configured.wire };
731
+ }
732
+ /**
733
+ * The replay scope isolating one ACCOUNT × CONVERSATION × MODEL. The
734
+ * account identity is the session's long-lived GitHub token (stable across
735
+ * Copilot-token refreshes, different per GitHub login); the conversation is
736
+ * the loop-stamped `sessionId`, falling back to the first message's id
737
+ * when a hand-built request carries no session stamp. Identity-free
738
+ * requests have no replay scope; the model separates
739
+ * wire families. A call id captured in one scope is invisible to every
740
+ * other scope, so reused ids cannot leak reasoning across accounts,
741
+ * conversations, or models.
742
+ */
743
+ replayScope(tokenKey, options) {
744
+ const conversation = options.sessionId !== undefined
745
+ ? `session:${String(options.sessionId)}`
746
+ : options.messages[0]?.id !== undefined
747
+ ? `anchor:${String(options.messages[0].id)}`
748
+ : undefined;
749
+ if (conversation === undefined)
750
+ return undefined;
751
+ return `${tokenKey}\u0000${conversation}\u0000${options.model}`;
752
+ }
753
+ /**
754
+ * Store one response's completed reasoning items behind every call id it
755
+ * produced, inside one replay scope. Retention: a CONSUMED entry is kept —
756
+ * every later round of the same conversation replays ALL its earlier
757
+ * function_calls — until it idles out of the TTL (see {@link replayFor})
758
+ * or the per-scope entry cap evicts it oldest-first. All calls of one
759
+ * response share ONE entry object: toResponsesInput dedupes replays by
760
+ * array reference, so parallel calls replay the items once instead of once
761
+ * per call.
762
+ */
763
+ captureReasoning(scope, callIds, items) {
764
+ if (scope === undefined)
765
+ return;
766
+ let entries = this.replayByScope.get(scope);
767
+ if (entries === undefined) {
768
+ entries = new Map();
769
+ this.replayByScope.set(scope, entries);
770
+ }
771
+ else {
772
+ // Refresh the scope's recency so an active conversation is never the
773
+ // scope-cap eviction victim.
774
+ this.replayByScope.delete(scope);
775
+ this.replayByScope.set(scope, entries);
776
+ }
777
+ const now = Date.now();
778
+ for (const [callId, entry] of entries) {
779
+ if (now - entry.at >= CopilotAdapter.REPLAY_TTL_MS)
780
+ entries.delete(callId);
781
+ }
782
+ const entry = { items: [...items], at: now };
783
+ for (const callId of callIds)
784
+ entries.set(callId, entry);
785
+ // Blobs reach tens of kilobytes; cap the ENTRY count and evict the oldest
786
+ // by insertion order rather than tracking bytes — eviction merely degrades
787
+ // ancient calls to the pre-capture behavior.
788
+ while (entries.size > CopilotAdapter.REPLAY_CALL_LIMIT) {
789
+ const oldest = entries.keys().next().value;
790
+ if (oldest === undefined)
791
+ break;
792
+ entries.delete(oldest);
793
+ }
794
+ while (this.replayByScope.size > CopilotAdapter.REPLAY_SCOPE_LIMIT) {
795
+ const oldest = this.replayByScope.keys().next().value;
796
+ if (oldest === undefined)
797
+ break;
798
+ this.replayByScope.delete(oldest);
799
+ }
800
+ }
801
+ /**
802
+ * The replay items for one call id in one scope, when still fresh. The TTL
803
+ * bounds IDLE time, not total age: a hit refreshes the entry (and its
804
+ * eviction recency), so an ongoing conversation keeps its chain alive
805
+ * while a conversation that stopped asking forgets within the TTL. An
806
+ * absent or aged-out entry answers `undefined` — the no-replay
807
+ * degradation, never an error.
808
+ */
809
+ replayFor(scope, callId) {
810
+ if (scope === undefined)
811
+ return undefined;
812
+ const entries = this.replayByScope.get(scope);
813
+ const entry = entries?.get(callId);
814
+ if (entries === undefined || entry === undefined)
815
+ return undefined;
816
+ const now = Date.now();
817
+ if (now - entry.at >= CopilotAdapter.REPLAY_TTL_MS)
818
+ return undefined;
819
+ entry.at = now;
820
+ entries.delete(callId);
821
+ entries.set(callId, entry);
822
+ this.replayByScope.delete(scope);
823
+ this.replayByScope.set(scope, entries);
824
+ return entry.items;
825
+ }
826
+ /**
827
+ * Drop every captured replay entry. Lookup correctness never depends on
828
+ * the call — the scope already carries the account identity — but the host
829
+ * wiring invokes this on every copilot auth transition (login, logout,
830
+ * credential death) so a switched account's memory never holds the
831
+ * previous account's encrypted reasoning at all; conversation teardown is
832
+ * bounded by the TTL and the caps.
833
+ */
834
+ clearReplayState() {
835
+ this.replayByScope.clear();
836
+ }
837
+ async resolveModel(provider, model) {
838
+ const pool = this.options.pool?.();
839
+ if (pool !== undefined && await pool.owns(provider, model)) {
840
+ return pool.resolveModel(provider, model);
841
+ }
842
+ return this.resolveOwnModel(provider, model);
843
+ }
844
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
845
+ async resolveOwnModel(provider, model) {
846
+ const discovered = await this.discovered(model);
847
+ const configured = this.options.models.find(entry => entry.id === model);
848
+ // Efforts come from the discovered catalog's reasoning_effort array; a
849
+ // model that did not advertise one exposes none, so the harness rejects
850
+ // an explicit effort before provider I/O instead of the API 400ing
851
+ // (Copilot returns invalid_request_body for models that cannot reason).
852
+ // A configured default effort still merges in: the picker then
853
+ // preselects it even for models the catalog does not cover.
854
+ const reasoning = mergeReasoning(this.options.defaultEffortOf?.(model), discovered?.reasoning);
855
+ return {
856
+ provider,
857
+ id: model,
858
+ name: discovered?.name ?? configured?.name ?? model,
859
+ ...discovered?.description === undefined ? {} : { description: discovered.description },
860
+ inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ['text'],
861
+ context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? COPILOT_CONTEXT_WINDOW },
862
+ defaultMaxTokens: configured?.maxTokens ?? COPILOT_DEFAULT_MAX_TOKENS,
863
+ ...reasoning === undefined ? {} : { reasoning },
864
+ };
865
+ }
866
+ async *stream(options) {
867
+ const pool = this.options.pool?.();
868
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
869
+ yield* pool.stream(options);
870
+ return;
871
+ }
872
+ yield* this.streamCore(options);
873
+ }
874
+ /** Pool seam: stream through one specific account instead of the default. */
875
+ streamAccount(options, account) {
876
+ return this.streamCore(options, account);
877
+ }
878
+ async *streamCore(options, account) {
879
+ const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
880
+ try {
881
+ // The discovered catalog decides the protocol: `/responses`-only model
882
+ // families (gpt-5.5/5.6, …) reject /chat/completions outright, and
883
+ // dual-protocol models reroute there once the request combines function
884
+ // tools with a reasoning effort (gpt-5.4 400s on the chat wire then).
885
+ // A configured `wire` outranks the catalog (see configuredWireEntry).
886
+ const wire = copilotRequestWire(this.configuredWireEntry(options.model) ?? await this.discovered(options.model), options);
887
+ const served = account ?? await this.options.tokens.defaultAccount();
888
+ let session = await this.options.tokens.session(served);
889
+ // Replay scope: account identity × conversation × model (see
890
+ // replayScope); a Copilot-token refresh preserves the GitHub token, so
891
+ // the 401 retry below reuses it too.
892
+ const scope = this.replayScope(session.refreshToken, options);
893
+ let response = await this.request(options, session, watchdog.signal, wire, scope);
894
+ if (response.status === 401) {
895
+ // One forced refresh + retry on an unexpired-but-rejected token. The
896
+ // editor version is force-refreshed too: a 401 `IDE token expired`
897
+ // means GitHub raised its minimum VS Code version, and only a fresh
898
+ // Editor-Version header fixes that (a new token does not).
899
+ await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch, true);
900
+ session = await this.options.tokens.session(served, true);
901
+ response = await this.request(options, session, watchdog.signal, wire, scope);
902
+ }
903
+ if (served !== undefined) {
904
+ this.options.onResponse?.(response, await this.options.tokens.resolveAccount(served), options.sessionId);
905
+ }
906
+ if (!response.ok) {
907
+ throw await httpLlmError(response, 'copilot API', {
908
+ // Copilot has no provider-specific reset reader yet. The shared
909
+ // mapper still honors its generic retry-after header and warns with
910
+ // rate-limit-shaped headers/body when GitHub sends another signal.
911
+ ...this.options.onWarn === undefined ? {} : { onWarn: this.options.onWarn },
912
+ });
913
+ }
914
+ if (response.body === null) {
915
+ throw new LlmError('copilot API returned no response body', EMPTY_RESPONSE_CODE);
916
+ }
917
+ const pulse = () => { watchdog.pulse(); };
918
+ if (wire === 'responses') {
919
+ // The normalizer doubles as the capture point for completed reasoning.
920
+ const normalizer = new CopilotResponsesItemNormalizer((callIds, items) => {
921
+ this.captureReasoning(scope, callIds, items);
922
+ });
923
+ yield* streamResponses(response.body, pulse, event => normalizer.push(event));
924
+ }
925
+ else {
926
+ yield* streamChatCompletions(response.body, pulse);
927
+ }
928
+ }
929
+ catch (error) {
930
+ throw mapFetchFailure('copilot API', error, watchdog, options.signal);
931
+ }
932
+ finally {
933
+ watchdog.stop();
934
+ }
935
+ }
936
+ async request(options, session, signal, wire, replayScopeKey) {
937
+ const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
938
+ const hasVision = messages.some(message => message.content.some(block => block.type === 'image'));
939
+ const body = wire === 'responses'
940
+ ? copilotResponsesRequestBody(options, toResponsesInput(messages, options.system,
941
+ // Captured completed reasoning replays ahead of its tool call.
942
+ callId => this.replayFor(replayScopeKey, callId)))
943
+ : copilotChatRequestBody(options, toChatMessages(messages, options.system));
944
+ return proxiedFetch(wire === 'responses' ? COPILOT_RESPONSES_URL : COPILOT_API_URL, {
945
+ method: 'POST',
946
+ headers: {
947
+ 'authorization': `Bearer ${session.accessToken}`,
948
+ 'accept': 'text/event-stream',
949
+ 'content-type': 'application/json',
950
+ ...copilotHeaders(hasVision, await latestVsCodeVersion(this.options.fetchFn ?? proxiedFetch)),
951
+ },
952
+ body: JSON.stringify(body),
953
+ signal,
954
+ });
955
+ }
956
+ }