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.
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/cordis.patch.yml +12 -0
- package/lib/auth/claude-code-creds.d.ts +23 -0
- package/lib/auth/claude-code-creds.js +172 -0
- package/lib/auth/device-flow.d.ts +55 -0
- package/lib/auth/device-flow.js +177 -0
- package/lib/auth/jwt.d.ts +10 -0
- package/lib/auth/jwt.js +25 -0
- package/lib/auth/oauth-flow.d.ts +91 -0
- package/lib/auth/oauth-flow.js +227 -0
- package/lib/auth/pkce.d.ts +31 -0
- package/lib/auth/pkce.js +35 -0
- package/lib/auth/rpc.d.ts +209 -0
- package/lib/auth/rpc.js +386 -0
- package/lib/auth/store.d.ts +196 -0
- package/lib/auth/store.js +468 -0
- package/lib/client/CursorCard.d.ts +9 -0
- package/lib/client/CursorCard.js +113 -0
- package/lib/client/ExternalUsageCards.d.ts +9 -0
- package/lib/client/ExternalUsageCards.js +72 -0
- package/lib/client/ImageGallery.d.ts +54 -0
- package/lib/client/ImageGallery.js +112 -0
- package/lib/client/ImageGenerateToolview.d.ts +48 -0
- package/lib/client/ImageGenerateToolview.js +133 -0
- package/lib/client/ProviderAccountManager.d.ts +13 -0
- package/lib/client/ProviderAccountManager.js +147 -0
- package/lib/client/ProviderModelEditor.d.ts +39 -0
- package/lib/client/ProviderModelEditor.js +107 -0
- package/lib/client/SpeedSelect.d.ts +71 -0
- package/lib/client/SpeedSelect.js +177 -0
- package/lib/client/SubscriptionUsageBadge.d.ts +116 -0
- package/lib/client/SubscriptionUsageBadge.js +543 -0
- package/lib/client/SubscriptionsSection.d.ts +140 -0
- package/lib/client/SubscriptionsSection.js +544 -0
- package/lib/client/UsageBadgeDisplaySetting.d.ts +7 -0
- package/lib/client/UsageBadgeDisplaySetting.js +36 -0
- package/lib/client/VideoGenerateToolview.d.ts +42 -0
- package/lib/client/VideoGenerateToolview.js +175 -0
- package/lib/client/account-preferences.d.ts +24 -0
- package/lib/client/account-preferences.js +21 -0
- package/lib/client/fast-command.d.ts +2 -0
- package/lib/client/fast-command.js +4 -0
- package/lib/client/host-contract.d.ts +90 -0
- package/lib/client/host-contract.js +101 -0
- package/lib/client/host-icons.d.ts +24 -0
- package/lib/client/host-icons.js +21 -0
- package/lib/client/index.d.ts +27 -0
- package/lib/client/index.js +136 -0
- package/lib/client/locales.d.ts +297 -0
- package/lib/client/locales.js +295 -0
- package/lib/client/subscription-card-styles.d.ts +3 -0
- package/lib/client/subscription-card-styles.js +53 -0
- package/lib/client/subscriptions-rpc.d.ts +14 -0
- package/lib/client/subscriptions-rpc.js +34 -0
- package/lib/client/usage-badge-preferences.d.ts +11 -0
- package/lib/client/usage-badge-preferences.js +41 -0
- package/lib/client.js +4472 -0
- package/lib/client.js.map +1 -0
- package/lib/compat.d.ts +36 -0
- package/lib/compat.js +20 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +15 -0
- package/lib/index.d.ts +80 -0
- package/lib/index.js +14018 -0
- package/lib/model-defaults.d.ts +23 -0
- package/lib/model-defaults.js +237 -0
- package/lib/provider-settings.d.ts +39 -0
- package/lib/provider-settings.js +151 -0
- package/lib/providers/account-preferences.d.ts +41 -0
- package/lib/providers/account-preferences.js +153 -0
- package/lib/providers/accounts.d.ts +112 -0
- package/lib/providers/accounts.js +142 -0
- package/lib/providers/antigravity-oauth-client.d.ts +7 -0
- package/lib/providers/antigravity-oauth-client.js +7 -0
- package/lib/providers/antigravity.d.ts +110 -0
- package/lib/providers/antigravity.js +516 -0
- package/lib/providers/catalog-store.d.ts +48 -0
- package/lib/providers/catalog-store.js +243 -0
- package/lib/providers/claude-cli-version.d.ts +15 -0
- package/lib/providers/claude-cli-version.js +24 -0
- package/lib/providers/claude.d.ts +181 -0
- package/lib/providers/claude.js +680 -0
- package/lib/providers/codex-client-version.d.ts +8 -0
- package/lib/providers/codex-client-version.js +17 -0
- package/lib/providers/codex-search.d.ts +29 -0
- package/lib/providers/codex-search.js +198 -0
- package/lib/providers/codex.d.ts +229 -0
- package/lib/providers/codex.js +821 -0
- package/lib/providers/common.d.ts +389 -0
- package/lib/providers/common.js +656 -0
- package/lib/providers/copilot.d.ts +344 -0
- package/lib/providers/copilot.js +888 -0
- package/lib/providers/cursor-adapter.d.ts +15 -0
- package/lib/providers/cursor-adapter.js +39 -0
- package/lib/providers/cursor-auth.d.ts +43 -0
- package/lib/providers/cursor-auth.js +169 -0
- package/lib/providers/cursor-model-settings.d.ts +15 -0
- package/lib/providers/cursor-model-settings.js +51 -0
- package/lib/providers/cursor-usage.d.ts +9 -0
- package/lib/providers/cursor-usage.js +112 -0
- package/lib/providers/external-usage-controller.d.ts +19 -0
- package/lib/providers/external-usage-controller.js +32 -0
- package/lib/providers/external-usage.d.ts +8 -0
- package/lib/providers/external-usage.js +82 -0
- package/lib/providers/grok.d.ts +205 -0
- package/lib/providers/grok.js +713 -0
- package/lib/providers/image-pool.d.ts +30 -0
- package/lib/providers/image-pool.js +89 -0
- package/lib/providers/npm-cli-version.d.ts +56 -0
- package/lib/providers/npm-cli-version.js +98 -0
- package/lib/providers/pool-family.d.ts +56 -0
- package/lib/providers/pool-family.js +45 -0
- package/lib/providers/pool-health.d.ts +74 -0
- package/lib/providers/pool-health.js +150 -0
- package/lib/providers/pool-usage.d.ts +86 -0
- package/lib/providers/pool-usage.js +223 -0
- package/lib/providers/pool.d.ts +108 -0
- package/lib/providers/pool.js +368 -0
- package/lib/providers/rate-limit.d.ts +192 -0
- package/lib/providers/rate-limit.js +338 -0
- package/lib/tools/image-generate.d.ts +128 -0
- package/lib/tools/image-generate.js +438 -0
- package/lib/tools/registration.d.ts +15 -0
- package/lib/tools/registration.js +24 -0
- package/lib/tools/video-generate.d.ts +89 -0
- package/lib/tools/video-generate.js +257 -0
- package/lib/tools/x-search.d.ts +58 -0
- package/lib/tools/x-search.js +197 -0
- package/lib/translate/anthropic.d.ts +161 -0
- package/lib/translate/anthropic.js +515 -0
- package/lib/translate/antigravity-schema.d.ts +4 -0
- package/lib/translate/antigravity-schema.js +145 -0
- package/lib/translate/antigravity-thinking.d.ts +5 -0
- package/lib/translate/antigravity-thinking.js +44 -0
- package/lib/translate/antigravity.d.ts +114 -0
- package/lib/translate/antigravity.js +366 -0
- package/lib/translate/chat-completions.d.ts +120 -0
- package/lib/translate/chat-completions.js +382 -0
- package/lib/translate/resolved.d.ts +80 -0
- package/lib/translate/resolved.js +118 -0
- package/lib/translate/responses.d.ts +179 -0
- package/lib/translate/responses.js +408 -0
- package/lib/translate/sse.d.ts +21 -0
- package/lib/translate/sse.js +56 -0
- package/package.json +114 -0
- package/vendor/cursor/LICENSE +21 -0
- package/vendor/cursor/index.d.ts +18 -0
- package/vendor/cursor/index.js +3574 -0
- 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
|
+
}
|