dsh-plugin-subscriptions 0.4.1 → 0.4.2

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.
@@ -3,15 +3,17 @@
3
3
  * platform.claude.com with the Claude Code client id, and streaming against
4
4
  * the Anthropic Messages API with the Claude Code identity headers.
5
5
  */
6
- import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
7
8
  import { resolveImages } from '../translate/resolved.js';
8
9
  import { streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
9
- import { httpLlmError, idleWatchdog, mapFetchFailure, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
11
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
11
- export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
12
- export const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
12
+ export const CLAUDE_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
13
+ export const CLAUDE_TOKEN_URL = 'https://claude.ai/v1/oauth/token';
13
14
  export const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages?beta=true';
14
15
  export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
16
+ export const CLAUDE_MODELS_URL = 'https://api.anthropic.com/v1/models?beta=true';
15
17
  const CLAUDE_SCOPE = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
16
18
  const CLAUDE_CALLBACK_PATH = '/callback';
17
19
  const CLAUDE_CONTEXT_WINDOW = 200_000;
@@ -23,19 +25,39 @@ export const CLAUDE_PREEMPT_MS = 5 * 60_000;
23
25
  * so these headers impersonate the CLI; the harness attribution user-agent
24
26
  * cannot be sent here (one user-agent slot, and the CLI's wins).
25
27
  */
26
- const CLAUDE_CLI_USER_AGENT = 'claude-cli/2.1.97 (external, cli)';
27
- const CLAUDE_BETA_FLAGS = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27';
28
+ export const CLAUDE_CLI_FALLBACK_VERSION = '2.1.234';
29
+ export function detectClaudeVersion() {
30
+ try {
31
+ const raw = execFileSync('claude', ['--version'], { timeout: 3000, encoding: 'utf8' });
32
+ const match = raw.match(/^(\d+\.\d+\.\d+)/);
33
+ if (match)
34
+ return match[1];
35
+ }
36
+ catch { }
37
+ return CLAUDE_CLI_FALLBACK_VERSION;
38
+ }
39
+ const CLAUDE_CLI_USER_AGENT = `claude-cli/${detectClaudeVersion()} (external, cli)`;
40
+ export const CLAUDE_BETA_FALLBACK = [
41
+ 'claude-code-20250219',
42
+ 'oauth-2025-04-20',
43
+ 'interleaved-thinking-2025-05-14',
44
+ 'context-management-2025-06-27',
45
+ 'effort-2025-11-24',
46
+ 'compact-2026-01-12',
47
+ 'files-api-2025-04-14',
48
+ ].join(',');
49
+ const CLAUDE_BETA_FLAGS = CLAUDE_BETA_FALLBACK;
28
50
  /** Static claude flow facts for the OAuth flow engine. */
29
51
  export const claudeFlow = {
30
52
  callbackPath: CLAUDE_CALLBACK_PATH,
31
53
  // The redirect URI embeds the port, so it must be an ephemeral one.
32
- listen: { host: 'localhost', ports: [0] },
54
+ listen: { host: '127.0.0.1', ports: [0] },
33
55
  buildAuthorizeUrl({ redirectUri, state, pkce }) {
34
56
  const params = new URLSearchParams({
35
57
  code: 'true',
36
58
  client_id: CLAUDE_CLIENT_ID,
37
59
  response_type: 'code',
38
- redirect_uri: redirectUri,
60
+ redirect_uri: 'https://platform.claude.com/oauth/code/callback',
39
61
  scope: CLAUDE_SCOPE,
40
62
  code_challenge: pkce.challenge,
41
63
  code_challenge_method: 'S256',
@@ -236,24 +258,88 @@ export async function fetchClaudeUsage(session, fetchFn = fetch, signal) {
236
258
  }
237
259
  return { supported: true, windows };
238
260
  }
261
+ function claudeThinkingType(capabilities) {
262
+ const types = capabilities?.thinking?.types;
263
+ if (types?.enabled?.supported === true)
264
+ return 'enabled';
265
+ if (types?.adaptive?.supported === true)
266
+ return 'adaptive';
267
+ return undefined;
268
+ }
269
+ /** Effort levels in display order; a model exposes only the ones it advertises as supported. */
270
+ const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
271
+ function claudeReasoning(capabilities) {
272
+ const effort = capabilities?.effort;
273
+ if (effort?.supported !== true)
274
+ return undefined;
275
+ const efforts = CLAUDE_EFFORT_LEVELS
276
+ .filter(level => effort[level]?.supported === true)
277
+ .map(level => ({ id: ReasoningEffortId(level), name: level[0].toUpperCase() + level.slice(1) }));
278
+ return efforts.length > 0 ? { efforts } : undefined;
279
+ }
280
+ /** Fetch the live model catalog from the subscription endpoint. */
281
+ export async function fetchClaudeModels(session, fetchFn = fetch) {
282
+ const response = await fetchFn(CLAUDE_MODELS_URL, {
283
+ headers: {
284
+ 'authorization': `Bearer ${session.accessToken}`,
285
+ 'anthropic-version': '2023-06-01',
286
+ 'user-agent': CLAUDE_CLI_USER_AGENT,
287
+ 'anthropic-dangerous-direct-browser-access': 'true',
288
+ 'accept': 'application/json',
289
+ },
290
+ });
291
+ if (!response.ok)
292
+ throw await httpLlmError(response, 'claude models API');
293
+ const payload = await response.json();
294
+ if (!Array.isArray(payload.data)) {
295
+ throw new Error('claude models API returned an invalid catalog');
296
+ }
297
+ const models = payload.data
298
+ .filter((m) => typeof m.id === 'string')
299
+ .map((m) => {
300
+ const thinkingType = claudeThinkingType(m.capabilities);
301
+ const reasoning = claudeReasoning(m.capabilities);
302
+ return {
303
+ id: m.id,
304
+ name: m.display_name ?? m.id,
305
+ ...thinkingType === undefined ? {} : { thinkingType },
306
+ ...reasoning === undefined ? {} : { reasoning },
307
+ };
308
+ });
309
+ if (models.length === 0) {
310
+ throw new Error('claude models API returned an empty catalog');
311
+ }
312
+ return models;
313
+ }
314
+ /**
315
+ * Claude Code's own SDK retry shape: exponential backoff starting at 1s,
316
+ * doubling per attempt, capped at 60s, plus jitter. `maxRetries` is the
317
+ * count of retries after the first attempt (Claude Code defaults to 10).
318
+ */
319
+ const CLAUDE_RETRY_INITIAL_DELAY_MS = 1_000;
320
+ const CLAUDE_RETRY_MAX_DELAY_MS = 60_000;
321
+ const CLAUDE_RETRY_JITTER_RATIO = 0.2;
239
322
  /** The Claude 4.5 family accepts image input. */
240
323
  const CLAUDE_MODALITIES = ['text', 'image'];
241
324
  /** Claude wire adapter: one instance serves the `claude` provider route. */
242
325
  export class ClaudeAdapter extends LlmAdapter {
243
326
  options;
327
+ catalog;
244
328
  constructor(options) {
245
329
  super();
246
330
  this.options = options;
331
+ this.catalog = new ModelCatalogCache(options.catalogStore);
247
332
  }
248
- providerInfo(provider) {
249
- return { id: provider, name: 'Claude (Subscription)' };
333
+ async fetchCatalog() {
334
+ return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
250
335
  }
251
- async listModels(provider) {
252
- // Not logged in → empty catalog, so the web picker drops the provider.
253
- // Claude has no subscription model-list endpoint, so the static catalog
254
- // is the whole answer when logged in.
255
- if (!await this.options.tokens.hasSession())
256
- return [];
336
+ async discovered(model) {
337
+ if (!this.options.discovery)
338
+ return undefined;
339
+ const models = await this.catalog.resolve(() => this.fetchCatalog());
340
+ return models?.find(entry => entry.id === model);
341
+ }
342
+ staticModels(provider) {
257
343
  return this.options.models.map(model => ({
258
344
  provider,
259
345
  id: model.id,
@@ -261,18 +347,61 @@ export class ClaudeAdapter extends LlmAdapter {
261
347
  inputModalities: model.inputModalities ?? CLAUDE_MODALITIES,
262
348
  }));
263
349
  }
264
- resolveModel(provider, model) {
350
+ providerInfo(provider) {
351
+ return { id: provider, name: 'Claude (Subscription)' };
352
+ }
353
+ providerRetryPolicy(provider) {
354
+ if (this.options.maxRetries === undefined)
355
+ return undefined;
356
+ return resolveRetryPolicy({
357
+ mode: 'normal',
358
+ maxRetries: this.options.maxRetries,
359
+ backoff: {
360
+ initialDelayMs: CLAUDE_RETRY_INITIAL_DELAY_MS,
361
+ maxDelayMs: CLAUDE_RETRY_MAX_DELAY_MS,
362
+ jitterRatio: CLAUDE_RETRY_JITTER_RATIO,
363
+ },
364
+ }, `claude: provider "${provider}" retryPolicy`);
365
+ }
366
+ async listModels(provider) {
367
+ if (await this.options.tokens.peek() === undefined)
368
+ return [];
369
+ if (!this.options.discovery)
370
+ return this.staticModels(provider);
371
+ try {
372
+ const models = await this.catalog.get(() => this.fetchCatalog());
373
+ return models.map(model => ({
374
+ provider,
375
+ id: model.id,
376
+ name: model.name,
377
+ inputModalities: CLAUDE_MODALITIES,
378
+ }));
379
+ }
380
+ catch (error) {
381
+ if (error instanceof LlmError
382
+ && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL'))
383
+ return [];
384
+ if (error instanceof LlmError && error.code === 'AUTH')
385
+ this.catalog.invalidate();
386
+ this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
387
+ return this.staticModels(provider);
388
+ }
389
+ }
390
+ async resolveModel(provider, model) {
391
+ const disc = await this.discovered(model);
265
392
  const configured = this.options.models.find(entry => entry.id === model);
266
- return Promise.resolve({
393
+ const reasoning = disc?.reasoning;
394
+ return {
267
395
  provider,
268
396
  id: model,
269
- name: configured?.name ?? model,
397
+ name: disc?.name ?? configured?.name ?? model,
270
398
  inputModalities: configured?.inputModalities ?? CLAUDE_MODALITIES,
271
- context: { contextWindow: configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW },
399
+ context: {
400
+ contextWindow: disc?.contextWindow ?? configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW,
401
+ },
272
402
  defaultMaxTokens: configured?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS,
273
- // No reasoning metadata: the subscription endpoint's thinking support is
274
- // not exercised, so effort requests reject as unsupported.
275
- });
403
+ ...(reasoning === undefined ? {} : { reasoning }),
404
+ };
276
405
  }
277
406
  async *stream(options) {
278
407
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -280,7 +409,6 @@ export class ClaudeAdapter extends LlmAdapter {
280
409
  let session = await this.options.tokens.session();
281
410
  let response = await this.request(options, session, watchdog.signal);
282
411
  if (response.status === 401) {
283
- // One forced refresh + retry on an unexpired-but-rejected token.
284
412
  session = await this.options.tokens.session(true);
285
413
  response = await this.request(options, session, watchdog.signal);
286
414
  }
@@ -298,18 +426,44 @@ export class ClaudeAdapter extends LlmAdapter {
298
426
  watchdog.stop();
299
427
  }
300
428
  }
429
+ /**
430
+ * `display: 'summarized'` is set explicitly on both shapes: `adaptive`-type
431
+ * models default to `display: 'omitted'`, which returns thinking blocks with
432
+ * an empty `thinking` field — without this override the "Think" panel would
433
+ * always render empty even though real reasoning (and billed thinking_tokens)
434
+ * ran.
435
+ */
436
+ thinkingParam(thinkingType, maxTokens) {
437
+ if (thinkingType === 'adaptive')
438
+ return { type: 'adaptive', display: 'summarized' };
439
+ if (thinkingType === 'enabled') {
440
+ const budget = Math.min(Math.max(1_024, Math.floor(maxTokens * 0.5)), maxTokens - 100);
441
+ if (budget < 1_024)
442
+ return undefined;
443
+ return { type: 'enabled', budget_tokens: budget, display: 'summarized' };
444
+ }
445
+ return undefined;
446
+ }
301
447
  async request(options, session, signal) {
302
448
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
449
+ const maxTokens = options.maxTokens
450
+ ?? this.options.models.find(entry => entry.id === options.model)?.maxTokens
451
+ ?? CLAUDE_DEFAULT_MAX_TOKENS;
452
+ const disc = await this.discovered(options.model);
453
+ const thinking = this.thinkingParam(disc?.thinkingType, maxTokens);
454
+ const effort = options.reasoningEffort !== undefined && disc?.reasoning !== undefined
455
+ ? { output_config: { effort: String(options.reasoningEffort) } }
456
+ : {};
303
457
  const body = {
304
458
  model: options.model,
305
- max_tokens: options.maxTokens
306
- ?? this.options.models.find(entry => entry.id === options.model)?.maxTokens
307
- ?? CLAUDE_DEFAULT_MAX_TOKENS,
459
+ max_tokens: maxTokens,
308
460
  system: toAnthropicSystem(options.system, messages),
309
461
  messages: toAnthropicMessages(messages),
310
462
  ...options.tools !== undefined && options.tools.length > 0
311
463
  ? { tools: toAnthropicTools(options.tools) }
312
464
  : {},
465
+ ...thinking === undefined ? {} : { thinking },
466
+ ...effort,
313
467
  stream: true,
314
468
  ...options.sessionId !== undefined ? { metadata: { user_id: String(options.sessionId) } } : {},
315
469
  };
@@ -175,6 +175,8 @@ export interface DiscoveredModel {
175
175
  }[];
176
176
  defaultEffort?: ReasoningEffortId;
177
177
  };
178
+ /** Claude-specific: which extended-thinking wire shape this model accepts. */
179
+ thinkingType?: 'enabled' | 'adaptive';
178
180
  }
179
181
  /** How long a discovered catalog is trusted before re-fetching. */
180
182
  export declare const DISCOVERY_TTL_MS: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscriptions",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Use ChatGPT (Codex), Claude, and Grok (X Premium) subscriptions as DeepSeek Harness LLM providers, with OAuth login from the web Settings page",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,12 +48,6 @@
48
48
  ]
49
49
  }
50
50
  },
51
- "scripts": {
52
- "build": "tsc && tsdown",
53
- "test": "tsc -p tsconfig.test.json && node --test lib-test/test/",
54
- "prepare": "tsdown -c tsdown.prepare.config.ts",
55
- "prepublishOnly": "pnpm build && pnpm test"
56
- },
57
51
  "peerDependencies": {
58
52
  "@deepseek-ai/cordis": "^4.0.1",
59
53
  "@deepseek-ai/dsh-attachment": "^0.1.0-rc.5",
@@ -81,5 +75,9 @@
81
75
  "react": "^18.2.0",
82
76
  "tsdown": "^0.15.0",
83
77
  "typescript": "^5.8.0"
78
+ },
79
+ "scripts": {
80
+ "build": "tsc && tsdown",
81
+ "test": "tsc -p tsconfig.test.json && node --test lib-test/test/"
84
82
  }
85
- }
83
+ }