dsh-subscriptions 0.1.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/auth/claude-code-creds.d.ts +23 -0
  5. package/lib/auth/claude-code-creds.js +172 -0
  6. package/lib/auth/device-flow.d.ts +55 -0
  7. package/lib/auth/device-flow.js +177 -0
  8. package/lib/auth/jwt.d.ts +10 -0
  9. package/lib/auth/jwt.js +25 -0
  10. package/lib/auth/oauth-flow.d.ts +91 -0
  11. package/lib/auth/oauth-flow.js +227 -0
  12. package/lib/auth/pkce.d.ts +31 -0
  13. package/lib/auth/pkce.js +35 -0
  14. package/lib/auth/rpc.d.ts +209 -0
  15. package/lib/auth/rpc.js +386 -0
  16. package/lib/auth/store.d.ts +196 -0
  17. package/lib/auth/store.js +468 -0
  18. package/lib/client/CursorCard.d.ts +9 -0
  19. package/lib/client/CursorCard.js +113 -0
  20. package/lib/client/ExternalUsageCards.d.ts +9 -0
  21. package/lib/client/ExternalUsageCards.js +72 -0
  22. package/lib/client/ImageGallery.d.ts +54 -0
  23. package/lib/client/ImageGallery.js +112 -0
  24. package/lib/client/ImageGenerateToolview.d.ts +48 -0
  25. package/lib/client/ImageGenerateToolview.js +133 -0
  26. package/lib/client/ProviderAccountManager.d.ts +13 -0
  27. package/lib/client/ProviderAccountManager.js +147 -0
  28. package/lib/client/ProviderModelEditor.d.ts +39 -0
  29. package/lib/client/ProviderModelEditor.js +107 -0
  30. package/lib/client/SpeedSelect.d.ts +71 -0
  31. package/lib/client/SpeedSelect.js +177 -0
  32. package/lib/client/SubscriptionUsageBadge.d.ts +116 -0
  33. package/lib/client/SubscriptionUsageBadge.js +543 -0
  34. package/lib/client/SubscriptionsSection.d.ts +140 -0
  35. package/lib/client/SubscriptionsSection.js +544 -0
  36. package/lib/client/UsageBadgeDisplaySetting.d.ts +7 -0
  37. package/lib/client/UsageBadgeDisplaySetting.js +36 -0
  38. package/lib/client/VideoGenerateToolview.d.ts +42 -0
  39. package/lib/client/VideoGenerateToolview.js +175 -0
  40. package/lib/client/account-preferences.d.ts +24 -0
  41. package/lib/client/account-preferences.js +21 -0
  42. package/lib/client/fast-command.d.ts +2 -0
  43. package/lib/client/fast-command.js +4 -0
  44. package/lib/client/host-contract.d.ts +90 -0
  45. package/lib/client/host-contract.js +101 -0
  46. package/lib/client/host-icons.d.ts +24 -0
  47. package/lib/client/host-icons.js +21 -0
  48. package/lib/client/index.d.ts +27 -0
  49. package/lib/client/index.js +136 -0
  50. package/lib/client/locales.d.ts +297 -0
  51. package/lib/client/locales.js +295 -0
  52. package/lib/client/subscription-card-styles.d.ts +3 -0
  53. package/lib/client/subscription-card-styles.js +53 -0
  54. package/lib/client/subscriptions-rpc.d.ts +14 -0
  55. package/lib/client/subscriptions-rpc.js +34 -0
  56. package/lib/client/usage-badge-preferences.d.ts +11 -0
  57. package/lib/client/usage-badge-preferences.js +41 -0
  58. package/lib/client.js +4472 -0
  59. package/lib/client.js.map +1 -0
  60. package/lib/compat.d.ts +36 -0
  61. package/lib/compat.js +20 -0
  62. package/lib/http.d.ts +6 -0
  63. package/lib/http.js +15 -0
  64. package/lib/index.d.ts +80 -0
  65. package/lib/index.js +14018 -0
  66. package/lib/model-defaults.d.ts +23 -0
  67. package/lib/model-defaults.js +237 -0
  68. package/lib/provider-settings.d.ts +39 -0
  69. package/lib/provider-settings.js +151 -0
  70. package/lib/providers/account-preferences.d.ts +41 -0
  71. package/lib/providers/account-preferences.js +153 -0
  72. package/lib/providers/accounts.d.ts +112 -0
  73. package/lib/providers/accounts.js +142 -0
  74. package/lib/providers/antigravity-oauth-client.d.ts +7 -0
  75. package/lib/providers/antigravity-oauth-client.js +7 -0
  76. package/lib/providers/antigravity.d.ts +110 -0
  77. package/lib/providers/antigravity.js +516 -0
  78. package/lib/providers/catalog-store.d.ts +48 -0
  79. package/lib/providers/catalog-store.js +243 -0
  80. package/lib/providers/claude-cli-version.d.ts +15 -0
  81. package/lib/providers/claude-cli-version.js +24 -0
  82. package/lib/providers/claude.d.ts +181 -0
  83. package/lib/providers/claude.js +680 -0
  84. package/lib/providers/codex-client-version.d.ts +8 -0
  85. package/lib/providers/codex-client-version.js +17 -0
  86. package/lib/providers/codex-search.d.ts +29 -0
  87. package/lib/providers/codex-search.js +198 -0
  88. package/lib/providers/codex.d.ts +229 -0
  89. package/lib/providers/codex.js +821 -0
  90. package/lib/providers/common.d.ts +389 -0
  91. package/lib/providers/common.js +656 -0
  92. package/lib/providers/copilot.d.ts +344 -0
  93. package/lib/providers/copilot.js +888 -0
  94. package/lib/providers/cursor-adapter.d.ts +15 -0
  95. package/lib/providers/cursor-adapter.js +39 -0
  96. package/lib/providers/cursor-auth.d.ts +43 -0
  97. package/lib/providers/cursor-auth.js +169 -0
  98. package/lib/providers/cursor-model-settings.d.ts +15 -0
  99. package/lib/providers/cursor-model-settings.js +51 -0
  100. package/lib/providers/cursor-usage.d.ts +9 -0
  101. package/lib/providers/cursor-usage.js +112 -0
  102. package/lib/providers/external-usage-controller.d.ts +19 -0
  103. package/lib/providers/external-usage-controller.js +32 -0
  104. package/lib/providers/external-usage.d.ts +8 -0
  105. package/lib/providers/external-usage.js +82 -0
  106. package/lib/providers/grok.d.ts +205 -0
  107. package/lib/providers/grok.js +713 -0
  108. package/lib/providers/image-pool.d.ts +30 -0
  109. package/lib/providers/image-pool.js +89 -0
  110. package/lib/providers/npm-cli-version.d.ts +56 -0
  111. package/lib/providers/npm-cli-version.js +98 -0
  112. package/lib/providers/pool-family.d.ts +56 -0
  113. package/lib/providers/pool-family.js +45 -0
  114. package/lib/providers/pool-health.d.ts +74 -0
  115. package/lib/providers/pool-health.js +150 -0
  116. package/lib/providers/pool-usage.d.ts +86 -0
  117. package/lib/providers/pool-usage.js +223 -0
  118. package/lib/providers/pool.d.ts +108 -0
  119. package/lib/providers/pool.js +368 -0
  120. package/lib/providers/rate-limit.d.ts +192 -0
  121. package/lib/providers/rate-limit.js +338 -0
  122. package/lib/tools/image-generate.d.ts +128 -0
  123. package/lib/tools/image-generate.js +438 -0
  124. package/lib/tools/registration.d.ts +15 -0
  125. package/lib/tools/registration.js +24 -0
  126. package/lib/tools/video-generate.d.ts +89 -0
  127. package/lib/tools/video-generate.js +257 -0
  128. package/lib/tools/x-search.d.ts +58 -0
  129. package/lib/tools/x-search.js +197 -0
  130. package/lib/translate/anthropic.d.ts +161 -0
  131. package/lib/translate/anthropic.js +515 -0
  132. package/lib/translate/antigravity-schema.d.ts +4 -0
  133. package/lib/translate/antigravity-schema.js +145 -0
  134. package/lib/translate/antigravity-thinking.d.ts +5 -0
  135. package/lib/translate/antigravity-thinking.js +44 -0
  136. package/lib/translate/antigravity.d.ts +114 -0
  137. package/lib/translate/antigravity.js +366 -0
  138. package/lib/translate/chat-completions.d.ts +120 -0
  139. package/lib/translate/chat-completions.js +382 -0
  140. package/lib/translate/resolved.d.ts +80 -0
  141. package/lib/translate/resolved.js +118 -0
  142. package/lib/translate/responses.d.ts +179 -0
  143. package/lib/translate/responses.js +408 -0
  144. package/lib/translate/sse.d.ts +21 -0
  145. package/lib/translate/sse.js +56 -0
  146. package/package.json +114 -0
  147. package/vendor/cursor/LICENSE +21 -0
  148. package/vendor/cursor/index.d.ts +18 -0
  149. package/vendor/cursor/index.js +3574 -0
  150. package/vendor/cursor/proto.js +254 -0
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Plumbing shared by the subscription adapters: HTTP error mapping, a
3
+ * stream idle watchdog, fetch failure classification, OAuth endpoint errors,
4
+ * and the per-provider {@link TokenManager} that owns session freshness.
5
+ * Concurrent refreshes for one provider coalesce behind a single in-flight
6
+ * promise (`inflight`), so a rotating refresh token is never spent twice.
7
+ */
8
+ import { createHash, randomUUID } from 'node:crypto';
9
+ import { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, } from '@deepseek-ai/dsh-llm';
10
+ import { durationMs, rateLimitDiagnostics, retryAfterInstant, waitFromReset } from './rate-limit.js';
11
+ /**
12
+ * Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
13
+ * @param models - raw configured entries.
14
+ * @param label - diagnostic prefix naming the provider.
15
+ * @returns the validated entries.
16
+ */
17
+ export function validateModels(models, label) {
18
+ const seen = new Set();
19
+ return models.map((model) => {
20
+ if (model.id.length === 0)
21
+ throw new Error(`${label}: catalog model ids must be non-empty`);
22
+ if (model.name !== undefined && model.name.length === 0) {
23
+ throw new Error(`${label}: catalog model "${model.id}" has an empty name`);
24
+ }
25
+ if (model.contextWindow !== undefined
26
+ && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
27
+ throw new Error(`${label}: catalog model "${model.id}" contextWindow must be a positive integer`);
28
+ }
29
+ if (model.maxTokens !== undefined && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
30
+ throw new Error(`${label}: catalog model "${model.id}" maxTokens must be a positive integer`);
31
+ }
32
+ if (model.inputModalities !== undefined
33
+ && (model.inputModalities.length === 0
34
+ || model.inputModalities.some(modality => modality !== 'text' && modality !== 'image'))) {
35
+ throw new Error(`${label}: catalog model "${model.id}" inputModalities must be a non-empty list of "text"/"image"`);
36
+ }
37
+ if (model.wire !== undefined && model.wire !== 'chat-completions' && model.wire !== 'responses') {
38
+ throw new Error(`${label}: catalog model "${model.id}" wire must be "chat-completions" or "responses"`);
39
+ }
40
+ if (seen.has(model.id))
41
+ throw new Error(`${label}: duplicate catalog model "${model.id}"`);
42
+ seen.add(model.id);
43
+ return {
44
+ id: model.id,
45
+ ...model.name === undefined ? {} : { name: model.name },
46
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
47
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
48
+ ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
49
+ ...model.wire === undefined ? {} : { wire: model.wire },
50
+ };
51
+ });
52
+ }
53
+ /**
54
+ * Build an LlmError from a non-2xx provider response, mapping the status to a
55
+ * stable code and, for a rate-limited request, the disclosed reset instant to
56
+ * the `providerRetryAfterMs` the retry plugin waits out.
57
+ *
58
+ * A 429 classifies as `RATE_LIMIT` on the strength of the status alone, ahead
59
+ * of the quota-wording check. On these routes there is no terminal quota to
60
+ * distinguish: a subscription has no balance to top up, only a window that
61
+ * reopens, and providers announce an exhausted window with wording
62
+ * (`usage_limit_reached`) the shared classifier reads as permanent.
63
+ * @param response - the failed response.
64
+ * @param label - diagnostic prefix naming the provider API.
65
+ * @param options - the calling provider's rate-limit reader and warning sink.
66
+ * @returns the classified error.
67
+ */
68
+ export async function httpLlmError(response, label, options = {}) {
69
+ let body = '';
70
+ try {
71
+ body = await response.text();
72
+ }
73
+ catch {
74
+ // Only swallow error-body reading: the HTTP status still identifies the failure.
75
+ }
76
+ // Truncated for display only; the readers below need the whole body to parse it.
77
+ const shown = body.slice(0, 500);
78
+ const message = shown.length > 0
79
+ ? `${label} error (HTTP ${String(response.status)}): ${shown}`
80
+ : `${label} error (HTTP ${String(response.status)})`;
81
+ let code;
82
+ if (response.status === 401 || response.status === 403)
83
+ code = 'AUTH';
84
+ else if (response.status === 429)
85
+ code = 'RATE_LIMIT';
86
+ else if (isQuotaExceededError(shown))
87
+ code = QUOTA_EXCEEDED_CODE;
88
+ else if (response.status === 400 && isContextWindowExceededError(shown))
89
+ code = CONTEXT_WINDOW_EXCEEDED_CODE;
90
+ else if (response.status === 408 || response.status === 504)
91
+ code = 'TIMEOUT';
92
+ else if (response.status >= 500)
93
+ code = 'SERVER';
94
+ else
95
+ code = `HTTP_${String(response.status)}`;
96
+ const now = Date.now();
97
+ // The provider's reader runs on a 429 and nowhere else. Providers attach
98
+ // their rate-limit headers to every response, so reading them on a transient
99
+ // 500 would report the current window's rollover — hours out — as the delay
100
+ // before retrying a failure that has nothing to do with the window, and the
101
+ // retry plugin honours `providerRetryAfterMs` for every retryable code.
102
+ // `retry-after` stays readable on any status: there it is a real backoff the
103
+ // provider asked for (a 503 shedding load), not a window snapshot.
104
+ //
105
+ // On a 429 the provider's own field wins outright rather than being raced
106
+ // against `retry-after`: a rejected window often carries both, and the
107
+ // generic header then names a short backoff that would burn the retry budget
108
+ // re-hitting the same closed window.
109
+ const rateLimited = response.status === 429;
110
+ const reset = rateLimited
111
+ ? options.rateLimitReset?.(response, body, now) ?? googleQuotaReset(body, now) ?? retryAfterInstant(response, now)
112
+ : retryAfterInstant(response, now);
113
+ if (reset === undefined && rateLimited) {
114
+ options.onWarn?.(`${label}: ${rateLimitDiagnostics(response, body)}`);
115
+ }
116
+ return new LlmError(message, code, {
117
+ status: response.status,
118
+ ...reset === undefined ? {} : { providerRetryAfterMs: waitFromReset(reset, now) },
119
+ });
120
+ }
121
+ /**
122
+ * Read a Google RPC quota reset from a 429 body.
123
+ *
124
+ * Antigravity discloses the window in `quotaResetDelay` and
125
+ * `quotaResetTimeStamp` and supplies no rate-limit reader. A wait beyond the
126
+ * route's delay ceiling makes the retry plugin fail the turn immediately
127
+ * rather than spending its local retry budget on a closed window. The
128
+ * relative delay wins: it is measured from this response, so a skewed local
129
+ * clock cannot stretch or erase it the way it would an absolute timestamp.
130
+ * @param body - the complete response body.
131
+ * @param now - the current epoch milliseconds.
132
+ * @returns the reset instant, or undefined when the body does not name one.
133
+ */
134
+ function googleQuotaReset(body, now) {
135
+ const delay = /"quotaResetDelay"\s*:\s*"([^"]+)"/.exec(body);
136
+ if (delay !== null) {
137
+ const ms = durationMs(delay[1]);
138
+ if (ms !== undefined && ms > 0)
139
+ return now + ms;
140
+ }
141
+ const stamp = /"quotaResetTimeStamp"\s*:\s*"([^"]+)"/.exec(body);
142
+ if (stamp === null)
143
+ return undefined;
144
+ const instant = Date.parse(stamp[1]);
145
+ return Number.isFinite(instant) && instant > now ? instant : undefined;
146
+ }
147
+ /**
148
+ * Parse a response's `retry-after` header (seconds) into milliseconds.
149
+ * @param response - the failed response.
150
+ * @returns the delay in ms, or undefined when absent/unusable.
151
+ */
152
+ function parseRetryAfterMs(response) {
153
+ const retryAfter = response.headers.get('retry-after');
154
+ if (retryAfter === null)
155
+ return undefined;
156
+ const seconds = Number(retryAfter);
157
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : undefined;
158
+ }
159
+ /**
160
+ * Create an idle watchdog chained to the caller's signal.
161
+ * @param caller - the request's own abort signal, when present.
162
+ * @param timeoutMs - maximum idle interval while a stream read is outstanding.
163
+ * @returns the watchdog; always {@link IdleWatchdog.stop} it when the stream ends.
164
+ */
165
+ export function idleWatchdog(caller, timeoutMs) {
166
+ const controller = new AbortController();
167
+ let expired = false;
168
+ let timer;
169
+ const arm = () => {
170
+ if (timer !== undefined)
171
+ clearTimeout(timer);
172
+ timer = setTimeout(() => {
173
+ expired = true;
174
+ controller.abort(new Error(`stream idle timeout after ${String(timeoutMs)}ms`));
175
+ }, timeoutMs);
176
+ timer.unref();
177
+ };
178
+ const onCallerAbort = () => controller.abort(caller?.reason);
179
+ if (caller?.aborted === true)
180
+ controller.abort(caller.reason);
181
+ else
182
+ caller?.addEventListener('abort', onCallerAbort, { once: true });
183
+ arm();
184
+ return {
185
+ signal: controller.signal,
186
+ pulse: arm,
187
+ stop() {
188
+ if (timer !== undefined)
189
+ clearTimeout(timer);
190
+ caller?.removeEventListener('abort', onCallerAbort);
191
+ },
192
+ timedOut: () => expired,
193
+ };
194
+ }
195
+ /**
196
+ * Classify a thrown fetch failure. Caller cancellation maps to ABORTED, idle
197
+ * expiry to TIMEOUT, and everything else (DNS, TLS, refused connection) to
198
+ * TRANSPORT with the cause chained.
199
+ * @param label - diagnostic prefix naming the provider API.
200
+ * @param error - the thrown value.
201
+ * @param watchdog - the request's idle watchdog.
202
+ * @param caller - the request's own abort signal, when present.
203
+ * @returns the classified error.
204
+ */
205
+ export function mapFetchFailure(label, error, watchdog, caller) {
206
+ if (watchdog.timedOut())
207
+ return new LlmError(`${label} stream idle timeout`, 'TIMEOUT', { cause: error });
208
+ if (caller?.aborted === true)
209
+ return new LlmError(`${label} request aborted by caller`, 'ABORTED', { cause: error });
210
+ if (error instanceof LlmError)
211
+ return error;
212
+ return new LlmError(`${label} request failed`, 'TRANSPORT', { cause: error });
213
+ }
214
+ /** OAuth token-endpoint failure carrying the provider's `error` code when it sent one. */
215
+ export class OAuthEndpointError extends Error {
216
+ /** HTTP status of the token endpoint response. */
217
+ status;
218
+ /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
219
+ oauthCode;
220
+ /**
221
+ * The endpoint's `retry-after`, in ms, when it sent one. Usage/models
222
+ * endpoints reuse this error type and can rate-limit progressively (each
223
+ * hit within the window extends the next one), so a caller retrying on a
224
+ * fixed schedule instead of honoring this can keep an account locked out
225
+ * indefinitely.
226
+ */
227
+ retryAfterMs;
228
+ constructor(message, status, oauthCode, retryAfterMs) {
229
+ super(message);
230
+ this.name = 'OAuthEndpointError';
231
+ this.status = status;
232
+ this.oauthCode = oauthCode;
233
+ this.retryAfterMs = retryAfterMs;
234
+ }
235
+ }
236
+ /**
237
+ * Read an OAuth JSON error body into an {@link OAuthEndpointError}. Two body
238
+ * shapes are understood: RFC 6749 (`{ error: "invalid_grant", error_description }`)
239
+ * and the OpenAI API envelope the auth.openai.com token endpoint now answers
240
+ * with (`{ error: { code: "refresh_token_reused", message } }`). The code
241
+ * must land in `oauthCode` either way — the permanent-failure classifiers
242
+ * key on it, and an unrecognized shape would keep a dead login forever.
243
+ * @param response - the failed token-endpoint response.
244
+ * @param label - diagnostic prefix naming the provider.
245
+ * @returns the error to throw.
246
+ */
247
+ export async function oauthEndpointError(response, label) {
248
+ let oauthCode;
249
+ let detail = '';
250
+ try {
251
+ const parsed = await response.json();
252
+ if (typeof parsed.error === 'string') {
253
+ oauthCode = parsed.error;
254
+ }
255
+ else if (typeof parsed.error === 'object' && parsed.error !== null) {
256
+ const nested = parsed.error;
257
+ if (typeof nested.code === 'string' && nested.code.length > 0)
258
+ oauthCode = nested.code;
259
+ if (typeof nested.message === 'string')
260
+ detail = nested.message;
261
+ }
262
+ if (typeof parsed.error_description === 'string')
263
+ detail = parsed.error_description;
264
+ if (detail.length === 0)
265
+ detail = oauthCode ?? '';
266
+ }
267
+ catch {
268
+ // Only swallow error-body parsing: the HTTP status still identifies the failure.
269
+ }
270
+ const message = detail.length > 0
271
+ ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}`
272
+ : `${label} token endpoint error (HTTP ${String(response.status)})`;
273
+ return new OAuthEndpointError(message, response.status, oauthCode, parseRetryAfterMs(response));
274
+ }
275
+ /**
276
+ * Per-provider session freshness: loads the stored session, refreshes
277
+ * proactively inside the preempt window or on demand after a 401, and
278
+ * coalesces concurrent refreshes behind one in-flight promise. Permanent
279
+ * refresh failures delete the stored session and surface INVALID_CREDENTIAL
280
+ * with a re-login hint; transient failures fall back to a still-valid token.
281
+ */
282
+ export class TokenManager {
283
+ options;
284
+ inflight;
285
+ /** The session whose refresh token the in-flight refresh is spending. */
286
+ attempted;
287
+ constructor(options) {
288
+ this.options = options;
289
+ this.options = options;
290
+ }
291
+ /**
292
+ * Read the stored session without any refresh side effect. Catalog queries
293
+ * (`listModels`) use this to decide whether the provider is logged in.
294
+ * @returns the stored session, or `undefined` when logged out.
295
+ */
296
+ peek() {
297
+ return this.options.load();
298
+ }
299
+ /**
300
+ * Whether a session is currently stored (cheap; never refreshes).
301
+ * @returns true when logged in.
302
+ */
303
+ async hasSession() {
304
+ return (await this.options.load()) !== undefined;
305
+ }
306
+ /**
307
+ * Resolve a usable session, refreshing proactively or on demand.
308
+ * @param forceRefresh - refresh regardless of expiry (used after a 401).
309
+ * @returns the persisted session to send.
310
+ * @throws LlmError MISSING_CREDENTIAL when logged out, INVALID_CREDENTIAL
311
+ * when the refresh grant is permanently rejected.
312
+ */
313
+ async session(forceRefresh = false) {
314
+ const session = await this.options.load();
315
+ if (session === undefined) {
316
+ throw new LlmError(`dsh-subscriptions: not logged in to ${this.options.displayName}; `
317
+ + 'log in via Settings → Subscriptions in the dsh web app', 'MISSING_CREDENTIAL');
318
+ }
319
+ if (!forceRefresh && session.expiresAt - Date.now() > this.options.preemptMs) {
320
+ return session;
321
+ }
322
+ this.inflight ??= this.doRefresh(session).finally(() => {
323
+ this.inflight = undefined;
324
+ });
325
+ try {
326
+ return await this.inflight;
327
+ }
328
+ catch (error) {
329
+ if (this.options.isPermanent(error)) {
330
+ // A re-login may have landed while this refresh was failing: its
331
+ // session carries a different refresh token, and deleting it would
332
+ // log out an account that just signed in. Serve it instead.
333
+ const attempted = this.attempted;
334
+ const stored = await this.options.load();
335
+ if (stored !== undefined && attempted !== undefined && stored.refreshToken !== attempted.refreshToken) {
336
+ return stored;
337
+ }
338
+ await this.options.remove();
339
+ this.options.onRemoved?.();
340
+ throw new LlmError(`${this.options.displayName} login expired or was revoked; log in again via Settings → Subscriptions`, 'INVALID_CREDENTIAL', { cause: error });
341
+ }
342
+ if (!forceRefresh && session.expiresAt > Date.now()) {
343
+ // Transient refresh failure with a still-valid token: use it.
344
+ return session;
345
+ }
346
+ throw error instanceof LlmError
347
+ ? error
348
+ : new LlmError(`${this.options.displayName} token refresh failed`, 'AUTH', { cause: error });
349
+ }
350
+ }
351
+ async doRefresh(session) {
352
+ // A concurrent caller may have refreshed while this one waited: re-read
353
+ // the store and skip the round trip when the stored session is fresh.
354
+ const current = await this.options.load();
355
+ if (current !== undefined
356
+ && current.accessToken !== session.accessToken
357
+ && current.expiresAt - Date.now() > this.options.preemptMs) {
358
+ return current;
359
+ }
360
+ const attempted = current ?? session;
361
+ this.attempted = attempted;
362
+ const next = await this.options.refresh(attempted);
363
+ await this.options.save(next);
364
+ return next;
365
+ }
366
+ }
367
+ /** Bound on one account catalog fetch or usage poll — a hang must not block the picker. */
368
+ export const DISCOVERY_TIMEOUT_MS = 10_000;
369
+ /**
370
+ * Run `work` with an aborting signal. Resolves undefined when the timeout
371
+ * fires (the fetch is aborted); other failures propagate.
372
+ */
373
+ export function withTimeout(work, timeoutMs) {
374
+ const signal = AbortSignal.timeout(timeoutMs);
375
+ const aborted = new Promise(resolve => {
376
+ if (signal.aborted)
377
+ resolve(undefined);
378
+ else
379
+ signal.addEventListener('abort', () => resolve(undefined), { once: true });
380
+ });
381
+ return Promise.race([
382
+ work(signal).then(value => (signal.aborted ? undefined : value), (error) => {
383
+ if (signal.aborted)
384
+ return undefined;
385
+ throw error;
386
+ }),
387
+ aborted,
388
+ ]);
389
+ }
390
+ /** Display name for a wire reasoning-effort identifier. */
391
+ export function effortDisplayName(effort) {
392
+ return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
393
+ }
394
+ /**
395
+ * Fold a configured per-model default effort into a reasoning block, keeping
396
+ * the DSH runtime invariant `defaultEffort ∈ efforts` (the runtime rejects an
397
+ * unknown default with `INVALID_MODEL_REASONING`).
398
+ *
399
+ * A configured level the base set does not advertise is *dropped*, not
400
+ * appended: for claude/grok/copilot the base is the provider's live catalog,
401
+ * i.e. the truth about what the model accepts, so honouring a stale override
402
+ * would put an unsupported effort on every single request instead of letting
403
+ * the harness reject it before provider I/O. The override then simply falls
404
+ * back to the provider's own default until the user picks a level the catalog
405
+ * still lists.
406
+ *
407
+ * `extendable` opts into the opposite rule for a base that is a *built-in
408
+ * fallback* rather than discovered truth (codex, whose static effort list is
409
+ * known to trail the backend): there, appending the configured level is how a
410
+ * newly shipped tier becomes selectable at all.
411
+ * @param configuredDefault - the user-configured default effort id, or undefined.
412
+ * @param base - the discovered/built-in reasoning block, or undefined.
413
+ * @param options - `extendable` marks the base as a fallback that may be extended.
414
+ * @returns the merged block, or undefined when neither side contributes one.
415
+ */
416
+ export function mergeReasoning(configuredDefault, base, options) {
417
+ const detached = base === undefined
418
+ ? undefined
419
+ : {
420
+ efforts: [...base.efforts],
421
+ ...(base.defaultEffort === undefined ? {} : { defaultEffort: base.defaultEffort }),
422
+ };
423
+ if (configuredDefault === undefined)
424
+ return detached;
425
+ const effort = ReasoningEffortId(configuredDefault);
426
+ if (base === undefined) {
427
+ // No capability information at all (catalog unavailable, or a model the
428
+ // catalog does not cover). Inventing a reasoning block here would claim a
429
+ // capability nobody advertised; only a fallback-based provider may.
430
+ return options?.extendable === true
431
+ ? { efforts: [{ id: effort, name: effortDisplayName(effort) }], defaultEffort: effort }
432
+ : undefined;
433
+ }
434
+ if (base.efforts.some(entry => entry.id === effort)) {
435
+ return { efforts: [...base.efforts], defaultEffort: effort };
436
+ }
437
+ if (options?.extendable !== true)
438
+ return detached;
439
+ return {
440
+ efforts: [...base.efforts, { id: effort, name: effortDisplayName(effort) }],
441
+ defaultEffort: effort,
442
+ };
443
+ }
444
+ /**
445
+ * First account catalog that lists `model` (callers pass default-first).
446
+ * One failing lookup sits that account out so a sibling's metadata still
447
+ * resolves — the same isolation as the picker catalog union.
448
+ */
449
+ export async function discoverAcrossAccounts(accounts, lookup) {
450
+ for (const account of accounts) {
451
+ try {
452
+ const found = await lookup(account);
453
+ if (found !== undefined)
454
+ return found;
455
+ }
456
+ catch {
457
+ // sit out
458
+ }
459
+ }
460
+ return undefined;
461
+ }
462
+ /** How long a discovered catalog is trusted before re-fetching. */
463
+ export const DISCOVERY_TTL_MS = 5 * 60_000;
464
+ /**
465
+ * Cache for one provider's discovered model catalog. The TTL only decides
466
+ * when to REFRESH; it never makes the cache forget: capability metadata
467
+ * (reasoning efforts) must stay stable for a session that selected an effort,
468
+ * or mid-conversation calls fail UNSUPPORTED_REASONING_EFFORT the moment the
469
+ * cache goes stale. `listModels` awaits freshness via {@link get};
470
+ * `resolveModel` uses {@link resolve}, which serves the last-known catalog
471
+ * while a stale entry refreshes in the background, and only awaits the fetch
472
+ * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
473
+ * last-known state across restarts and receives every successful fetch. A 401
474
+ * that still fails after a forced token refresh must call {@link invalidate}.
475
+ */
476
+ export class ModelCatalogCache {
477
+ persistence;
478
+ ttlMs;
479
+ entry;
480
+ inflight;
481
+ /** Settles once the persisted snapshot (when any) has been considered. */
482
+ seeded;
483
+ /** Set by {@link invalidate} so an in-flight disk read cannot resurrect dropped state. */
484
+ seedDisabled = false;
485
+ /** Bumped by {@link invalidate} so a loser in-flight fetch cannot write back. */
486
+ generation = 0;
487
+ constructor(persistence, ttlMs = DISCOVERY_TTL_MS) {
488
+ this.persistence = persistence;
489
+ this.ttlMs = ttlMs;
490
+ }
491
+ /**
492
+ * The cached catalog when fresh, without fetching.
493
+ * @returns the cached models, or `undefined` when absent or stale.
494
+ */
495
+ cached() {
496
+ if (this.entry === undefined || Date.now() - this.entry.at >= this.ttlMs)
497
+ return undefined;
498
+ return this.entry.models;
499
+ }
500
+ /**
501
+ * The last successfully fetched catalog, ignoring TTL. Used to carry
502
+ * capability metadata forward when a later fetch cannot re-enrich.
503
+ * @returns the last-known models, or `undefined` when nothing has been stored.
504
+ */
505
+ lastKnown() {
506
+ return this.entry?.models;
507
+ }
508
+ /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
509
+ ensureSeeded() {
510
+ if (this.persistence === undefined)
511
+ return Promise.resolve();
512
+ this.seeded ??= this.persistence.load().then((snapshot) => {
513
+ if (snapshot !== undefined && this.entry === undefined && !this.seedDisabled) {
514
+ this.entry = snapshot;
515
+ }
516
+ }, () => undefined);
517
+ return this.seeded;
518
+ }
519
+ /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
520
+ refresh(fetcher) {
521
+ if (this.inflight !== undefined)
522
+ return this.inflight;
523
+ const gen = this.generation;
524
+ const pending = fetcher()
525
+ .then((models) => {
526
+ if (this.generation !== gen)
527
+ return models;
528
+ const snapshot = { at: Date.now(), models };
529
+ this.entry = snapshot;
530
+ // Write-through is fire-and-forget: a failed save only costs durability.
531
+ void this.persistence?.save(snapshot).catch(() => undefined);
532
+ return models;
533
+ })
534
+ .finally(() => {
535
+ if (this.generation === gen)
536
+ this.inflight = undefined;
537
+ });
538
+ this.inflight = pending;
539
+ return pending;
540
+ }
541
+ /**
542
+ * Return the cached catalog when fresh, otherwise fetch and cache it.
543
+ * @param fetcher - performs the provider's model-list request.
544
+ * @returns the discovered models.
545
+ * @throws the fetcher's failure (the `listModels` caller warns and falls back).
546
+ */
547
+ async get(fetcher) {
548
+ await this.ensureSeeded();
549
+ return this.cached() ?? this.refresh(fetcher);
550
+ }
551
+ /**
552
+ * The models for capability resolution. A fresh cache answers directly; a
553
+ * stale one answers immediately from the last-known catalog while a
554
+ * background refresh runs (a mid-conversation `resolveModel` must neither
555
+ * block on nor fail with the network); a cold cache awaits one fetch.
556
+ * @param fetcher - performs the provider's model-list request.
557
+ * @returns the models, or `undefined` when nothing is known (the caller
558
+ * falls back to its static metadata). Never throws.
559
+ */
560
+ async resolve(fetcher) {
561
+ await this.ensureSeeded();
562
+ const fresh = this.cached();
563
+ if (fresh !== undefined)
564
+ return fresh;
565
+ const known = this.entry?.models;
566
+ if (known !== undefined) {
567
+ // Stale-while-revalidate: the refresh outcome serves the NEXT resolve.
568
+ this.refresh(fetcher).catch(() => undefined);
569
+ return known;
570
+ }
571
+ try {
572
+ return await this.refresh(fetcher);
573
+ }
574
+ catch {
575
+ return undefined;
576
+ }
577
+ }
578
+ /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
579
+ invalidate() {
580
+ this.generation += 1;
581
+ this.entry = undefined;
582
+ this.inflight = undefined;
583
+ this.seedDisabled = true;
584
+ void this.persistence?.clear().catch(() => undefined);
585
+ }
586
+ }
587
+ /** Whether discovery failed because the stored login is gone. */
588
+ export function isMissingOrInvalidCredential(error) {
589
+ return error instanceof LlmError
590
+ && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL');
591
+ }
592
+ /** Whether discovery stopped because the caller cancelled or the timeout fired. */
593
+ export function isDiscoveryAborted(error, signal) {
594
+ if (signal?.aborted === true)
595
+ return true;
596
+ // Only treat abort-shaped errors as cancellation when this call had a signal;
597
+ // a refresh TimeoutError must not fail the whole picker union.
598
+ return signal !== undefined
599
+ && error instanceof Error
600
+ && (error.name === 'AbortError' || error.name === 'TimeoutError');
601
+ }
602
+ /**
603
+ * Whether discovery failed because the access token was rejected. After
604
+ * {@link discoverOrRetryAuth} this means the token was rejected AGAIN right
605
+ * after a forced refresh: the login is dead server-side (revoked) even though
606
+ * the refresh grant still answers, so the store keeps the session.
607
+ */
608
+ export function isDiscoveryAuthFailure(error) {
609
+ return (error instanceof OAuthEndpointError && error.status === 401)
610
+ || (error instanceof LlmError && error.code === 'AUTH');
611
+ }
612
+ /**
613
+ * Run a catalog fetch, retrying once after a forced token refresh when the
614
+ * first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
615
+ * when the retry is also an auth failure, so a refresh race cannot erase
616
+ * last-known capability metadata.
617
+ */
618
+ export async function discoverOrRetryAuth(session, catalog, run) {
619
+ try {
620
+ return await run();
621
+ }
622
+ catch (error) {
623
+ if (isMissingOrInvalidCredential(error) || !isDiscoveryAuthFailure(error))
624
+ throw error;
625
+ try {
626
+ await session(true);
627
+ return await run();
628
+ }
629
+ catch (retryError) {
630
+ if (!isMissingOrInvalidCredential(retryError) && isDiscoveryAuthFailure(retryError)) {
631
+ catalog.invalidate();
632
+ }
633
+ throw retryError;
634
+ }
635
+ }
636
+ }
637
+ /**
638
+ * Keep Codex's session header stable for a supplied, non-empty session ID.
639
+ * Existing UUIDs are preserved; other IDs use a SHA-256-derived UUIDv8 (a
640
+ * custom deterministic layout). UUID formatting is a client convention,
641
+ * not a claim about gateway validation or guaranteed prompt-cache hits.
642
+ * Missing and empty IDs have no session identity and receive a fresh UUIDv4.
643
+ */
644
+ export function deterministicSessionId(sessionId) {
645
+ if (sessionId === undefined || sessionId.length === 0)
646
+ return randomUUID();
647
+ const str = String(sessionId);
648
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str)) {
649
+ return str;
650
+ }
651
+ const bytes = createHash('sha256').update(str).digest().subarray(0, 16);
652
+ bytes[6] = (bytes[6] & 0x0f) | 0x80;
653
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
654
+ const hex = bytes.toString('hex');
655
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
656
+ }