@opengeni/xai-subscription 0.1.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.
package/src/video.ts ADDED
@@ -0,0 +1,388 @@
1
+ import {
2
+ pinnedFetch,
3
+ readResponseBodyBounded,
4
+ readResponseJsonBounded,
5
+ readResponseTextBounded,
6
+ validateHttpUrl,
7
+ } from "@opengeni/network";
8
+
9
+ import {
10
+ XAI_PUBLIC_API_BASE_URL,
11
+ XAI_CLIENT_MODE,
12
+ XAI_CLIENT_VERSION,
13
+ XAI_VIDEO_DOWNLOAD_TIMEOUT_MS,
14
+ XAI_VIDEO_GENERATION_TIMEOUT_MS,
15
+ XAI_VIDEO_MODEL,
16
+ XAI_VIDEO_POLL_INTERVAL_MS,
17
+ XAI_VIDEO_POLL_REQUEST_TIMEOUT_MS,
18
+ XAI_VIDEO_START_TIMEOUT_MS,
19
+ } from "./constants";
20
+ import { XaiSubscriptionError } from "./errors";
21
+ import type { XaiFetchLike } from "./fetch";
22
+ import type { XaiSubscriptionTokenSnapshot } from "./request-context";
23
+
24
+ const XAI_VIDEO_MAX_BYTES = 256 * 1024 * 1024;
25
+ const XAI_VIDEO_ERROR_MAX_BYTES = 64 * 1024;
26
+
27
+ const defaultVideoFetch: XaiFetchLike = async (input, init) =>
28
+ await pinnedFetch(
29
+ input,
30
+ init,
31
+ {
32
+ environment: "production",
33
+ integrationsAllowPrivateNetworkTargets: false,
34
+ },
35
+ { label: "xAI video generation", requireHttpsOutsideLocalTest: true },
36
+ );
37
+
38
+ export type XaiGeneratedVideo = {
39
+ bytes: Uint8Array;
40
+ declaredMediaType: "video/mp4";
41
+ requestId: string;
42
+ };
43
+
44
+ export type XaiVideoStatus =
45
+ | Readonly<{ status: "pending" }>
46
+ | Readonly<{ status: "completed"; outputUrl: string; mediaType: "video/mp4" }>
47
+ | Readonly<{ status: "error"; publicReason: string }>;
48
+
49
+ /** One-shot durable start. The caller owns replay/idempotency policy. */
50
+ export async function startXaiSubscriptionVideoWithBody(input: {
51
+ body: Record<string, unknown>;
52
+ getToken: () => Promise<XaiSubscriptionTokenSnapshot>;
53
+ refresh: () => Promise<XaiSubscriptionTokenSnapshot>;
54
+ sessionId?: string;
55
+ signal?: AbortSignal;
56
+ fetch?: XaiFetchLike;
57
+ baseUrl?: string;
58
+ }): Promise<{ providerJobId: string }> {
59
+ const fetchImpl = input.fetch ?? defaultVideoFetch;
60
+ const baseUrl = (input.baseUrl ?? XAI_PUBLIC_API_BASE_URL).replace(/\/+$/, "");
61
+ const request = async (token: XaiSubscriptionTokenSnapshot) =>
62
+ await fetchWithDeadline(
63
+ fetchImpl,
64
+ `${baseUrl}/videos/generations`,
65
+ {
66
+ method: "POST",
67
+ redirect: "error",
68
+ headers: publicVideoHeaders(token, input.sessionId, true),
69
+ body: JSON.stringify(input.body),
70
+ ...(input.signal ? { signal: input.signal } : {}),
71
+ },
72
+ XAI_VIDEO_START_TIMEOUT_MS,
73
+ input.signal ?? AbortSignal.timeout(XAI_VIDEO_START_TIMEOUT_MS),
74
+ );
75
+ let response = await request(await input.getToken());
76
+ if (response.status === 401) {
77
+ await response.body?.cancel().catch(() => undefined);
78
+ response = await request(await input.refresh());
79
+ }
80
+ if (!response.ok)
81
+ throw await providerError("start", response, input.signal ?? new AbortController().signal);
82
+ const body = await readResponseJsonBounded<Record<string, unknown>>(
83
+ response,
84
+ 1024 * 1024,
85
+ "xAI video start",
86
+ input.signal ? { signal: input.signal } : {},
87
+ );
88
+ if (typeof body.request_id !== "string" || !body.request_id.trim()) {
89
+ throw new XaiSubscriptionError("invalid_response", "xAI video start response is malformed");
90
+ }
91
+ return { providerJobId: body.request_id };
92
+ }
93
+
94
+ export async function getXaiSubscriptionVideoStatus(input: {
95
+ providerJobId: string;
96
+ getToken: () => Promise<XaiSubscriptionTokenSnapshot>;
97
+ refresh: () => Promise<XaiSubscriptionTokenSnapshot>;
98
+ sessionId?: string;
99
+ signal?: AbortSignal;
100
+ fetch?: XaiFetchLike;
101
+ baseUrl?: string;
102
+ }): Promise<XaiVideoStatus> {
103
+ if (!input.providerJobId.trim() || input.providerJobId.length > 1024) {
104
+ throw new Error("xAI video job identity is invalid");
105
+ }
106
+ const fetchImpl = input.fetch ?? defaultVideoFetch;
107
+ const baseUrl = (input.baseUrl ?? XAI_PUBLIC_API_BASE_URL).replace(/\/+$/, "");
108
+ const request = async (token: XaiSubscriptionTokenSnapshot) =>
109
+ await fetchWithDeadline(
110
+ fetchImpl,
111
+ `${baseUrl}/videos/${encodeURIComponent(input.providerJobId)}`,
112
+ {
113
+ method: "GET",
114
+ redirect: "error",
115
+ headers: publicVideoHeaders(token, input.sessionId, false),
116
+ ...(input.signal ? { signal: input.signal } : {}),
117
+ },
118
+ XAI_VIDEO_POLL_REQUEST_TIMEOUT_MS,
119
+ input.signal ?? AbortSignal.timeout(XAI_VIDEO_POLL_REQUEST_TIMEOUT_MS),
120
+ );
121
+ let response = await request(await input.getToken());
122
+ if (response.status === 401) {
123
+ await response.body?.cancel().catch(() => undefined);
124
+ response = await request(await input.refresh());
125
+ }
126
+ if (!response.ok && response.status !== 202) {
127
+ throw await providerError("poll", response, input.signal ?? new AbortController().signal);
128
+ }
129
+ const body = await readResponseJsonBounded<Record<string, unknown>>(
130
+ response,
131
+ 1024 * 1024,
132
+ "xAI video poll",
133
+ input.signal ? { signal: input.signal } : {},
134
+ );
135
+ if (body.status === "pending" || body.status === "queued" || body.status === "running") {
136
+ return { status: "pending" };
137
+ }
138
+ if (body.status === "failed" || body.status === "expired") {
139
+ return {
140
+ status: "error",
141
+ publicReason: `xAI video generation ${body.status}`,
142
+ };
143
+ }
144
+ const video =
145
+ body.video && typeof body.video === "object" && !Array.isArray(body.video)
146
+ ? (body.video as Record<string, unknown>)
147
+ : null;
148
+ if (body.status !== "done" || typeof video?.url !== "string") {
149
+ throw new XaiSubscriptionError("invalid_response", "xAI video poll response is malformed");
150
+ }
151
+ return {
152
+ status: "completed",
153
+ outputUrl: validateMediaUrl(video.url),
154
+ mediaType: "video/mp4",
155
+ };
156
+ }
157
+
158
+ function publicVideoHeaders(
159
+ token: XaiSubscriptionTokenSnapshot,
160
+ sessionId: string | undefined,
161
+ json: boolean,
162
+ ): Headers {
163
+ return new Headers({
164
+ accept: "application/json",
165
+ authorization: `Bearer ${token.accessToken}`,
166
+ "user-agent": `opengeni/${XAI_CLIENT_VERSION}`,
167
+ "x-grok-client-version": XAI_CLIENT_VERSION,
168
+ "x-grok-client-identifier": "opengeni",
169
+ "x-grok-client-mode": XAI_CLIENT_MODE,
170
+ ...(sessionId ? { "x-grok-session-id": sessionId } : {}),
171
+ ...(json ? { "content-type": "application/json" } : {}),
172
+ });
173
+ }
174
+
175
+ export async function generateXaiSubscriptionVideo(input: {
176
+ prompt: string;
177
+ durationSeconds?: number;
178
+ aspectRatio?: string;
179
+ resolution?: "480p" | "720p";
180
+ imageUrl?: string;
181
+ referenceImageUrls?: readonly string[];
182
+ getToken: () => Promise<XaiSubscriptionTokenSnapshot>;
183
+ refresh: () => Promise<XaiSubscriptionTokenSnapshot>;
184
+ abortSignal?: AbortSignal;
185
+ fetch?: XaiFetchLike;
186
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
187
+ baseUrl?: string;
188
+ generationTimeoutMs?: number;
189
+ }): Promise<XaiGeneratedVideo> {
190
+ const baseUrl = (input.baseUrl ?? XAI_PUBLIC_API_BASE_URL).replace(/\/+$/, "");
191
+ const fetchImpl = input.fetch ?? defaultVideoFetch;
192
+ const deadline = new AbortController();
193
+ const timeoutMs = input.generationTimeoutMs ?? XAI_VIDEO_GENERATION_TIMEOUT_MS;
194
+ const timer = setTimeout(
195
+ () => deadline.abort(new XaiSubscriptionError("timeout", "xAI video generation timed out")),
196
+ timeoutMs,
197
+ );
198
+ const signal = input.abortSignal
199
+ ? AbortSignal.any([input.abortSignal, deadline.signal])
200
+ : deadline.signal;
201
+ const references = (input.referenceImageUrls ?? []).map(validateMediaUrl);
202
+ const image = input.imageUrl ? validateMediaUrl(input.imageUrl) : undefined;
203
+ const request = async (token: XaiSubscriptionTokenSnapshot): Promise<Response> =>
204
+ await fetchWithDeadline(
205
+ fetchImpl,
206
+ `${baseUrl}/videos/generations`,
207
+ {
208
+ method: "POST",
209
+ redirect: "error",
210
+ headers: {
211
+ accept: "application/json",
212
+ authorization: `Bearer ${token.accessToken}`,
213
+ "content-type": "application/json",
214
+ },
215
+ body: JSON.stringify({
216
+ model: XAI_VIDEO_MODEL,
217
+ prompt: input.prompt,
218
+ ...(image ? { image: { url: image } } : {}),
219
+ ...(references.length > 0
220
+ ? { reference_images: references.map((url) => ({ url })) }
221
+ : {}),
222
+ ...(input.durationSeconds ? { duration: input.durationSeconds } : {}),
223
+ aspect_ratio: input.aspectRatio ?? "16:9",
224
+ resolution: input.resolution ?? "480p",
225
+ }),
226
+ signal,
227
+ },
228
+ XAI_VIDEO_START_TIMEOUT_MS,
229
+ signal,
230
+ );
231
+ try {
232
+ let start = await request(await input.getToken());
233
+ if (start.status === 401) {
234
+ await start.body?.cancel().catch(() => undefined);
235
+ start = await request(await input.refresh());
236
+ }
237
+ if (!start.ok) throw await providerError("start", start, signal);
238
+ const started = await readResponseJsonBounded<Record<string, unknown>>(
239
+ start,
240
+ 1024 * 1024,
241
+ "xAI video start",
242
+ { signal },
243
+ );
244
+ const requestId =
245
+ typeof started.request_id === "string" && started.request_id.length > 0
246
+ ? started.request_id
247
+ : null;
248
+ if (!requestId) {
249
+ throw new XaiSubscriptionError(
250
+ "invalid_response",
251
+ "xAI video generation did not return request_id",
252
+ );
253
+ }
254
+ const sleep = input.sleep ?? abortableSleep;
255
+ for (;;) {
256
+ await sleep(XAI_VIDEO_POLL_INTERVAL_MS, signal);
257
+ let poll = await pollVideo(fetchImpl, baseUrl, requestId, await input.getToken(), signal);
258
+ if (poll.status === 401) {
259
+ await poll.body?.cancel().catch(() => undefined);
260
+ poll = await pollVideo(fetchImpl, baseUrl, requestId, await input.refresh(), signal);
261
+ }
262
+ if (!poll.ok && poll.status !== 202) throw await providerError("poll", poll, signal);
263
+ const status = await readResponseJsonBounded<Record<string, unknown>>(
264
+ poll,
265
+ 1024 * 1024,
266
+ "xAI video poll",
267
+ { signal },
268
+ );
269
+ if (status.status === "failed" || status.status === "expired") {
270
+ throw new XaiSubscriptionError(
271
+ "provider_rejected",
272
+ `xAI video generation ${status.status}`,
273
+ );
274
+ }
275
+ if (status.status !== "done") continue;
276
+ const video =
277
+ status.video && typeof status.video === "object" && !Array.isArray(status.video)
278
+ ? (status.video as Record<string, unknown>)
279
+ : null;
280
+ const downloadUrl = typeof video?.url === "string" ? validateMediaUrl(video.url) : null;
281
+ if (!downloadUrl) {
282
+ throw new XaiSubscriptionError(
283
+ "invalid_response",
284
+ "xAI video generation completed without a download URL",
285
+ );
286
+ }
287
+ const download = await fetchWithDeadline(
288
+ fetchImpl,
289
+ downloadUrl,
290
+ { method: "GET", redirect: "error", signal },
291
+ XAI_VIDEO_DOWNLOAD_TIMEOUT_MS,
292
+ signal,
293
+ );
294
+ if (!download.ok) throw await providerError("download", download, signal);
295
+ const bytes = await readResponseBodyBounded(
296
+ download,
297
+ XAI_VIDEO_MAX_BYTES,
298
+ "xAI video download",
299
+ { signal },
300
+ );
301
+ return { bytes, declaredMediaType: "video/mp4", requestId };
302
+ }
303
+ } finally {
304
+ clearTimeout(timer);
305
+ }
306
+ }
307
+
308
+ async function pollVideo(
309
+ fetchImpl: XaiFetchLike,
310
+ baseUrl: string,
311
+ requestId: string,
312
+ token: XaiSubscriptionTokenSnapshot,
313
+ signal: AbortSignal,
314
+ ): Promise<Response> {
315
+ return await fetchWithDeadline(
316
+ fetchImpl,
317
+ `${baseUrl}/videos/${encodeURIComponent(requestId)}`,
318
+ {
319
+ method: "GET",
320
+ redirect: "error",
321
+ headers: {
322
+ accept: "application/json",
323
+ authorization: `Bearer ${token.accessToken}`,
324
+ },
325
+ signal,
326
+ },
327
+ XAI_VIDEO_POLL_REQUEST_TIMEOUT_MS,
328
+ signal,
329
+ );
330
+ }
331
+
332
+ async function fetchWithDeadline(
333
+ fetchImpl: XaiFetchLike,
334
+ url: string,
335
+ init: RequestInit,
336
+ timeoutMs: number,
337
+ outerSignal: AbortSignal,
338
+ ): Promise<Response> {
339
+ const timeout = AbortSignal.timeout(timeoutMs);
340
+ return await fetchImpl(url, {
341
+ ...init,
342
+ signal: AbortSignal.any([outerSignal, timeout]),
343
+ });
344
+ }
345
+
346
+ async function providerError(
347
+ phase: string,
348
+ response: Response,
349
+ signal: AbortSignal,
350
+ ): Promise<XaiSubscriptionError> {
351
+ const detail = await readResponseTextBounded(
352
+ response,
353
+ XAI_VIDEO_ERROR_MAX_BYTES,
354
+ `xAI video ${phase} error`,
355
+ { signal },
356
+ ).catch(() => "");
357
+ return new XaiSubscriptionError(
358
+ "provider_rejected",
359
+ `xAI video ${phase} failed (${response.status})${detail ? `: ${detail.replace(/\s+/g, " ").trim().slice(0, 1_000)}` : ""}`,
360
+ response.status,
361
+ );
362
+ }
363
+
364
+ function validateMediaUrl(value: string): string {
365
+ try {
366
+ return validateHttpUrl(value, { label: "xAI media" });
367
+ } catch {
368
+ throw new XaiSubscriptionError("invalid_response", "xAI returned an invalid media URL");
369
+ }
370
+ }
371
+
372
+ async function abortableSleep(ms: number, signal: AbortSignal): Promise<void> {
373
+ if (signal.aborted) throw signal.reason;
374
+ await new Promise<void>((resolve, reject) => {
375
+ const timer = setTimeout(done, ms);
376
+ const onAbort = () => done(signal.reason);
377
+ function done(error?: unknown) {
378
+ clearTimeout(timer);
379
+ signal.removeEventListener("abort", onAbort);
380
+ if (error === undefined) {
381
+ resolve();
382
+ } else {
383
+ reject(error);
384
+ }
385
+ }
386
+ signal.addEventListener("abort", onAbort, { once: true });
387
+ });
388
+ }