dsh-plugin-subscriptions 0.2.0 → 0.3.1

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,7 +3,7 @@
3
3
  * auth.x.ai with the Grok CLI client id, and streaming against the xAI
4
4
  * Responses-style endpoint.
5
5
  */
6
- import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
6
+ import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
7
7
  import { decodeJwtPayload } from '../auth/jwt.js';
8
8
  import { resolveImages } from '../translate/resolved.js';
9
9
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
@@ -276,28 +276,110 @@ function grokModalities(id) {
276
276
  return /code|embed/i.test(id) ? ['text'] : ['text', 'image'];
277
277
  }
278
278
  /**
279
- * The /v1/models list also serves generation models that cannot chat
280
- * (grok-imagine-image*, grok-imagine-video*) and embedding models; the picker
281
- * must not offer them. Heuristic over the id substring, verified against the
282
- * live catalog (grok-build-0.1 and the grok-4 family pass).
279
+ * The Grok Build CLI chat proxy's model catalog the only grok endpoint that
280
+ * advertises reasoning capability. The `api.x.ai/v1/models` and
281
+ * `/v1/language-models` payloads carry pricing, context, and aliases only, so
282
+ * effort metadata must come from here (the same source the official CLI's
283
+ * picker uses).
283
284
  */
284
- function isChatModel(id) {
285
- return !/imagine|image-|video|embed/i.test(id);
285
+ export const GROK_CLI_MODELS_URL = 'https://cli-chat-proxy.grok.com/v1/models';
286
+ /** Map one CLI catalog entry's reasoning fields, or undefined when unsupported. */
287
+ function grokCliReasoning(entry) {
288
+ if (entry.supports_reasoning_effort !== true)
289
+ return undefined;
290
+ const efforts = (entry.reasoning_efforts ?? [])
291
+ .filter(level => typeof level.value === 'string' && level.value.length > 0)
292
+ .map(level => ({
293
+ id: ReasoningEffortId(level.value),
294
+ name: typeof level.label === 'string' && level.label.length > 0 ? level.label : level.value,
295
+ ...typeof level.description === 'string' && level.description.length > 0
296
+ ? { description: level.description }
297
+ : {},
298
+ }));
299
+ if (efforts.length === 0)
300
+ return undefined;
301
+ // The per-entry `default` flags are unreliable (the live catalog marks
302
+ // several levels default at once), so the top-level `reasoning_effort`
303
+ // field is the trusted default.
304
+ const defaultEffort = typeof entry.reasoning_effort === 'string'
305
+ && efforts.some(effort => effort.id === ReasoningEffortId(entry.reasoning_effort))
306
+ ? ReasoningEffortId(entry.reasoning_effort)
307
+ : undefined;
308
+ return { efforts, ...defaultEffort === undefined ? {} : { defaultEffort } };
286
309
  }
287
310
  /**
288
- * Fetch the live grok model list.
311
+ * Fetch the CLI catalog and index its per-model metadata by model id.
289
312
  * @param session - the stored session (used as-is; never refreshed here).
290
313
  * @param fetchFn - fetch implementation (injectable for tests).
291
- * @returns discovered chat models in endpoint order (id doubles as the name).
314
+ * @returns model id contributed metadata.
292
315
  */
293
- export async function fetchGrokModels(session, fetchFn = fetch) {
294
- const response = await fetchFn(GROK_MODELS_URL, {
316
+ export async function fetchGrokCliCatalog(session, fetchFn = fetch) {
317
+ const response = await fetchFn(GROK_CLI_MODELS_URL, {
295
318
  headers: {
296
319
  'authorization': `Bearer ${session.accessToken}`,
320
+ // The proxy only honors bearer tokens presented as the Grok CLI.
321
+ 'x-xai-token-auth': 'xai-grok-cli',
297
322
  'accept': 'application/json',
298
323
  ...attributionHeaders(),
299
324
  },
300
325
  });
326
+ if (!response.ok)
327
+ throw await oauthEndpointError(response, 'grok CLI catalog');
328
+ const payload = await response.json();
329
+ if (!Array.isArray(payload.data))
330
+ throw new Error('grok CLI catalog returned no data array');
331
+ const catalog = new Map();
332
+ for (const entry of payload.data) {
333
+ if (typeof entry.id !== 'string' || entry.id.length === 0)
334
+ continue;
335
+ const reasoning = grokCliReasoning(entry);
336
+ catalog.set(entry.id, {
337
+ ...typeof entry.name === 'string' && entry.name.length > 0 ? { name: entry.name } : {},
338
+ ...typeof entry.description === 'string' && entry.description.length > 0
339
+ ? { description: entry.description }
340
+ : {},
341
+ ...typeof entry.context_window === 'number' && entry.context_window > 0
342
+ ? { contextWindow: entry.context_window }
343
+ : {},
344
+ ...reasoning === undefined ? {} : { reasoning },
345
+ });
346
+ }
347
+ return catalog;
348
+ }
349
+ /**
350
+ * The /v1/models list also serves generation models that cannot chat
351
+ * (grok-imagine-image*, grok-imagine-video*) and embedding models; the picker
352
+ * must not offer them. Heuristic over the id substring, verified against the
353
+ * live catalog (grok-build-0.1 and the grok-4 family pass).
354
+ */
355
+ function isChatModel(id) {
356
+ return !/imagine|image-|video|embed/i.test(id);
357
+ }
358
+ /**
359
+ * Fetch the live grok model list, enriched with the CLI catalog's per-model
360
+ * metadata (display name, context window, reasoning efforts). The api.x.ai
361
+ * list stays authoritative for which models exist; the CLI catalog is
362
+ * enrichment only, so its failure degrades to a plain list instead of taking
363
+ * discovery down — models it does not cover simply expose no efforts.
364
+ * @param session - the stored session (used as-is; never refreshed here).
365
+ * @param fetchFn - fetch implementation (injectable for tests).
366
+ * @param onWarn - warning sink for a failed CLI catalog fetch.
367
+ * @returns discovered chat models in endpoint order.
368
+ */
369
+ export async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
370
+ const [response, cliCatalog] = await Promise.all([
371
+ fetchFn(GROK_MODELS_URL, {
372
+ headers: {
373
+ 'authorization': `Bearer ${session.accessToken}`,
374
+ 'accept': 'application/json',
375
+ ...attributionHeaders(),
376
+ },
377
+ }),
378
+ fetchGrokCliCatalog(session, fetchFn).catch((error) => {
379
+ onWarn?.(`grok CLI catalog fetch failed; reasoning efforts are unavailable (${errorChain(error)})`);
380
+ return undefined;
381
+ }),
382
+ ]);
301
383
  if (!response.ok)
302
384
  throw await oauthEndpointError(response, 'grok models');
303
385
  const payload = await response.json();
@@ -311,7 +393,7 @@ export async function fetchGrokModels(session, fetchFn = fetch) {
311
393
  if (!isChatModel(entry.id))
312
394
  continue;
313
395
  seen.add(entry.id);
314
- discovered.push({ id: entry.id, name: entry.id });
396
+ discovered.push({ id: entry.id, name: entry.id, ...cliCatalog?.get(entry.id) });
315
397
  }
316
398
  // An empty catalog from a 200 response is treated as a discovery failure so
317
399
  // the adapter falls back to the static catalog instead of vanishing from
@@ -323,10 +405,15 @@ export async function fetchGrokModels(session, fetchFn = fetch) {
323
405
  /** Grok wire adapter: one instance serves the `grok` provider route. */
324
406
  export class GrokAdapter extends LlmAdapter {
325
407
  options;
326
- catalog = new ModelCatalogCache();
408
+ catalog;
327
409
  constructor(options) {
328
410
  super();
329
411
  this.options = options;
412
+ this.catalog = new ModelCatalogCache(options.catalogStore);
413
+ }
414
+ /** Discovery fetcher: resolves the session through the refresh-aware path. */
415
+ async fetchCatalog() {
416
+ return fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn);
330
417
  }
331
418
  providerInfo(provider) {
332
419
  return { id: provider, name: 'Grok (Subscription)' };
@@ -350,11 +437,12 @@ export class GrokAdapter extends LlmAdapter {
350
437
  // The fetcher runs only on a cache miss, and resolves the session
351
438
  // through the refresh-aware path so an expired access token renews here
352
439
  // instead of failing discovery into the static fallback.
353
- const discovered = await this.catalog.get(async () => fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn));
440
+ const discovered = await this.catalog.get(() => this.fetchCatalog());
354
441
  return discovered.map(model => ({
355
442
  provider,
356
443
  id: model.id,
357
444
  name: model.name,
445
+ ...model.description === undefined ? {} : { description: model.description },
358
446
  inputModalities: grokModalities(model.id),
359
447
  }));
360
448
  }
@@ -370,20 +458,36 @@ export class GrokAdapter extends LlmAdapter {
370
458
  return this.staticModels(provider);
371
459
  }
372
460
  }
373
- resolveModel(provider, model) {
374
- const discovered = this.options.discovery
375
- ? this.catalog.cached()?.find(entry => entry.id === model)
376
- : undefined;
461
+ /**
462
+ * The discovered entry for one model. Resolved through the cache's
463
+ * stale-while-revalidate path: capability metadata must stay stable across
464
+ * a long conversation — a session that selected a reasoning effort calls
465
+ * this on EVERY step, and forgetting the efforts just because the TTL
466
+ * lapsed mid-turn would fail the call with UNSUPPORTED_REASONING_EFFORT
467
+ * before provider I/O.
468
+ */
469
+ async discovered(model) {
470
+ if (!this.options.discovery)
471
+ return undefined;
472
+ const models = await this.catalog.resolve(() => this.fetchCatalog());
473
+ return models?.find(entry => entry.id === model);
474
+ }
475
+ async resolveModel(provider, model) {
476
+ const discovered = await this.discovered(model);
377
477
  const configured = this.options.models.find(entry => entry.id === model);
378
- return Promise.resolve({
478
+ return {
379
479
  provider,
380
480
  id: model,
381
481
  name: discovered?.name ?? configured?.name ?? model,
482
+ ...discovered?.description === undefined ? {} : { description: discovered.description },
382
483
  inputModalities: configured?.inputModalities ?? grokModalities(model),
383
- context: { contextWindow: configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
484
+ context: { contextWindow: discovered?.contextWindow ?? configured?.contextWindow ?? GROK_CONTEXT_WINDOW },
384
485
  defaultMaxTokens: configured?.maxTokens ?? GROK_DEFAULT_MAX_TOKENS,
385
- // No reasoning metadata: effort selection is not exposed for grok.
386
- });
486
+ // Efforts come from the discovered CLI catalog; models it does not
487
+ // cover expose none, so the harness rejects explicit efforts before
488
+ // provider I/O instead of the API 400ing.
489
+ ...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
490
+ };
387
491
  }
388
492
  async *stream(options) {
389
493
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -422,6 +526,11 @@ export class GrokAdapter extends LlmAdapter {
422
526
  tool_choice: 'auto',
423
527
  parallel_tool_calls: true,
424
528
  ...options.maxTokens !== undefined ? { max_output_tokens: options.maxTokens } : {},
529
+ // The harness only passes an effort the resolved model advertised (the
530
+ // CLI catalog's), so this never reaches a model that rejects it.
531
+ ...options.reasoningEffort !== undefined
532
+ ? { reasoning: { effort: String(options.reasoningEffort) } }
533
+ : {},
425
534
  store: false,
426
535
  stream: true,
427
536
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscriptions",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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
+ }