dsh-plugin-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 (44) hide show
  1. package/README.md +93 -0
  2. package/README.zh.md +93 -0
  3. package/cordis.patch.yml +12 -0
  4. package/lib/auth/jwt.d.ts +10 -0
  5. package/lib/auth/jwt.js +25 -0
  6. package/lib/auth/oauth-flow.d.ts +91 -0
  7. package/lib/auth/oauth-flow.js +227 -0
  8. package/lib/auth/pkce.d.ts +31 -0
  9. package/lib/auth/pkce.js +35 -0
  10. package/lib/auth/rpc.d.ts +51 -0
  11. package/lib/auth/rpc.js +83 -0
  12. package/lib/auth/store.d.ts +90 -0
  13. package/lib/auth/store.js +137 -0
  14. package/lib/client/SubscriptionsSection.d.ts +30 -0
  15. package/lib/client/SubscriptionsSection.js +290 -0
  16. package/lib/client/index.d.ts +31 -0
  17. package/lib/client/index.js +35 -0
  18. package/lib/client/locales.d.ts +45 -0
  19. package/lib/client/locales.js +43 -0
  20. package/lib/client.js +546 -0
  21. package/lib/client.js.map +1 -0
  22. package/lib/index.d.ts +34 -0
  23. package/lib/index.js +2932 -0
  24. package/lib/providers/claude.d.ts +60 -0
  25. package/lib/providers/claude.js +243 -0
  26. package/lib/providers/codex.d.ts +96 -0
  27. package/lib/providers/codex.js +391 -0
  28. package/lib/providers/common.d.ts +185 -0
  29. package/lib/providers/common.js +302 -0
  30. package/lib/providers/grok.d.ts +90 -0
  31. package/lib/providers/grok.js +337 -0
  32. package/lib/tools/image-generate.d.ts +60 -0
  33. package/lib/tools/image-generate.js +142 -0
  34. package/lib/tools/x-search.d.ts +58 -0
  35. package/lib/tools/x-search.js +195 -0
  36. package/lib/translate/anthropic.d.ts +120 -0
  37. package/lib/translate/anthropic.js +370 -0
  38. package/lib/translate/resolved.d.ts +35 -0
  39. package/lib/translate/resolved.js +40 -0
  40. package/lib/translate/responses.d.ts +127 -0
  41. package/lib/translate/responses.js +352 -0
  42. package/lib/translate/sse.d.ts +21 -0
  43. package/lib/translate/sse.js +56 -0
  44. package/package.json +83 -0
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Plumbing shared by the three 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 { CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
9
+ /**
10
+ * Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
11
+ * @param models - raw configured entries.
12
+ * @param label - diagnostic prefix naming the provider.
13
+ * @returns the validated entries.
14
+ */
15
+ export function validateModels(models, label) {
16
+ const seen = new Set();
17
+ return models.map((model) => {
18
+ if (model.id.length === 0)
19
+ throw new Error(`${label}: catalog model ids must be non-empty`);
20
+ if (model.name !== undefined && model.name.length === 0) {
21
+ throw new Error(`${label}: catalog model "${model.id}" has an empty name`);
22
+ }
23
+ if (model.contextWindow !== undefined
24
+ && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
25
+ throw new Error(`${label}: catalog model "${model.id}" contextWindow must be a positive integer`);
26
+ }
27
+ if (model.maxTokens !== undefined && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
28
+ throw new Error(`${label}: catalog model "${model.id}" maxTokens must be a positive integer`);
29
+ }
30
+ if (model.inputModalities !== undefined
31
+ && (model.inputModalities.length === 0
32
+ || model.inputModalities.some(modality => modality !== 'text' && modality !== 'image'))) {
33
+ throw new Error(`${label}: catalog model "${model.id}" inputModalities must be a non-empty list of "text"/"image"`);
34
+ }
35
+ if (seen.has(model.id))
36
+ throw new Error(`${label}: duplicate catalog model "${model.id}"`);
37
+ seen.add(model.id);
38
+ return {
39
+ id: model.id,
40
+ ...model.name === undefined ? {} : { name: model.name },
41
+ ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
42
+ ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
43
+ ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
44
+ };
45
+ });
46
+ }
47
+ /**
48
+ * Build an LlmError from a non-2xx provider response, reading and truncating
49
+ * the body for the message and mapping the status to a stable code.
50
+ * @param response - the failed response.
51
+ * @param label - diagnostic prefix naming the provider API.
52
+ * @returns the classified error.
53
+ */
54
+ export async function httpLlmError(response, label) {
55
+ let body = '';
56
+ try {
57
+ body = (await response.text()).slice(0, 500);
58
+ }
59
+ catch {
60
+ // Only swallow error-body reading: the HTTP status still identifies the failure.
61
+ }
62
+ const message = body.length > 0
63
+ ? `${label} error (HTTP ${String(response.status)}): ${body}`
64
+ : `${label} error (HTTP ${String(response.status)})`;
65
+ let code;
66
+ if (response.status === 401 || response.status === 403)
67
+ code = 'AUTH';
68
+ else if (isQuotaExceededError(body))
69
+ code = QUOTA_EXCEEDED_CODE;
70
+ else if (response.status === 429)
71
+ code = 'RATE_LIMIT';
72
+ else if (response.status === 400 && isContextWindowExceededError(body))
73
+ code = CONTEXT_WINDOW_EXCEEDED_CODE;
74
+ else if (response.status === 408 || response.status === 504)
75
+ code = 'TIMEOUT';
76
+ else if (response.status >= 500)
77
+ code = 'SERVER';
78
+ else
79
+ code = `HTTP_${String(response.status)}`;
80
+ const retryAfter = response.headers.get('retry-after');
81
+ let providerRetryAfterMs;
82
+ if (retryAfter !== null) {
83
+ const seconds = Number(retryAfter);
84
+ if (Number.isFinite(seconds) && seconds > 0)
85
+ providerRetryAfterMs = seconds * 1000;
86
+ }
87
+ return new LlmError(message, code, {
88
+ status: response.status,
89
+ ...providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs },
90
+ });
91
+ }
92
+ /**
93
+ * Create an idle watchdog chained to the caller's signal.
94
+ * @param caller - the request's own abort signal, when present.
95
+ * @param timeoutMs - maximum idle interval while a stream read is outstanding.
96
+ * @returns the watchdog; always {@link IdleWatchdog.stop} it when the stream ends.
97
+ */
98
+ export function idleWatchdog(caller, timeoutMs) {
99
+ const controller = new AbortController();
100
+ let expired = false;
101
+ let timer;
102
+ const arm = () => {
103
+ if (timer !== undefined)
104
+ clearTimeout(timer);
105
+ timer = setTimeout(() => {
106
+ expired = true;
107
+ controller.abort(new Error(`stream idle timeout after ${String(timeoutMs)}ms`));
108
+ }, timeoutMs);
109
+ timer.unref();
110
+ };
111
+ const onCallerAbort = () => controller.abort(caller?.reason);
112
+ if (caller?.aborted === true)
113
+ controller.abort(caller.reason);
114
+ else
115
+ caller?.addEventListener('abort', onCallerAbort, { once: true });
116
+ arm();
117
+ return {
118
+ signal: controller.signal,
119
+ pulse: arm,
120
+ stop() {
121
+ if (timer !== undefined)
122
+ clearTimeout(timer);
123
+ caller?.removeEventListener('abort', onCallerAbort);
124
+ },
125
+ timedOut: () => expired,
126
+ };
127
+ }
128
+ /**
129
+ * Classify a thrown fetch failure. Caller cancellation maps to ABORTED, idle
130
+ * expiry to TIMEOUT, and everything else (DNS, TLS, refused connection) to
131
+ * TRANSPORT with the cause chained.
132
+ * @param label - diagnostic prefix naming the provider API.
133
+ * @param error - the thrown value.
134
+ * @param watchdog - the request's idle watchdog.
135
+ * @param caller - the request's own abort signal, when present.
136
+ * @returns the classified error.
137
+ */
138
+ export function mapFetchFailure(label, error, watchdog, caller) {
139
+ if (watchdog.timedOut())
140
+ return new LlmError(`${label} stream idle timeout`, 'TIMEOUT', { cause: error });
141
+ if (caller?.aborted === true)
142
+ return new LlmError(`${label} request aborted by caller`, 'ABORTED', { cause: error });
143
+ if (error instanceof LlmError)
144
+ return error;
145
+ return new LlmError(`${label} request failed`, 'TRANSPORT', { cause: error });
146
+ }
147
+ /** OAuth token-endpoint failure carrying the provider's `error` code when it sent one. */
148
+ export class OAuthEndpointError extends Error {
149
+ /** HTTP status of the token endpoint response. */
150
+ status;
151
+ /** The provider's OAuth `error` code (e.g. `invalid_grant`), when present. */
152
+ oauthCode;
153
+ constructor(message, status, oauthCode) {
154
+ super(message);
155
+ this.name = 'OAuthEndpointError';
156
+ this.status = status;
157
+ this.oauthCode = oauthCode;
158
+ }
159
+ }
160
+ /**
161
+ * Read an OAuth JSON error body into an {@link OAuthEndpointError}.
162
+ * @param response - the failed token-endpoint response.
163
+ * @param label - diagnostic prefix naming the provider.
164
+ * @returns the error to throw.
165
+ */
166
+ export async function oauthEndpointError(response, label) {
167
+ let oauthCode;
168
+ let detail = '';
169
+ try {
170
+ const parsed = await response.json();
171
+ oauthCode = typeof parsed.error === 'string' ? parsed.error : undefined;
172
+ detail = typeof parsed.error_description === 'string' ? parsed.error_description : (oauthCode ?? '');
173
+ }
174
+ catch {
175
+ // Only swallow error-body parsing: the HTTP status still identifies the failure.
176
+ }
177
+ const message = detail.length > 0
178
+ ? `${label} token endpoint error (HTTP ${String(response.status)}): ${detail}`
179
+ : `${label} token endpoint error (HTTP ${String(response.status)})`;
180
+ return new OAuthEndpointError(message, response.status, oauthCode);
181
+ }
182
+ /**
183
+ * Per-provider session freshness: loads the stored session, refreshes
184
+ * proactively inside the preempt window or on demand after a 401, and
185
+ * coalesces concurrent refreshes behind one in-flight promise. Permanent
186
+ * refresh failures delete the stored session and surface INVALID_CREDENTIAL
187
+ * with a re-login hint; transient failures fall back to a still-valid token.
188
+ */
189
+ export class TokenManager {
190
+ options;
191
+ inflight;
192
+ constructor(options) {
193
+ this.options = options;
194
+ this.options = options;
195
+ }
196
+ /**
197
+ * Read the stored session without any refresh side effect. Catalog queries
198
+ * (`listModels`) use this to decide whether the provider is logged in.
199
+ * @returns the stored session, or `undefined` when logged out.
200
+ */
201
+ peek() {
202
+ return this.options.load();
203
+ }
204
+ /**
205
+ * Whether a session is currently stored (cheap; never refreshes).
206
+ * @returns true when logged in.
207
+ */
208
+ async hasSession() {
209
+ return (await this.options.load()) !== undefined;
210
+ }
211
+ /**
212
+ * Resolve a usable session, refreshing proactively or on demand.
213
+ * @param forceRefresh - refresh regardless of expiry (used after a 401).
214
+ * @returns the persisted session to send.
215
+ * @throws LlmError MISSING_CREDENTIAL when logged out, INVALID_CREDENTIAL
216
+ * when the refresh grant is permanently rejected.
217
+ */
218
+ async session(forceRefresh = false) {
219
+ const session = await this.options.load();
220
+ if (session === undefined) {
221
+ throw new LlmError(`dsh-plugin-subscriptions: not logged in to ${this.options.displayName}; `
222
+ + 'log in via Settings → Subscriptions in the dsh web app', 'MISSING_CREDENTIAL');
223
+ }
224
+ if (!forceRefresh && session.expiresAt - Date.now() > this.options.preemptMs) {
225
+ return session;
226
+ }
227
+ this.inflight ??= this.doRefresh(session).finally(() => {
228
+ this.inflight = undefined;
229
+ });
230
+ try {
231
+ return await this.inflight;
232
+ }
233
+ catch (error) {
234
+ if (this.options.isPermanent(error)) {
235
+ await this.options.remove();
236
+ this.options.onRemoved?.();
237
+ throw new LlmError(`${this.options.displayName} login expired or was revoked; log in again via Settings → Subscriptions`, 'INVALID_CREDENTIAL', { cause: error });
238
+ }
239
+ if (!forceRefresh && session.expiresAt > Date.now()) {
240
+ // Transient refresh failure with a still-valid token: use it.
241
+ return session;
242
+ }
243
+ throw error instanceof LlmError
244
+ ? error
245
+ : new LlmError(`${this.options.displayName} token refresh failed`, 'AUTH', { cause: error });
246
+ }
247
+ }
248
+ async doRefresh(session) {
249
+ // A concurrent caller may have refreshed while this one waited: re-read
250
+ // the store and skip the round trip when the stored session is fresh.
251
+ const current = await this.options.load();
252
+ if (current !== undefined
253
+ && current.accessToken !== session.accessToken
254
+ && current.expiresAt - Date.now() > this.options.preemptMs) {
255
+ return current;
256
+ }
257
+ const next = await this.options.refresh(current ?? session);
258
+ await this.options.save(next);
259
+ return next;
260
+ }
261
+ }
262
+ /** How long a discovered catalog is trusted before re-fetching. */
263
+ export const DISCOVERY_TTL_MS = 5 * 60_000;
264
+ /**
265
+ * TTL cache for one provider's discovered model catalog. Only `listModels`
266
+ * populates it (via {@link get}); `resolveModel` reads {@link cached} so it
267
+ * never performs network I/O. A 401 during a fetch must call
268
+ * {@link invalidate}.
269
+ */
270
+ export class ModelCatalogCache {
271
+ ttlMs;
272
+ entry;
273
+ constructor(ttlMs = DISCOVERY_TTL_MS) {
274
+ this.ttlMs = ttlMs;
275
+ }
276
+ /**
277
+ * The cached catalog when fresh, without fetching.
278
+ * @returns the cached models, or `undefined` when absent or stale.
279
+ */
280
+ cached() {
281
+ if (this.entry === undefined || Date.now() - this.entry.at >= this.ttlMs)
282
+ return undefined;
283
+ return this.entry.models;
284
+ }
285
+ /**
286
+ * Return the cached catalog when fresh, otherwise fetch and cache it.
287
+ * @param fetcher - performs the provider's model-list request.
288
+ * @returns the discovered models.
289
+ */
290
+ async get(fetcher) {
291
+ const cached = this.cached();
292
+ if (cached !== undefined)
293
+ return cached;
294
+ const models = await fetcher();
295
+ this.entry = { at: Date.now(), models };
296
+ return models;
297
+ }
298
+ /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
299
+ invalidate() {
300
+ this.entry = undefined;
301
+ }
302
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Grok (X Premium / xAI) subscription provider: OIDC-discovered OAuth against
3
+ * auth.x.ai with the Grok CLI client id, and streaming against the xAI
4
+ * Responses-style endpoint.
5
+ */
6
+ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
7
+ import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
8
+ import type { FlowSpec } from '../auth/oauth-flow.js';
9
+ import type { GrokSession } from '../auth/store.js';
10
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
+ import { TokenManager } from './common.js';
12
+ import type { DiscoveredModel, FetchFn, ModelEntry } from './common.js';
13
+ export declare const GROK_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
14
+ export declare const GROK_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
15
+ export declare const GROK_API_URL = "https://api.x.ai/v1/responses";
16
+ /** Refresh when the access token has less than this much life left. */
17
+ export declare const GROK_PREEMPT_MS: number;
18
+ /** Discovered OIDC endpoints for the xAI authorization server. */
19
+ export interface GrokDiscovery {
20
+ authorizationEndpoint: string;
21
+ tokenEndpoint: string;
22
+ }
23
+ /**
24
+ * Resolve the xAI OIDC endpoints (cached after the first fetch).
25
+ * @returns validated authorization and token endpoints.
26
+ */
27
+ export declare function grokDiscovery(): Promise<GrokDiscovery>;
28
+ /**
29
+ * Build the grok flow facts for the OAuth flow engine (async because the
30
+ * authorize URL comes from OIDC discovery).
31
+ * @returns the flow spec for one attempt.
32
+ */
33
+ export declare function grokFlow(): Promise<FlowSpec>;
34
+ /**
35
+ * Exchange an authorization code for a grok session (form-encoded grant that
36
+ * echoes the PKCE challenge as well as the verifier, per the xAI flow).
37
+ * A 403 here means the X plan lacks the API OAuth entitlement.
38
+ * @param code - the authorization code from the callback.
39
+ * @param verifier - the PKCE verifier minted for the attempt.
40
+ * @param redirectUri - the attempt's redirect URI.
41
+ * @param challenge - the PKCE challenge sent at authorize time.
42
+ * @returns the session to store.
43
+ */
44
+ export declare function exchangeGrokCode(code: string, verifier: string, redirectUri: string, challenge: string): Promise<GrokSession>;
45
+ /**
46
+ * Refresh a grok session (form-encoded grant).
47
+ * @param session - the stored session.
48
+ * @returns the fresh session to store.
49
+ */
50
+ export declare function refreshGrok(session: GrokSession): Promise<GrokSession>;
51
+ /**
52
+ * Whether a grok refresh failure means the login is permanently gone.
53
+ * @param error - the thrown refresh error.
54
+ * @returns true when re-login is the only fix.
55
+ */
56
+ export declare function isGrokPermanentRefreshError(error: unknown): boolean;
57
+ export declare const GROK_MODELS_URL = "https://api.x.ai/v1/models";
58
+ /**
59
+ * Fetch the live grok model list.
60
+ * @param session - the stored session (used as-is; never refreshed here).
61
+ * @param fetchFn - fetch implementation (injectable for tests).
62
+ * @returns discovered chat models in endpoint order (id doubles as the name).
63
+ */
64
+ export declare function fetchGrokModels(session: GrokSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
65
+ /** Constructor dependencies for {@link GrokAdapter}. */
66
+ export interface GrokAdapterOptions {
67
+ models: readonly ModelEntry[];
68
+ streamIdleTimeoutMs: number;
69
+ tokens: TokenManager<GrokSession>;
70
+ /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
71
+ discovery: boolean;
72
+ /** Warning sink for discovery failures that fall back to the static catalog. */
73
+ onWarn?: (message: string) => void;
74
+ /** Fetch implementation for discovery (defaults to global fetch). */
75
+ fetchFn?: FetchFn;
76
+ /** Resolve the attachment service per request; absent means image requests fail loudly. */
77
+ resolveAttachments?: () => AttachmentStore | undefined;
78
+ }
79
+ /** Grok wire adapter: one instance serves the `grok` provider route. */
80
+ export declare class GrokAdapter extends LlmAdapter {
81
+ private readonly options;
82
+ private readonly catalog;
83
+ constructor(options: GrokAdapterOptions);
84
+ providerInfo(provider: string): LlmProviderInfo;
85
+ private staticModels;
86
+ listModels(provider: string): Promise<readonly LlmModelInfo[]>;
87
+ resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
88
+ stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
89
+ private request;
90
+ }