dsh-plugin-subscriptions 0.5.1 → 0.5.3

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 (50) hide show
  1. package/README.md +42 -1
  2. package/README.zh.md +42 -1
  3. package/lib/auth/device-flow.d.ts +0 -9
  4. package/lib/auth/device-flow.js +2 -1
  5. package/lib/auth/rpc.d.ts +44 -13
  6. package/lib/auth/rpc.js +127 -9
  7. package/lib/auth/store.d.ts +75 -17
  8. package/lib/auth/store.js +148 -27
  9. package/lib/client/SubscriptionsSection.d.ts +26 -3
  10. package/lib/client/SubscriptionsSection.js +263 -67
  11. package/lib/client/index.js +11 -0
  12. package/lib/client/locales.d.ts +82 -10
  13. package/lib/client/locales.js +82 -10
  14. package/lib/client.js +837 -223
  15. package/lib/client.js.map +1 -1
  16. package/lib/http.d.ts +114 -0
  17. package/lib/http.js +402 -0
  18. package/lib/index.d.ts +21 -0
  19. package/lib/index.js +1938 -208
  20. package/lib/providers/accounts.d.ts +102 -0
  21. package/lib/providers/accounts.js +123 -0
  22. package/lib/providers/antigravity.d.ts +90 -0
  23. package/lib/providers/antigravity.js +392 -0
  24. package/lib/providers/claude.d.ts +22 -4
  25. package/lib/providers/claude.js +97 -16
  26. package/lib/providers/codex.d.ts +24 -3
  27. package/lib/providers/codex.js +121 -21
  28. package/lib/providers/common.d.ts +17 -0
  29. package/lib/providers/common.js +67 -3
  30. package/lib/providers/copilot.d.ts +23 -4
  31. package/lib/providers/copilot.js +99 -19
  32. package/lib/providers/grok.d.ts +24 -4
  33. package/lib/providers/grok.js +106 -19
  34. package/lib/providers/pool-family.d.ts +56 -0
  35. package/lib/providers/pool-family.js +45 -0
  36. package/lib/providers/pool-health.d.ts +74 -0
  37. package/lib/providers/pool-health.js +148 -0
  38. package/lib/providers/pool-usage.d.ts +57 -0
  39. package/lib/providers/pool-usage.js +130 -0
  40. package/lib/providers/pool.d.ts +107 -0
  41. package/lib/providers/pool.js +371 -0
  42. package/lib/tools/image-generate.d.ts +3 -3
  43. package/lib/tools/image-generate.js +4 -2
  44. package/lib/tools/video-generate.d.ts +2 -2
  45. package/lib/tools/video-generate.js +4 -2
  46. package/lib/tools/x-search.d.ts +2 -2
  47. package/lib/tools/x-search.js +4 -2
  48. package/lib/translate/antigravity.d.ts +110 -0
  49. package/lib/translate/antigravity.js +303 -0
  50. package/package.json +14 -9
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Google Antigravity subscription provider. This is intentionally separate
3
+ * from Gemini CLI: it uses Antigravity OAuth scopes, project discovery, and
4
+ * the daily-cloudcode-pa v1internal request envelope.
5
+ */
6
+ import { errorChain, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
7
+ import { resolveImages } from '../translate/resolved.js';
8
+ import { parseAntigravityResponse, streamAntigravity, toAntigravityRequest, } from '../translate/antigravity.js';
9
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
+ import { proxiedFetch } from '../http.js';
11
+ export const ANTIGRAVITY_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
12
+ export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
13
+ export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v2/userinfo';
14
+ export const ANTIGRAVITY_DEFAULT_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com';
15
+ export const ANTIGRAVITY_PROD_BASE_URL = 'https://cloudcode-pa.googleapis.com';
16
+ export const ANTIGRAVITY_DEFAULT_USER_AGENT = 'antigravity/1.104.0 dsh-plugin-subscriptions';
17
+ export const ANTIGRAVITY_PREEMPT_MS = 5 * 60_000;
18
+ const ANTIGRAVITY_CALLBACK_PATH = '/oauth-callback';
19
+ const ANTIGRAVITY_CONTEXT_WINDOW = 1_024_000;
20
+ const ANTIGRAVITY_DEFAULT_MAX_TOKENS = 65_536;
21
+ /** Antigravity, not Gemini CLI, OAuth scopes from the local reference clients. */
22
+ export const ANTIGRAVITY_SCOPES = [
23
+ 'openid',
24
+ 'https://www.googleapis.com/auth/cloud-platform',
25
+ 'https://www.googleapis.com/auth/userinfo.email',
26
+ 'https://www.googleapis.com/auth/userinfo.profile',
27
+ 'https://www.googleapis.com/auth/cclog',
28
+ 'https://www.googleapis.com/auth/experimentsandconfigs',
29
+ ];
30
+ /** Resolve and validate a user-supplied OAuth config without embedded credentials. */
31
+ export function resolveAntigravityOAuthConfig(config) {
32
+ const clientId = config?.clientId?.trim() || process.env.ANTIGRAVITY_CLIENT_ID?.trim() || '';
33
+ const clientSecret = config?.clientSecret?.trim() || process.env.ANTIGRAVITY_CLIENT_SECRET?.trim();
34
+ if (clientId.length === 0) {
35
+ throw new Error('Antigravity OAuth is not configured; set config.antigravity.clientId or ANTIGRAVITY_CLIENT_ID '
36
+ + '(and clientSecret/ANTIGRAVITY_CLIENT_SECRET when required by the Google OAuth client)');
37
+ }
38
+ return { clientId, ...clientSecret === undefined || clientSecret.length === 0 ? {} : { clientSecret } };
39
+ }
40
+ /** Normalize the configured API origin and reject paths/credentials. */
41
+ export function antigravityBaseURL(value) {
42
+ const parsed = new URL(value?.trim() || ANTIGRAVITY_DEFAULT_BASE_URL);
43
+ if (parsed.protocol !== 'https:' || parsed.username.length > 0 || parsed.password.length > 0) {
44
+ throw new Error('config.antigravity.baseURL must be an HTTPS origin without credentials');
45
+ }
46
+ if (parsed.pathname !== '/' || parsed.search.length > 0 || parsed.hash.length > 0) {
47
+ throw new Error('config.antigravity.baseURL must not contain a path, query, or fragment');
48
+ }
49
+ return parsed.origin;
50
+ }
51
+ /** Google authorization-code + PKCE flow for Antigravity. */
52
+ export function antigravityFlow(oauth) {
53
+ return {
54
+ callbackPath: ANTIGRAVITY_CALLBACK_PATH,
55
+ listen: { host: 'localhost', ports: [51121, 0] },
56
+ timeoutMs: 5 * 60_000,
57
+ buildAuthorizeUrl({ redirectUri, state, pkce }) {
58
+ const params = new URLSearchParams({
59
+ access_type: 'offline',
60
+ client_id: oauth.clientId,
61
+ code_challenge: pkce.challenge,
62
+ code_challenge_method: 'S256',
63
+ include_granted_scopes: 'true',
64
+ prompt: 'consent',
65
+ redirect_uri: redirectUri,
66
+ response_type: 'code',
67
+ scope: ANTIGRAVITY_SCOPES.join(' '),
68
+ state,
69
+ });
70
+ return `${ANTIGRAVITY_AUTHORIZE_URL}?${params.toString()}`;
71
+ },
72
+ };
73
+ }
74
+ /** Shared Antigravity API headers. */
75
+ export function antigravityHeaders(accessToken, userAgent = ANTIGRAVITY_DEFAULT_USER_AGENT) {
76
+ return {
77
+ 'authorization': `Bearer ${accessToken}`,
78
+ 'content-type': 'application/json',
79
+ 'user-agent': userAgent,
80
+ };
81
+ }
82
+ /** POST a v1internal JSON method and classify non-2xx responses. */
83
+ async function callInternal(method, body, accessToken, runtime, fetchFn, signal) {
84
+ const response = await fetchFn(`${antigravityBaseURL(runtime.baseURL)}/v1internal:${method}`, {
85
+ method: 'POST',
86
+ headers: antigravityHeaders(accessToken, runtime.userAgent),
87
+ body: JSON.stringify(body),
88
+ ...signal === undefined ? {} : { signal },
89
+ });
90
+ if (!response.ok)
91
+ throw await httpLlmError(response, `Antigravity ${method}`);
92
+ return response.json();
93
+ }
94
+ function projectIdOf(value) {
95
+ if (typeof value === 'string' && value.length > 0)
96
+ return value;
97
+ if (typeof value === 'object' && value !== null) {
98
+ const id = value.id;
99
+ if (typeof id === 'string' && id.length > 0)
100
+ return id;
101
+ }
102
+ return undefined;
103
+ }
104
+ /** Read (and, when enabled, initialize) the Antigravity project/account. */
105
+ export async function discoverAntigravityAccount(accessToken, runtime = {}, fetchFn = proxiedFetch) {
106
+ const metadata = { ideType: 'ANTIGRAVITY', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' };
107
+ const load = await callInternal('loadCodeAssist', { metadata }, accessToken, runtime, fetchFn);
108
+ let projectId = projectIdOf(load.cloudaicompanionProject);
109
+ if (projectId === undefined && runtime.onboard !== false) {
110
+ const tierId = load.allowedTiers?.find(tier => tier.isDefault)?.id ?? 'LEGACY';
111
+ const onboardBody = { tierId, metadata };
112
+ for (let attempt = 0; attempt < 10; attempt++) {
113
+ const result = await callInternal('onboardUser', onboardBody, accessToken, runtime, fetchFn);
114
+ if (result.done === true) {
115
+ projectId = projectIdOf(result.response?.cloudaicompanionProject);
116
+ break;
117
+ }
118
+ await new Promise(resolve => setTimeout(resolve, 1_000));
119
+ }
120
+ }
121
+ if (projectId === undefined) {
122
+ throw new Error('Antigravity account has no Cloud AI Companion project; open Antigravity and complete onboarding, then log in again');
123
+ }
124
+ let account;
125
+ try {
126
+ const profileResponse = await fetchFn(ANTIGRAVITY_USERINFO_URL, {
127
+ headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
128
+ });
129
+ if (profileResponse.ok) {
130
+ const profile = await profileResponse.json();
131
+ if (typeof profile.email === 'string' && profile.email.length > 0)
132
+ account = profile.email;
133
+ }
134
+ }
135
+ catch {
136
+ // Identity is display-only; project discovery is the login boundary.
137
+ }
138
+ const plan = load.paidTier?.name ?? load.paidTier?.id ?? load.currentTier?.name ?? load.currentTier?.id;
139
+ return {
140
+ projectId,
141
+ ...account === undefined ? {} : { account },
142
+ ...typeof plan !== 'string' || plan.length === 0 ? {} : { plan },
143
+ };
144
+ }
145
+ function sessionFromTokens(tokens, account, fallback) {
146
+ if (typeof tokens.access_token !== 'string' || tokens.access_token.length === 0) {
147
+ throw new Error('Antigravity token endpoint returned no access token');
148
+ }
149
+ const refreshToken = tokens.refresh_token ?? fallback?.refreshToken;
150
+ if (refreshToken === undefined || refreshToken.length === 0) {
151
+ throw new Error('Antigravity token endpoint returned no refresh token; revoke the app grant and log in again');
152
+ }
153
+ if (typeof tokens.expires_in !== 'number' || tokens.expires_in <= 0) {
154
+ throw new Error('Antigravity token endpoint returned no usable expiry');
155
+ }
156
+ return {
157
+ accessToken: tokens.access_token,
158
+ refreshToken,
159
+ expiresAt: Date.now() + tokens.expires_in * 1000,
160
+ projectId: account.projectId,
161
+ ...tokens.scope === undefined ? {} : { scopes: tokens.scope },
162
+ ...account.account === undefined ? {} : { account: account.account },
163
+ ...account.plan === undefined ? {} : { plan: account.plan },
164
+ };
165
+ }
166
+ /** Exchange a Google OAuth authorization code and discover the Antigravity project. */
167
+ export async function exchangeAntigravityCode(code, verifier, redirectUri, oauth, runtime = {}, fetchFn = proxiedFetch) {
168
+ const body = new URLSearchParams({
169
+ grant_type: 'authorization_code',
170
+ code,
171
+ redirect_uri: redirectUri,
172
+ client_id: oauth.clientId,
173
+ code_verifier: verifier,
174
+ ...oauth.clientSecret === undefined ? {} : { client_secret: oauth.clientSecret },
175
+ });
176
+ const response = await fetchFn(ANTIGRAVITY_TOKEN_URL, {
177
+ method: 'POST',
178
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
179
+ body: body.toString(),
180
+ });
181
+ if (!response.ok)
182
+ throw await oauthEndpointError(response, 'Antigravity');
183
+ const tokens = await response.json();
184
+ if (typeof tokens.access_token !== 'string')
185
+ throw new Error('Antigravity token endpoint returned no access token');
186
+ const account = await discoverAntigravityAccount(tokens.access_token, runtime, fetchFn);
187
+ return sessionFromTokens(tokens, account);
188
+ }
189
+ /** Refresh a stored Antigravity Google token, preserving project/account metadata. */
190
+ export async function refreshAntigravity(session, oauth, fetchFn = proxiedFetch) {
191
+ const body = new URLSearchParams({
192
+ grant_type: 'refresh_token',
193
+ refresh_token: session.refreshToken,
194
+ client_id: oauth.clientId,
195
+ ...oauth.clientSecret === undefined ? {} : { client_secret: oauth.clientSecret },
196
+ });
197
+ const response = await fetchFn(ANTIGRAVITY_TOKEN_URL, {
198
+ method: 'POST',
199
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
200
+ body: body.toString(),
201
+ });
202
+ if (!response.ok)
203
+ throw await oauthEndpointError(response, 'Antigravity');
204
+ return sessionFromTokens(await response.json(), {
205
+ projectId: session.projectId,
206
+ ...session.account === undefined ? {} : { account: session.account },
207
+ ...session.plan === undefined ? {} : { plan: session.plan },
208
+ }, session);
209
+ }
210
+ /** Refresh failures that require a fresh Google consent grant. */
211
+ export function isAntigravityPermanentRefreshError(error) {
212
+ return error instanceof OAuthEndpointError
213
+ && (error.status === 400 || error.status === 401 || error.status === 403)
214
+ && (error.oauthCode === 'invalid_grant' || error.status !== 400);
215
+ }
216
+ /** Fetch the authenticated account's live Antigravity model catalog. */
217
+ export async function fetchAntigravityModels(session, runtime = {}, fetchFn = proxiedFetch) {
218
+ const payload = await callInternal('fetchAvailableModels', { project: session.projectId }, session.accessToken, runtime, fetchFn);
219
+ if (typeof payload.models !== 'object' || payload.models === null) {
220
+ throw new Error('Antigravity models endpoint returned no models object');
221
+ }
222
+ const models = Object.entries(payload.models).map(([id, model]) => ({
223
+ id,
224
+ name: model.displayName ?? id.split('-').map(word => word.length === 0 ? word : word[0].toUpperCase() + word.slice(1)).join(' '),
225
+ ...model.description === undefined ? {} : { description: model.description },
226
+ contextWindow: model.inputTokenLimit ?? model.maxInputTokens ?? ANTIGRAVITY_CONTEXT_WINDOW,
227
+ inputModalities: ['text', 'image'],
228
+ }));
229
+ if (models.length === 0)
230
+ throw new Error('Antigravity models endpoint returned an empty catalog');
231
+ return models;
232
+ }
233
+ function resetTime(value) {
234
+ if (typeof value !== 'string')
235
+ return undefined;
236
+ const parsed = Date.parse(value);
237
+ return Number.isFinite(parsed) ? parsed : undefined;
238
+ }
239
+ function usageWindow(kind, scope, quota) {
240
+ const remaining = quota?.remainingFraction;
241
+ if (typeof remaining !== 'number' || !Number.isFinite(remaining))
242
+ return undefined;
243
+ const resetsAt = resetTime(quota?.resetTime);
244
+ return {
245
+ kind,
246
+ scope,
247
+ usedPercent: Math.max(0, Math.min(100, (1 - remaining) * 100)),
248
+ ...resetsAt === undefined ? {} : { resetsAt },
249
+ };
250
+ }
251
+ /** Fetch plan and per-model quota windows when the upstream exposes them. */
252
+ export async function fetchAntigravityUsage(session, runtime = {}, fetchFn = proxiedFetch, signal) {
253
+ const metadata = { ideType: 'ANTIGRAVITY', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' };
254
+ const [models, account] = await Promise.all([
255
+ callInternal('fetchAvailableModels', { project: session.projectId }, session.accessToken, runtime, fetchFn, signal),
256
+ callInternal('loadCodeAssist', { metadata }, session.accessToken, runtime, fetchFn, signal),
257
+ ]);
258
+ const windows = [];
259
+ for (const [modelId, model] of Object.entries(models.models ?? {})) {
260
+ const ordinary = usageWindow('other', modelId, model.quotaInfo);
261
+ const weekly = usageWindow('weekly', modelId, model.weeklyQuotaInfo ?? model.weeklyQuota);
262
+ if (ordinary !== undefined)
263
+ windows.push(ordinary);
264
+ if (weekly !== undefined)
265
+ windows.push(weekly);
266
+ }
267
+ const plan = account.paidTier?.name ?? account.paidTier?.id
268
+ ?? account.currentTier?.name ?? account.currentTier?.id ?? session.plan;
269
+ const credits = account.paidTier?.availableCredits?.[0]?.creditAmount;
270
+ const displayPlan = credits === undefined ? plan : `${plan ?? 'Antigravity'} · ${String(credits)} credits`;
271
+ return {
272
+ supported: true,
273
+ windows,
274
+ ...displayPlan === undefined ? {} : { plan: displayPlan },
275
+ };
276
+ }
277
+ /** URL for either v1internal generation transport. */
278
+ export function antigravityGenerateURL(baseURL, stream) {
279
+ return `${antigravityBaseURL(baseURL)}/v1internal:${stream ? 'streamGenerateContent?alt=sse' : 'generateContent'}`;
280
+ }
281
+ /** Forward one already-built payload to generateContent or streamGenerateContent. */
282
+ export async function requestAntigravityContent(session, payload, stream, runtime = {}, fetchFn = proxiedFetch, signal) {
283
+ return fetchFn(antigravityGenerateURL(runtime.baseURL, stream), {
284
+ method: 'POST',
285
+ headers: {
286
+ ...antigravityHeaders(session.accessToken, runtime.userAgent),
287
+ accept: stream ? 'text/event-stream' : 'application/json',
288
+ },
289
+ body: JSON.stringify(payload),
290
+ ...signal === undefined ? {} : { signal },
291
+ });
292
+ }
293
+ /** DSH provider adapter for the `antigravity` route. */
294
+ export class AntigravityAdapter extends LlmAdapter {
295
+ options;
296
+ catalog;
297
+ constructor(options) {
298
+ super();
299
+ this.options = options;
300
+ this.catalog = new ModelCatalogCache(options.catalogStore);
301
+ }
302
+ providerInfo(provider) {
303
+ return { id: provider, name: 'Google Antigravity' };
304
+ }
305
+ staticModels(provider) {
306
+ return this.options.models.map(model => ({
307
+ provider,
308
+ id: model.id,
309
+ name: model.name ?? model.id,
310
+ inputModalities: model.inputModalities ?? ['text', 'image'],
311
+ }));
312
+ }
313
+ fetchCatalog() {
314
+ return this.options.tokens.session().then(session => fetchAntigravityModels(session, this.options.runtime, this.options.fetchFn));
315
+ }
316
+ async listModels(provider) {
317
+ if (await this.options.tokens.peek() === undefined)
318
+ return [];
319
+ if (!this.options.discovery)
320
+ return this.staticModels(provider);
321
+ try {
322
+ const models = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
323
+ return models.map(model => ({
324
+ provider,
325
+ id: model.id,
326
+ name: model.name,
327
+ ...model.description === undefined ? {} : { description: model.description },
328
+ inputModalities: model.inputModalities ?? ['text', 'image'],
329
+ }));
330
+ }
331
+ catch (error) {
332
+ if (isMissingOrInvalidCredential(error))
333
+ return [];
334
+ this.options.onWarn?.(`Antigravity model discovery failed; using the built-in catalog (${errorChain(error)})`);
335
+ return this.staticModels(provider);
336
+ }
337
+ }
338
+ async discovered(model) {
339
+ if (!this.options.discovery)
340
+ return undefined;
341
+ const models = await this.catalog.resolve(() => this.fetchCatalog());
342
+ return models?.find(entry => entry.id === model);
343
+ }
344
+ async resolveModel(provider, model) {
345
+ const discovered = await this.discovered(model);
346
+ const configured = this.options.models.find(entry => entry.id === model);
347
+ return {
348
+ provider,
349
+ id: model,
350
+ name: discovered?.name ?? configured?.name ?? model,
351
+ ...discovered?.description === undefined ? {} : { description: discovered.description },
352
+ inputModalities: discovered?.inputModalities ?? configured?.inputModalities ?? ['text', 'image'],
353
+ context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? ANTIGRAVITY_CONTEXT_WINDOW },
354
+ defaultMaxTokens: configured?.maxTokens ?? ANTIGRAVITY_DEFAULT_MAX_TOKENS,
355
+ };
356
+ }
357
+ async *stream(options) {
358
+ const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
359
+ try {
360
+ let session = await this.options.tokens.session();
361
+ const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), watchdog.signal);
362
+ const payload = toAntigravityRequest(options, messages, session.projectId);
363
+ let response = await requestAntigravityContent(session, payload, true, this.options.runtime, this.options.fetchFn, watchdog.signal);
364
+ if (response.status === 401) {
365
+ this.catalog.invalidate();
366
+ session = await this.options.tokens.session(true);
367
+ response = await requestAntigravityContent(session, payload, true, this.options.runtime, this.options.fetchFn, watchdog.signal);
368
+ }
369
+ if (!response.ok)
370
+ throw await httpLlmError(response, 'Antigravity API');
371
+ if (response.body === null) {
372
+ throw new LlmError('Antigravity API returned no response body', EMPTY_RESPONSE_CODE);
373
+ }
374
+ yield* streamAntigravity(response.body, () => { watchdog.pulse(); });
375
+ }
376
+ catch (error) {
377
+ throw mapFetchFailure('Antigravity API', error, watchdog, options.signal);
378
+ }
379
+ finally {
380
+ watchdog.stop();
381
+ }
382
+ }
383
+ /** Non-stream forwarding seam used by tests and future DSH complete calls. */
384
+ async generate(options) {
385
+ const session = await this.options.tokens.session();
386
+ const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), options.signal);
387
+ const response = await requestAntigravityContent(session, toAntigravityRequest(options, messages, session.projectId), false, this.options.runtime, this.options.fetchFn, options.signal);
388
+ if (!response.ok)
389
+ throw await httpLlmError(response, 'Antigravity API');
390
+ return parseAntigravityResponse(await response.json());
391
+ }
392
+ }
@@ -7,9 +7,10 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
7
7
  import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
8
8
  import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { ClaudeSession } from '../auth/store.js';
10
+ import type { PoolAdapter } from './pool.js';
10
11
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
12
  import type { TranslatableMessage } from '../translate/resolved.js';
12
- import { TokenManager } from './common.js';
13
+ import { AccountTokenManager } from './accounts.js';
13
14
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
14
15
  export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
15
16
  export declare const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
@@ -64,13 +65,15 @@ export declare const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usa
64
65
  * @returns the mapped usage snapshot.
65
66
  */
66
67
  export declare function fetchClaudeUsage(session: ClaudeSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<ProviderUsage>;
67
- /** Fetch the live model catalog from the subscription endpoint. */
68
- export declare function fetchClaudeModels(session: ClaudeSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
68
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
69
+ export declare function fetchClaudeModels(session: ClaudeSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<DiscoveredModel[]>;
69
70
  /** Constructor dependencies for {@link ClaudeAdapter}. */
70
71
  export interface ClaudeAdapterOptions {
71
72
  models: readonly ModelEntry[];
72
73
  streamIdleTimeoutMs: number;
73
- tokens: TokenManager<ClaudeSession>;
74
+ tokens: AccountTokenManager<ClaudeSession>;
75
+ /** Late-bound pool facade (wired after adapter construction); pools list under their first member's provider. */
76
+ pool?: () => PoolAdapter | undefined;
74
77
  /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
75
78
  discovery: boolean;
76
79
  fetchFn?: FetchFn;
@@ -102,15 +105,30 @@ export declare function claudeRequestBody(options: GenerateOptions, messages: re
102
105
  export declare class ClaudeAdapter extends LlmAdapter {
103
106
  private readonly options;
104
107
  private readonly catalog;
108
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
109
+ private readonly accountCatalogs;
110
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
111
+ private catalogOwner;
105
112
  constructor(options: ClaudeAdapterOptions);
106
113
  private fetchCatalog;
114
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
115
+ clearAccountCatalog(account?: string): void;
116
+ /** Persisted cache for the default account; a throwaway cache for any other. */
117
+ private catalogFor;
107
118
  private discovered;
108
119
  private staticModels;
109
120
  providerInfo(provider: string): LlmProviderInfo;
110
121
  providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy | undefined;
111
122
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
123
+ /** The provider's own catalog: union of every account, or one account when named. */
124
+ listOwnModels(provider: string, account?: string, signal?: AbortSignal): Promise<readonly LlmModelInfo[]>;
112
125
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
126
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
127
+ resolveOwnModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
113
128
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
129
+ /** Pool seam: stream through one specific account instead of the default. */
130
+ streamAccount(options: GenerateOptions, account: string): AsyncIterable<StreamChunk>;
131
+ private streamCore;
114
132
  /**
115
133
  * `display: 'summarized'` is set explicitly on both shapes: `adaptive`-type
116
134
  * models default to `display: 'omitted'`, which returns thinking blocks with
@@ -7,7 +7,9 @@ import { execFileSync } from 'node:child_process';
7
7
  import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
8
8
  import { resolveImages } from '../translate/resolved.js';
9
9
  import { markMessageCache, streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverAcrossAccounts, discoverOrRetryAuth, isDiscoveryAborted, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, } from './common.js';
11
+ import { AccountTokenManager, DISCOVERY_TIMEOUT_MS, unionAccountCatalogs } from './accounts.js';
12
+ import { proxiedFetch } from '../http.js';
11
13
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
12
14
  export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
13
15
  export const CLAUDE_TOKEN_URL = 'https://claude.ai/v1/oauth/token';
@@ -79,7 +81,7 @@ export const claudeFlow = {
79
81
  /** Best-effort account profile; login must not fail when this does. */
80
82
  async function fetchClaudeProfile(accessToken) {
81
83
  try {
82
- const response = await fetch(CLAUDE_PROFILE_URL, {
84
+ const response = await proxiedFetch(CLAUDE_PROFILE_URL, {
83
85
  headers: { authorization: `Bearer ${accessToken}` },
84
86
  });
85
87
  if (!response.ok)
@@ -129,7 +131,7 @@ async function claudeSession(tokens, fallbackRefreshToken, withProfile) {
129
131
  * @returns the session to store.
130
132
  */
131
133
  export async function exchangeClaudeCode(code, verifier, redirectUri, state) {
132
- const response = await fetch(CLAUDE_TOKEN_URL, {
134
+ const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
133
135
  method: 'POST',
134
136
  headers: { 'content-type': 'application/json' },
135
137
  body: JSON.stringify({
@@ -151,7 +153,7 @@ export async function exchangeClaudeCode(code, verifier, redirectUri, state) {
151
153
  * @returns the fresh session to store.
152
154
  */
153
155
  export async function refreshClaude(session) {
154
- const response = await fetch(CLAUDE_TOKEN_URL, {
156
+ const response = await proxiedFetch(CLAUDE_TOKEN_URL, {
155
157
  method: 'POST',
156
158
  headers: { 'content-type': 'application/json' },
157
159
  body: JSON.stringify({
@@ -237,7 +239,7 @@ function claudeLimitsWindows(value) {
237
239
  * @param signal - caller cancellation from the RPC transport.
238
240
  * @returns the mapped usage snapshot.
239
241
  */
240
- export async function fetchClaudeUsage(session, fetchFn = fetch, signal) {
242
+ export async function fetchClaudeUsage(session, fetchFn = proxiedFetch, signal) {
241
243
  const response = await fetchFn(CLAUDE_USAGE_URL, {
242
244
  headers: {
243
245
  'authorization': `Bearer ${session.accessToken}`,
@@ -287,8 +289,8 @@ function claudeReasoning(capabilities) {
287
289
  .map(level => ({ id: ReasoningEffortId(level), name: level[0].toUpperCase() + level.slice(1) }));
288
290
  return efforts.length > 0 ? { efforts } : undefined;
289
291
  }
290
- /** Fetch the live model catalog from the subscription endpoint. */
291
- export async function fetchClaudeModels(session, fetchFn = fetch) {
292
+ /** Fetch the live model catalog from the subscription endpoint. `signal` cancels the request. */
293
+ export async function fetchClaudeModels(session, fetchFn = proxiedFetch, signal) {
292
294
  const response = await fetchFn(CLAUDE_MODELS_URL, {
293
295
  headers: {
294
296
  'authorization': `Bearer ${session.accessToken}`,
@@ -297,6 +299,7 @@ export async function fetchClaudeModels(session, fetchFn = fetch) {
297
299
  'anthropic-dangerous-direct-browser-access': 'true',
298
300
  'accept': 'application/json',
299
301
  },
302
+ ...signal === undefined ? {} : { signal },
300
303
  });
301
304
  if (!response.ok)
302
305
  throw await httpLlmError(response, 'claude models API');
@@ -367,19 +370,56 @@ export function claudeRequestBody(options, messages, maxTokens, thinking, effort
367
370
  export class ClaudeAdapter extends LlmAdapter {
368
371
  options;
369
372
  catalog;
373
+ /** In-memory catalogs for non-default accounts (the persisted cache is the default's). */
374
+ accountCatalogs = new Map();
375
+ /** Account whose snapshot currently lives in {@link catalog}; cleared on default change. */
376
+ catalogOwner;
370
377
  constructor(options) {
371
378
  super();
372
379
  this.options = options;
373
380
  this.catalog = new ModelCatalogCache(options.catalogStore);
374
381
  }
375
- async fetchCatalog() {
376
- return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
382
+ async fetchCatalog(account, signal) {
383
+ return fetchClaudeModels(await this.options.tokens.session(account), this.options.fetchFn, signal);
384
+ }
385
+ /** Drop cached catalogs after login/logout so the next list does not reuse a stale plan. */
386
+ clearAccountCatalog(account) {
387
+ if (account === undefined)
388
+ this.accountCatalogs.clear();
389
+ else
390
+ this.accountCatalogs.delete(account);
391
+ if (account === undefined || this.catalogOwner === account || this.catalogOwner === undefined) {
392
+ this.catalogOwner = undefined;
393
+ this.catalog.invalidate();
394
+ }
395
+ }
396
+ /** Persisted cache for the default account; a throwaway cache for any other. */
397
+ async catalogFor(account) {
398
+ const defaultKey = await this.options.tokens.defaultAccount();
399
+ const key = account ?? defaultKey;
400
+ if (key === undefined || key === defaultKey) {
401
+ if (this.catalogOwner !== undefined && this.catalogOwner !== defaultKey) {
402
+ this.catalog.invalidate();
403
+ }
404
+ this.catalogOwner = defaultKey;
405
+ return this.catalog;
406
+ }
407
+ let cache = this.accountCatalogs.get(key);
408
+ if (cache === undefined) {
409
+ cache = new ModelCatalogCache();
410
+ this.accountCatalogs.set(key, cache);
411
+ }
412
+ return cache;
377
413
  }
378
414
  async discovered(model) {
379
415
  if (!this.options.discovery)
380
416
  return undefined;
381
- const models = await this.catalog.resolve(() => this.fetchCatalog());
382
- return models?.find(entry => entry.id === model);
417
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
418
+ return discoverAcrossAccounts(accounts, async (account) => {
419
+ const catalog = await this.catalogFor(account);
420
+ const models = await catalog.resolve(() => this.fetchCatalog(account));
421
+ return models?.find(entry => entry.id === model);
422
+ });
383
423
  }
384
424
  staticModels(provider) {
385
425
  return this.options.models.map(model => ({
@@ -406,12 +446,31 @@ export class ClaudeAdapter extends LlmAdapter {
406
446
  }, `claude: provider "${provider}" retryPolicy`);
407
447
  }
408
448
  async listModels(provider) {
409
- if (await this.options.tokens.peek() === undefined)
449
+ const own = await this.listOwnModels(provider);
450
+ const pool = this.options.pool?.();
451
+ if (pool === undefined)
452
+ return own;
453
+ const extra = await pool.modelsForProvider(provider);
454
+ const seen = new Set(own.map(model => model.id));
455
+ // Account pools reuse the catalog row; only configured tiers are extra.
456
+ return [...own, ...extra.filter(model => !seen.has(model.id))];
457
+ }
458
+ /** The provider's own catalog: union of every account, or one account when named. */
459
+ async listOwnModels(provider, account, signal) {
460
+ if (account === undefined) {
461
+ const accounts = (await this.options.tokens.list()).map(entry => entry.key);
462
+ if (accounts.length === 0)
463
+ return [];
464
+ return unionAccountCatalogs(accounts, (key, accountSignal) => this.listOwnModels(provider, key, accountSignal), { timeoutMs: DISCOVERY_TIMEOUT_MS, ...signal === undefined ? {} : { signal } });
465
+ }
466
+ if (!await this.options.tokens.hasSession(account)) {
410
467
  return [];
468
+ }
411
469
  if (!this.options.discovery)
412
470
  return this.staticModels(provider);
471
+ const catalog = await this.catalogFor(account);
413
472
  try {
414
- const models = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
473
+ const models = await discoverOrRetryAuth(force => this.options.tokens.session(account, force), catalog, () => catalog.get(() => this.fetchCatalog(account, signal)));
415
474
  return models.map(model => ({
416
475
  provider,
417
476
  id: model.id,
@@ -420,6 +479,8 @@ export class ClaudeAdapter extends LlmAdapter {
420
479
  }));
421
480
  }
422
481
  catch (error) {
482
+ if (isDiscoveryAborted(error, signal))
483
+ throw error;
423
484
  if (isMissingOrInvalidCredential(error))
424
485
  return [];
425
486
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
@@ -427,6 +488,14 @@ export class ClaudeAdapter extends LlmAdapter {
427
488
  }
428
489
  }
429
490
  async resolveModel(provider, model) {
491
+ const pool = this.options.pool?.();
492
+ if (pool !== undefined && await pool.owns(provider, model)) {
493
+ return pool.resolveModel(provider, model);
494
+ }
495
+ return this.resolveOwnModel(provider, model);
496
+ }
497
+ /** Capability resolution of the provider's own models (the pool resolves members here). */
498
+ async resolveOwnModel(provider, model) {
430
499
  const disc = await this.discovered(model);
431
500
  const configured = this.options.models.find(entry => entry.id === model);
432
501
  const reasoning = disc?.reasoning;
@@ -443,12 +512,24 @@ export class ClaudeAdapter extends LlmAdapter {
443
512
  };
444
513
  }
445
514
  async *stream(options) {
515
+ const pool = this.options.pool?.();
516
+ if (pool !== undefined && await pool.owns(options.provider, options.model)) {
517
+ yield* pool.stream(options);
518
+ return;
519
+ }
520
+ yield* this.streamCore(options);
521
+ }
522
+ /** Pool seam: stream through one specific account instead of the default. */
523
+ streamAccount(options, account) {
524
+ return this.streamCore(options, account);
525
+ }
526
+ async *streamCore(options, account) {
446
527
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
447
528
  try {
448
- let session = await this.options.tokens.session();
529
+ let session = await this.options.tokens.session(account);
449
530
  let response = await this.request(options, session, watchdog.signal);
450
531
  if (response.status === 401) {
451
- session = await this.options.tokens.session(true);
532
+ session = await this.options.tokens.session(account, true);
452
533
  response = await this.request(options, session, watchdog.signal);
453
534
  }
454
535
  if (!response.ok)
@@ -494,7 +575,7 @@ export class ClaudeAdapter extends LlmAdapter {
494
575
  ? String(options.reasoningEffort)
495
576
  : undefined;
496
577
  const body = claudeRequestBody(options, messages, maxTokens, thinking, effort);
497
- return fetch(CLAUDE_API_URL, {
578
+ return proxiedFetch(CLAUDE_API_URL, {
498
579
  method: 'POST',
499
580
  headers: {
500
581
  'authorization': `Bearer ${session.accessToken}`,