dsh-plugin-subscriptions 0.3.0 → 0.4.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.
@@ -405,10 +405,15 @@ export async function fetchGrokModels(session, fetchFn = fetch, onWarn) {
405
405
  /** Grok wire adapter: one instance serves the `grok` provider route. */
406
406
  export class GrokAdapter extends LlmAdapter {
407
407
  options;
408
- catalog = new ModelCatalogCache();
408
+ catalog;
409
409
  constructor(options) {
410
410
  super();
411
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);
412
417
  }
413
418
  providerInfo(provider) {
414
419
  return { id: provider, name: 'Grok (Subscription)' };
@@ -432,7 +437,7 @@ export class GrokAdapter extends LlmAdapter {
432
437
  // The fetcher runs only on a cache miss, and resolves the session
433
438
  // through the refresh-aware path so an expired access token renews here
434
439
  // instead of failing discovery into the static fallback.
435
- const discovered = await this.catalog.get(async () => fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn, this.options.onWarn));
440
+ const discovered = await this.catalog.get(() => this.fetchCatalog());
436
441
  return discovered.map(model => ({
437
442
  provider,
438
443
  id: model.id,
@@ -453,12 +458,24 @@ export class GrokAdapter extends LlmAdapter {
453
458
  return this.staticModels(provider);
454
459
  }
455
460
  }
456
- resolveModel(provider, model) {
457
- const discovered = this.options.discovery
458
- ? this.catalog.cached()?.find(entry => entry.id === model)
459
- : 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);
460
477
  const configured = this.options.models.find(entry => entry.id === model);
461
- return Promise.resolve({
478
+ return {
462
479
  provider,
463
480
  id: model,
464
481
  name: discovered?.name ?? configured?.name ?? model,
@@ -470,7 +487,7 @@ export class GrokAdapter extends LlmAdapter {
470
487
  // cover expose none, so the harness rejects explicit efforts before
471
488
  // provider I/O instead of the API 400ing.
472
489
  ...discovered?.reasoning === undefined ? {} : { reasoning: discovered.reasoning },
473
- });
490
+ };
474
491
  }
475
492
  async *stream(options) {
476
493
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `video_generate` tool: generate videos through the grok subscription's
3
+ * Imagine video endpoint and save them as MP4 files under the harness home.
4
+ * The xAI API is asynchronous: POST `/v1/videos/generations` returns a
5
+ * `request_id`, GET `/v1/videos/{request_id}` is polled until the status
6
+ * leaves `pending`, and the completed response carries a temporary MP4 URL
7
+ * that is downloaded promptly (the URL expires). The canonical result is the
8
+ * saved file path; videos have no attachment surface, so the result stays
9
+ * text-only (unlike image_generate).
10
+ */
11
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
12
+ import type { GrokSession } from '../auth/store.js';
13
+ import { TokenManager } from '../providers/common.js';
14
+ import type { FetchFn } from '../providers/common.js';
15
+ /** Endpoint the generation request is posted to. */
16
+ export declare const VIDEO_GENERATE_URL = "https://api.x.ai/v1/videos/generations";
17
+ /** The video model the grok subscription endpoint serves. */
18
+ export declare const VIDEO_GENERATE_MODEL = "grok-imagine-video-1.5";
19
+ /** Polling endpoint for one generation request. */
20
+ export declare function videoStatusUrl(requestId: string): string;
21
+ /** Default delay between two status polls. */
22
+ export declare const DEFAULT_POLL_INTERVAL_MS = 3000;
23
+ /** Default overall deadline for one generation (submit → done). */
24
+ export declare const DEFAULT_MAX_WAIT_MS: number;
25
+ /** Dependencies of the `video_generate` tool. */
26
+ export interface VideoGenerateToolOptions {
27
+ /** Grok session source; a missing session throws the log-in hint. */
28
+ tokens: TokenManager<GrokSession>;
29
+ /** Fetch implementation (injectable for tests). */
30
+ fetchFn?: FetchFn;
31
+ /** Directory override for saved videos (defaults under the harness home). */
32
+ videosDir?: string;
33
+ /** Delay between status polls (injectable for tests). */
34
+ pollIntervalMs?: number;
35
+ /** Overall deadline from submit to completion. */
36
+ maxWaitMs?: number;
37
+ }
38
+ /** The wire request body for one generation call. */
39
+ export interface VideoGenerateRequestBody {
40
+ prompt: string;
41
+ model: string;
42
+ duration?: number;
43
+ aspect_ratio?: string;
44
+ resolution?: string;
45
+ image?: {
46
+ url: string;
47
+ };
48
+ }
49
+ /**
50
+ * Assemble the request body from tool arguments (hand-checks the non-empty
51
+ * prompt and the duration range the schema DSL cannot express).
52
+ */
53
+ export declare function buildVideoGenerateBody(args: {
54
+ prompt: string;
55
+ duration?: number;
56
+ aspect_ratio?: '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3';
57
+ resolution?: '480p' | '720p' | '1080p';
58
+ image_url?: string;
59
+ }): VideoGenerateRequestBody;
60
+ /**
61
+ * Extract the request id from the submit response. Throws when the payload
62
+ * carries none.
63
+ */
64
+ export declare function parseVideoStartResponse(payload: unknown): string;
65
+ /** One decoded poll response. */
66
+ export type VideoStatus = {
67
+ status: 'pending';
68
+ } | {
69
+ status: 'done';
70
+ url: string;
71
+ duration?: number;
72
+ } | {
73
+ status: 'failed' | 'expired';
74
+ detail?: string;
75
+ };
76
+ /**
77
+ * Decode one poll response. A `done` payload without a video URL and an
78
+ * unrecognized status both throw (the poll loop cannot make progress on
79
+ * either).
80
+ */
81
+ export declare function parseVideoStatusResponse(payload: unknown): VideoStatus;
82
+ /** Directory the downloaded MP4 files are written to. */
83
+ export declare function videosDirectory(): string;
84
+ /**
85
+ * Build the `video_generate` tool definition.
86
+ * @param options - grok session source, fetch implementation, and video directory.
87
+ * @returns the tool to register on `ctx.tools`.
88
+ */
89
+ export declare function createVideoGenerateTool(options: VideoGenerateToolOptions): ToolDefinition;
@@ -0,0 +1,255 @@
1
+ /**
2
+ * `video_generate` tool: generate videos through the grok subscription's
3
+ * Imagine video endpoint and save them as MP4 files under the harness home.
4
+ * The xAI API is asynchronous: POST `/v1/videos/generations` returns a
5
+ * `request_id`, GET `/v1/videos/{request_id}` is polled until the status
6
+ * leaves `pending`, and the completed response carries a temporary MP4 URL
7
+ * that is downloaded promptly (the URL expires). The canonical result is the
8
+ * saved file path; videos have no attachment surface, so the result stays
9
+ * text-only (unlike image_generate).
10
+ */
11
+ import { mkdir, writeFile } from 'node:fs/promises';
12
+ import { basename, join } from 'node:path';
13
+ import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
14
+ import { defineTool } from '@deepseek-ai/dsh-tools';
15
+ import { httpLlmError, TokenManager } from '../providers/common.js';
16
+ /** Endpoint the generation request is posted to. */
17
+ export const VIDEO_GENERATE_URL = 'https://api.x.ai/v1/videos/generations';
18
+ /** The video model the grok subscription endpoint serves. */
19
+ export const VIDEO_GENERATE_MODEL = 'grok-imagine-video-1.5';
20
+ /** Polling endpoint for one generation request. */
21
+ export function videoStatusUrl(requestId) {
22
+ return `https://api.x.ai/v1/videos/${encodeURIComponent(requestId)}`;
23
+ }
24
+ /** Default delay between two status polls. */
25
+ export const DEFAULT_POLL_INTERVAL_MS = 3_000;
26
+ /** Default overall deadline for one generation (submit → done). */
27
+ export const DEFAULT_MAX_WAIT_MS = 10 * 60_000;
28
+ /** xAI's supported clip length range in seconds. */
29
+ const DURATION_RANGE = { min: 1, max: 15 };
30
+ /**
31
+ * Assemble the request body from tool arguments (hand-checks the non-empty
32
+ * prompt and the duration range the schema DSL cannot express).
33
+ */
34
+ export function buildVideoGenerateBody(args) {
35
+ const prompt = args.prompt.trim();
36
+ if (prompt.length === 0)
37
+ throw new Error('video_generate: prompt must be a non-empty string');
38
+ if (args.duration !== undefined
39
+ && (!Number.isInteger(args.duration)
40
+ || args.duration < DURATION_RANGE.min
41
+ || args.duration > DURATION_RANGE.max)) {
42
+ throw new Error(`video_generate: duration must be an integer between ${String(DURATION_RANGE.min)} and ${String(DURATION_RANGE.max)} seconds`);
43
+ }
44
+ const imageUrl = args.image_url?.trim();
45
+ return {
46
+ prompt,
47
+ model: VIDEO_GENERATE_MODEL,
48
+ ...args.duration === undefined ? {} : { duration: args.duration },
49
+ ...args.aspect_ratio === undefined ? {} : { aspect_ratio: args.aspect_ratio },
50
+ ...args.resolution === undefined ? {} : { resolution: args.resolution },
51
+ ...imageUrl === undefined || imageUrl.length === 0 ? {} : { image: { url: imageUrl } },
52
+ };
53
+ }
54
+ function isRecord(value) {
55
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
56
+ }
57
+ /**
58
+ * Extract the request id from the submit response. Throws when the payload
59
+ * carries none.
60
+ */
61
+ export function parseVideoStartResponse(payload) {
62
+ const body = isRecord(payload) ? payload : {};
63
+ if (typeof body.request_id !== 'string' || body.request_id.length === 0) {
64
+ throw new Error('video_generate: the response carried no request_id');
65
+ }
66
+ return body.request_id;
67
+ }
68
+ /**
69
+ * Decode one poll response. A `done` payload without a video URL and an
70
+ * unrecognized status both throw (the poll loop cannot make progress on
71
+ * either).
72
+ */
73
+ export function parseVideoStatusResponse(payload) {
74
+ const body = isRecord(payload) ? payload : {};
75
+ switch (body.status) {
76
+ case 'pending':
77
+ return { status: 'pending' };
78
+ case 'done': {
79
+ const video = isRecord(body.video) ? body.video : {};
80
+ if (typeof video.url !== 'string' || video.url.length === 0) {
81
+ throw new Error('video_generate: the completed response carried no video URL');
82
+ }
83
+ return {
84
+ status: 'done',
85
+ url: video.url,
86
+ ...typeof video.duration === 'number' ? { duration: video.duration } : {},
87
+ };
88
+ }
89
+ case 'failed':
90
+ case 'expired': {
91
+ const error = isRecord(body.error) ? body.error : {};
92
+ const detail = typeof error.message === 'string' && error.message.length > 0
93
+ ? error.message
94
+ : typeof body.error === 'string' && body.error.length > 0 ? body.error : undefined;
95
+ return { status: body.status, ...detail === undefined ? {} : { detail } };
96
+ }
97
+ default:
98
+ throw new Error(`video_generate: unexpected status ${JSON.stringify(body.status)}`);
99
+ }
100
+ }
101
+ /** Directory the downloaded MP4 files are written to. */
102
+ export function videosDirectory() {
103
+ return dshHomePath('plugins', 'subscriptions', 'videos');
104
+ }
105
+ /** Timestamped, collision-safe file name for one generated video. */
106
+ function videoFileName() {
107
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
108
+ return `video-${stamp}-${Math.random().toString(36).slice(2, 8)}.mp4`;
109
+ }
110
+ /** Bound a call-card title's prompt. */
111
+ function truncate(text, max = 60) {
112
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
113
+ }
114
+ /** Abort-aware sleep between two polls. */
115
+ function sleep(ms, signal) {
116
+ if (ms <= 0)
117
+ return Promise.resolve();
118
+ return new Promise((resolve, reject) => {
119
+ const onAbort = () => {
120
+ clearTimeout(timer);
121
+ reject(signal.reason instanceof Error ? signal.reason : new Error('video_generate: aborted'));
122
+ };
123
+ const timer = setTimeout(() => {
124
+ signal.removeEventListener('abort', onAbort);
125
+ resolve();
126
+ }, ms);
127
+ if (signal.aborted) {
128
+ onAbort();
129
+ return;
130
+ }
131
+ signal.addEventListener('abort', onAbort, { once: true });
132
+ });
133
+ }
134
+ /**
135
+ * Build the `video_generate` tool definition.
136
+ * @param options - grok session source, fetch implementation, and video directory.
137
+ * @returns the tool to register on `ctx.tools`.
138
+ */
139
+ export function createVideoGenerateTool(options) {
140
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
141
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
142
+ return defineTool({
143
+ name: 'video_generate',
144
+ description: `Generate a short video (1-15 seconds) with the grok subscription (${VIDEO_GENERATE_MODEL}) `
145
+ + 'and save it as an MP4 file. Generation is asynchronous and may take a minute or more; '
146
+ + 'the tool waits for completion and returns the saved file path. '
147
+ + 'Optionally animate a still image by passing image_url (image-to-video).',
148
+ parameters: {
149
+ prompt: { type: 'string', required: true, description: 'What the video should show.' },
150
+ duration: {
151
+ type: 'integer',
152
+ description: 'Clip length in seconds (1-15); omit for the provider default.',
153
+ },
154
+ aspect_ratio: {
155
+ type: 'string',
156
+ enum: ['16:9', '9:16', '1:1', '4:3', '3:4', '3:2', '2:3'],
157
+ description: 'Output aspect ratio; omit for the provider default (16:9).',
158
+ },
159
+ resolution: {
160
+ type: 'string',
161
+ enum: ['480p', '720p', '1080p'],
162
+ description: 'Output resolution; omit for the provider default (480p). Higher is slower.',
163
+ },
164
+ image_url: {
165
+ type: 'string',
166
+ description: 'Optional public URL or base64 data URL of a JPEG/PNG/WebP image to animate '
167
+ + '(image-to-video); the image becomes the starting frame.',
168
+ },
169
+ },
170
+ output: {
171
+ schema: {
172
+ type: 'object',
173
+ properties: {
174
+ path: { type: 'string', required: true },
175
+ url: { type: 'string', required: true },
176
+ duration: { type: 'number' },
177
+ },
178
+ additionalProperties: false,
179
+ },
180
+ render: (_args, value) => [{
181
+ type: 'text',
182
+ text: `Saved video to ${value.path}`
183
+ + (value.duration === undefined ? '' : ` (${String(value.duration)}s)`)
184
+ + `\nTemporary provider URL (expires soon): ${value.url}`,
185
+ }],
186
+ // The client toolview fetches the bytes by bare file name through the
187
+ // `/subscriptions-auth` `video` endpoint; meta hands it that name.
188
+ presentationMeta: (_args, value) => ({
189
+ fileName: basename(value.path),
190
+ ...value.duration === undefined ? {} : { duration: value.duration },
191
+ }),
192
+ },
193
+ presentCall: args => ({
194
+ card: 'generic',
195
+ title: `video_generate: ${truncate(args.prompt)}`,
196
+ }),
197
+ async execute(args, exec) {
198
+ const body = buildVideoGenerateBody(args);
199
+ const session = await options.tokens.session();
200
+ const fetchFn = options.fetchFn ?? fetch;
201
+ const headers = {
202
+ 'authorization': `Bearer ${session.accessToken}`,
203
+ 'accept': 'application/json',
204
+ };
205
+ const submit = await fetchFn(VIDEO_GENERATE_URL, {
206
+ method: 'POST',
207
+ headers: { ...headers, 'content-type': 'application/json' },
208
+ body: JSON.stringify(body),
209
+ signal: exec.signal,
210
+ });
211
+ if (!submit.ok)
212
+ throw await httpLlmError(submit, 'video_generate');
213
+ const requestId = parseVideoStartResponse(await submit.json());
214
+ const deadline = Date.now() + maxWaitMs;
215
+ let done;
216
+ for (;;) {
217
+ await sleep(pollIntervalMs, exec.signal);
218
+ const poll = await fetchFn(videoStatusUrl(requestId), {
219
+ method: 'GET',
220
+ headers,
221
+ signal: exec.signal,
222
+ });
223
+ if (!poll.ok)
224
+ throw await httpLlmError(poll, 'video_generate');
225
+ const status = parseVideoStatusResponse(await poll.json());
226
+ if (status.status === 'done') {
227
+ done = status;
228
+ break;
229
+ }
230
+ if (status.status === 'failed' || status.status === 'expired') {
231
+ throw new Error(`video_generate: generation ${status.status} (request ${requestId})`
232
+ + (status.detail === undefined ? '' : `: ${status.detail}`));
233
+ }
234
+ if (Date.now() >= deadline) {
235
+ throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
236
+ }
237
+ }
238
+ // The MP4 lives on a separate signed host: no auth header on purpose.
239
+ const download = await fetchFn(done.url, { method: 'GET', signal: exec.signal });
240
+ if (!download.ok)
241
+ throw await httpLlmError(download, 'video_generate download');
242
+ const data = Buffer.from(await download.arrayBuffer());
243
+ const directory = options.videosDir ?? videosDirectory();
244
+ await mkdir(directory, { recursive: true });
245
+ const path = join(directory, videoFileName());
246
+ await writeFile(path, data);
247
+ const value = {
248
+ path,
249
+ url: done.url,
250
+ ...done.duration === undefined ? {} : { duration: done.duration },
251
+ };
252
+ return value;
253
+ },
254
+ });
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscriptions",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
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,6 +48,12 @@
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
+ },
51
57
  "peerDependencies": {
52
58
  "@deepseek-ai/cordis": "^4.0.1",
53
59
  "@deepseek-ai/dsh-attachment": "^0.1.0-rc.5",
@@ -75,9 +81,5 @@
75
81
  "react": "^18.2.0",
76
82
  "tsdown": "^0.15.0",
77
83
  "typescript": "^5.8.0"
78
- },
79
- "scripts": {
80
- "build": "tsc && tsdown",
81
- "test": "tsc -p tsconfig.test.json && node --test lib-test/test/"
82
84
  }
83
- }
85
+ }