dsh-plugin-subscriptions 0.3.1 → 0.4.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.
@@ -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.1",
3
+ "version": "0.4.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,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
+ }