dsh-plugin-subscriptions 0.3.1 → 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.
package/lib/index.js CHANGED
@@ -2,9 +2,9 @@ import z from "@deepseek-ai/schemastery";
2
2
  import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, createUserMessage, errorChain, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
3
3
  import { createServer } from "node:http";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
- import { AttachmentId } from "@deepseek-ai/dsh-attachment";
6
5
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
7
6
  import { basename, dirname, join } from "node:path";
7
+ import { AttachmentId } from "@deepseek-ai/dsh-attachment";
8
8
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
9
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
10
10
 
@@ -371,6 +371,8 @@ const IMAGE_MEDIA_TYPES = [
371
371
  "image/webp",
372
372
  "image/gif"
373
373
  ];
374
+ /** Bare MP4 file names the `video` endpoint accepts (no path separators). */
375
+ const VIDEO_NAME_PATTERN = /^[\w.-]+\.mp4$/;
374
376
  /** Payload carried no usable provider id — an RPC client bug, not a server failure. */
375
377
  var BadRequest = class extends Error {};
376
378
  function ok(value) {
@@ -436,6 +438,17 @@ function readImageRef(payload) {
436
438
  ...name$1 === void 0 ? {} : { name: name$1 }
437
439
  };
438
440
  }
441
+ /**
442
+ * Validate the `video` endpoint's payload into a bare file name. Rejecting
443
+ * anything with a path separator (the pattern allows none) pins every read
444
+ * inside the plugin's videos directory.
445
+ */
446
+ function readVideoName(payload) {
447
+ if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
448
+ const name$1 = payload.name;
449
+ if (typeof name$1 !== "string" || !VIDEO_NAME_PATTERN.test(name$1)) throw new BadRequest("payload.name must be a bare .mp4 file name");
450
+ return name$1;
451
+ }
439
452
  async function dispatch(controller, endpoint, payload, signal) {
440
453
  switch (endpoint) {
441
454
  case "status": {
@@ -456,6 +469,7 @@ async function dispatch(controller, endpoint, payload, signal) {
456
469
  return ok({ ok: true });
457
470
  case "usage": return ok(await controller.usage(readProvider(payload), signal));
458
471
  case "image": return ok(await controller.readImage(readImageRef(payload), signal));
472
+ case "video": return ok(await controller.readVideo(readVideoName(payload), signal));
459
473
  default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
460
474
  }
461
475
  }
@@ -1510,8 +1524,28 @@ function isCodexPermanentRefreshError(error) {
1510
1524
  return error instanceof OAuthEndpointError && error.oauthCode !== void 0 && PERMANENT_REFRESH_CODES.has(error.oauthCode);
1511
1525
  }
1512
1526
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
1527
+ /** Seconds of the canonical 5-hour session and 7-day weekly windows. */
1528
+ const SESSION_WINDOW_SECONDS = 300 * 60;
1529
+ const WEEKLY_WINDOW_SECONDS = 10080 * 60;
1530
+ /** Whether a reported duration approximately matches the expected window length. */
1531
+ function matchesWindow(seconds, expected) {
1532
+ return seconds >= expected * .95 && seconds <= expected * 1.05;
1533
+ }
1534
+ /**
1535
+ * Classify a wham/usage window by its reported duration. The backend has been
1536
+ * observed to place the weekly lane in `primary_window` with no secondary
1537
+ * window, so slot position alone is unreliable; the caller's positional
1538
+ * fallback applies only when the duration is absent.
1539
+ */
1540
+ function codexWindowKind(window, fallback) {
1541
+ const seconds = window.limit_window_seconds;
1542
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return fallback;
1543
+ if (matchesWindow(seconds, SESSION_WINDOW_SECONDS)) return "session";
1544
+ if (matchesWindow(seconds, WEEKLY_WINDOW_SECONDS)) return "weekly";
1545
+ return "other";
1546
+ }
1513
1547
  /** Map one wham/usage window into a {@link UsageWindow}; undefined when unusable. */
1514
- function codexUsageWindow(value, kind) {
1548
+ function codexUsageWindow(value, fallbackKind) {
1515
1549
  if (typeof value !== "object" || value === null) return void 0;
1516
1550
  const window = value;
1517
1551
  if (typeof window.used_percent !== "number" || !Number.isFinite(window.used_percent)) return void 0;
@@ -1519,7 +1553,7 @@ function codexUsageWindow(value, kind) {
1519
1553
  if (typeof window.reset_at === "number" && window.reset_at > 0) resetsAt = window.reset_at * 1e3;
1520
1554
  else if (typeof window.reset_after_seconds === "number" && window.reset_after_seconds > 0) resetsAt = Date.now() + window.reset_after_seconds * 1e3;
1521
1555
  return {
1522
- kind,
1556
+ kind: codexWindowKind(window, fallbackKind),
1523
1557
  usedPercent: window.used_percent,
1524
1558
  ...resetsAt === void 0 ? {} : { resetsAt }
1525
1559
  };
@@ -1527,8 +1561,11 @@ function codexUsageWindow(value, kind) {
1527
1561
  /**
1528
1562
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
1529
1563
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
1530
- * primary window is the rolling session (5-hour) lane, the secondary window
1531
- * the weekly lane; the lookup itself consumes no rate-limit budget.
1564
+ * windows are classified by their reported duration (`limit_window_seconds`)
1565
+ * rather than by slot, since the backend has been observed to report the
1566
+ * weekly lane as `primary_window` without a secondary window; slot order is
1567
+ * kept only as a fallback when the duration is absent. The lookup itself
1568
+ * consumes no rate-limit budget.
1532
1569
  * @param session - the stored session (used as-is; never refreshed here).
1533
1570
  * @param fetchFn - fetch implementation (injectable for tests).
1534
1571
  * @param signal - caller cancellation from the RPC transport.
@@ -2893,7 +2930,7 @@ function normalizeHandles(value, field) {
2893
2930
  if (handles.length > MAX_HANDLES) throw new Error(`x_search: ${field} supports at most ${MAX_HANDLES} handles`);
2894
2931
  return handles;
2895
2932
  }
2896
- function isRecord(value) {
2933
+ function isRecord$1(value) {
2897
2934
  return typeof value === "object" && value !== null && !Array.isArray(value);
2898
2935
  }
2899
2936
  /**
@@ -2902,7 +2939,7 @@ function isRecord(value) {
2902
2939
  * top-level `citations` and inline `url_citation` annotations for sources.
2903
2940
  */
2904
2941
  function parseXSearchResponse(payload) {
2905
- const body = isRecord(payload) ? payload : {};
2942
+ const body = isRecord$1(payload) ? payload : {};
2906
2943
  let answer = typeof body.output_text === "string" ? body.output_text.trim() : "";
2907
2944
  const citations = [];
2908
2945
  const push = (url) => {
@@ -2911,12 +2948,12 @@ function parseXSearchResponse(payload) {
2911
2948
  if (Array.isArray(body.citations)) for (const citation of body.citations) push(citation);
2912
2949
  const parts = [];
2913
2950
  if (Array.isArray(body.output)) for (const item of body.output) {
2914
- if (!isRecord(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
2951
+ if (!isRecord$1(item) || item.type !== "message" || !Array.isArray(item.content)) continue;
2915
2952
  for (const part of item.content) {
2916
- if (!isRecord(part)) continue;
2953
+ if (!isRecord$1(part)) continue;
2917
2954
  if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string" && part.text.trim().length > 0) parts.push(part.text.trim());
2918
2955
  if (Array.isArray(part.annotations)) {
2919
- for (const annotation of part.annotations) if (isRecord(annotation) && annotation.type === "url_citation") push(annotation.url);
2956
+ for (const annotation of part.annotations) if (isRecord$1(annotation) && annotation.type === "url_citation") push(annotation.url);
2920
2957
  }
2921
2958
  }
2922
2959
  }
@@ -2927,7 +2964,7 @@ function parseXSearchResponse(payload) {
2927
2964
  };
2928
2965
  }
2929
2966
  /** Bound a call-card title's query. */
2930
- function truncate$1(text, max = 60) {
2967
+ function truncate$2(text, max = 60) {
2931
2968
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
2932
2969
  }
2933
2970
  /**
@@ -2999,11 +3036,11 @@ function createXSearchTool(options) {
2999
3036
  },
3000
3037
  presentCall: (args) => ({
3001
3038
  card: "generic",
3002
- title: `x_search: ${truncate$1(args.query)}`,
3039
+ title: `x_search: ${truncate$2(args.query)}`,
3003
3040
  kind: "search"
3004
3041
  }),
3005
3042
  presentResult: (_args, result) => {
3006
- if (result.isError || !isRecord(result.meta)) return void 0;
3043
+ if (result.isError || !isRecord$1(result.meta)) return void 0;
3007
3044
  return {
3008
3045
  card: "web",
3009
3046
  kind: "search",
@@ -3088,7 +3125,7 @@ function imageFileName(index) {
3088
3125
  return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}.png`;
3089
3126
  }
3090
3127
  /** Bound a call-card title's prompt. */
3091
- function truncate(text, max = 60) {
3128
+ function truncate$1(text, max = 60) {
3092
3129
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
3093
3130
  }
3094
3131
  /**
@@ -3224,7 +3261,7 @@ function createImageGenerateTool(options) {
3224
3261
  },
3225
3262
  presentCall: (args) => ({
3226
3263
  card: "generic",
3227
- title: `image_generate: ${truncate(args.prompt)}`
3264
+ title: `image_generate: ${truncate$1(args.prompt)}`
3228
3265
  }),
3229
3266
  presentResult: (_args, result) => ({
3230
3267
  card: "generic",
@@ -3291,6 +3328,249 @@ function createImageGenerateTool(options) {
3291
3328
  });
3292
3329
  }
3293
3330
 
3331
+ //#endregion
3332
+ //#region src/tools/video-generate.ts
3333
+ /** Endpoint the generation request is posted to. */
3334
+ const VIDEO_GENERATE_URL = "https://api.x.ai/v1/videos/generations";
3335
+ /** The video model the grok subscription endpoint serves. */
3336
+ const VIDEO_GENERATE_MODEL = "grok-imagine-video-1.5";
3337
+ /** Polling endpoint for one generation request. */
3338
+ function videoStatusUrl(requestId) {
3339
+ return `https://api.x.ai/v1/videos/${encodeURIComponent(requestId)}`;
3340
+ }
3341
+ /** Default delay between two status polls. */
3342
+ const DEFAULT_POLL_INTERVAL_MS = 3e3;
3343
+ /** Default overall deadline for one generation (submit → done). */
3344
+ const DEFAULT_MAX_WAIT_MS = 10 * 6e4;
3345
+ /** xAI's supported clip length range in seconds. */
3346
+ const DURATION_RANGE = {
3347
+ min: 1,
3348
+ max: 15
3349
+ };
3350
+ /**
3351
+ * Assemble the request body from tool arguments (hand-checks the non-empty
3352
+ * prompt and the duration range the schema DSL cannot express).
3353
+ */
3354
+ function buildVideoGenerateBody(args) {
3355
+ const prompt = args.prompt.trim();
3356
+ if (prompt.length === 0) throw new Error("video_generate: prompt must be a non-empty string");
3357
+ if (args.duration !== void 0 && (!Number.isInteger(args.duration) || args.duration < DURATION_RANGE.min || args.duration > DURATION_RANGE.max)) throw new Error(`video_generate: duration must be an integer between ${String(DURATION_RANGE.min)} and ${String(DURATION_RANGE.max)} seconds`);
3358
+ const imageUrl = args.image_url?.trim();
3359
+ return {
3360
+ prompt,
3361
+ model: VIDEO_GENERATE_MODEL,
3362
+ ...args.duration === void 0 ? {} : { duration: args.duration },
3363
+ ...args.aspect_ratio === void 0 ? {} : { aspect_ratio: args.aspect_ratio },
3364
+ ...args.resolution === void 0 ? {} : { resolution: args.resolution },
3365
+ ...imageUrl === void 0 || imageUrl.length === 0 ? {} : { image: { url: imageUrl } }
3366
+ };
3367
+ }
3368
+ function isRecord(value) {
3369
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3370
+ }
3371
+ /**
3372
+ * Extract the request id from the submit response. Throws when the payload
3373
+ * carries none.
3374
+ */
3375
+ function parseVideoStartResponse(payload) {
3376
+ const body = isRecord(payload) ? payload : {};
3377
+ if (typeof body.request_id !== "string" || body.request_id.length === 0) throw new Error("video_generate: the response carried no request_id");
3378
+ return body.request_id;
3379
+ }
3380
+ /**
3381
+ * Decode one poll response. A `done` payload without a video URL and an
3382
+ * unrecognized status both throw (the poll loop cannot make progress on
3383
+ * either).
3384
+ */
3385
+ function parseVideoStatusResponse(payload) {
3386
+ const body = isRecord(payload) ? payload : {};
3387
+ switch (body.status) {
3388
+ case "pending": return { status: "pending" };
3389
+ case "done": {
3390
+ const video = isRecord(body.video) ? body.video : {};
3391
+ if (typeof video.url !== "string" || video.url.length === 0) throw new Error("video_generate: the completed response carried no video URL");
3392
+ return {
3393
+ status: "done",
3394
+ url: video.url,
3395
+ ...typeof video.duration === "number" ? { duration: video.duration } : {}
3396
+ };
3397
+ }
3398
+ case "failed":
3399
+ case "expired": {
3400
+ const error = isRecord(body.error) ? body.error : {};
3401
+ const detail = typeof error.message === "string" && error.message.length > 0 ? error.message : typeof body.error === "string" && body.error.length > 0 ? body.error : void 0;
3402
+ return {
3403
+ status: body.status,
3404
+ ...detail === void 0 ? {} : { detail }
3405
+ };
3406
+ }
3407
+ default: throw new Error(`video_generate: unexpected status ${JSON.stringify(body.status)}`);
3408
+ }
3409
+ }
3410
+ /** Directory the downloaded MP4 files are written to. */
3411
+ function videosDirectory() {
3412
+ return dshHomePath("plugins", "subscriptions", "videos");
3413
+ }
3414
+ /** Timestamped, collision-safe file name for one generated video. */
3415
+ function videoFileName() {
3416
+ return `video-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}.mp4`;
3417
+ }
3418
+ /** Bound a call-card title's prompt. */
3419
+ function truncate(text, max = 60) {
3420
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
3421
+ }
3422
+ /** Abort-aware sleep between two polls. */
3423
+ function sleep(ms, signal) {
3424
+ if (ms <= 0) return Promise.resolve();
3425
+ return new Promise((resolve, reject) => {
3426
+ const onAbort = () => {
3427
+ clearTimeout(timer);
3428
+ reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("video_generate: aborted"));
3429
+ };
3430
+ const timer = setTimeout(() => {
3431
+ signal.removeEventListener("abort", onAbort);
3432
+ resolve();
3433
+ }, ms);
3434
+ if (signal.aborted) {
3435
+ onAbort();
3436
+ return;
3437
+ }
3438
+ signal.addEventListener("abort", onAbort, { once: true });
3439
+ });
3440
+ }
3441
+ /**
3442
+ * Build the `video_generate` tool definition.
3443
+ * @param options - grok session source, fetch implementation, and video directory.
3444
+ * @returns the tool to register on `ctx.tools`.
3445
+ */
3446
+ function createVideoGenerateTool(options) {
3447
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
3448
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
3449
+ return defineTool({
3450
+ name: "video_generate",
3451
+ description: `Generate a short video (1-15 seconds) with the grok subscription (${VIDEO_GENERATE_MODEL}) and save it as an MP4 file. Generation is asynchronous and may take a minute or more; the tool waits for completion and returns the saved file path. Optionally animate a still image by passing image_url (image-to-video).`,
3452
+ parameters: {
3453
+ prompt: {
3454
+ type: "string",
3455
+ required: true,
3456
+ description: "What the video should show."
3457
+ },
3458
+ duration: {
3459
+ type: "integer",
3460
+ description: "Clip length in seconds (1-15); omit for the provider default."
3461
+ },
3462
+ aspect_ratio: {
3463
+ type: "string",
3464
+ enum: [
3465
+ "16:9",
3466
+ "9:16",
3467
+ "1:1",
3468
+ "4:3",
3469
+ "3:4",
3470
+ "3:2",
3471
+ "2:3"
3472
+ ],
3473
+ description: "Output aspect ratio; omit for the provider default (16:9)."
3474
+ },
3475
+ resolution: {
3476
+ type: "string",
3477
+ enum: [
3478
+ "480p",
3479
+ "720p",
3480
+ "1080p"
3481
+ ],
3482
+ description: "Output resolution; omit for the provider default (480p). Higher is slower."
3483
+ },
3484
+ image_url: {
3485
+ type: "string",
3486
+ description: "Optional public URL or base64 data URL of a JPEG/PNG/WebP image to animate (image-to-video); the image becomes the starting frame."
3487
+ }
3488
+ },
3489
+ output: {
3490
+ schema: {
3491
+ type: "object",
3492
+ properties: {
3493
+ path: {
3494
+ type: "string",
3495
+ required: true
3496
+ },
3497
+ url: {
3498
+ type: "string",
3499
+ required: true
3500
+ },
3501
+ duration: { type: "number" }
3502
+ },
3503
+ additionalProperties: false
3504
+ },
3505
+ render: (_args, value) => [{
3506
+ type: "text",
3507
+ text: `Saved video to ${value.path}` + (value.duration === void 0 ? "" : ` (${String(value.duration)}s)`) + `\nTemporary provider URL (expires soon): ${value.url}`
3508
+ }],
3509
+ presentationMeta: (_args, value) => ({
3510
+ fileName: basename(value.path),
3511
+ ...value.duration === void 0 ? {} : { duration: value.duration }
3512
+ })
3513
+ },
3514
+ presentCall: (args) => ({
3515
+ card: "generic",
3516
+ title: `video_generate: ${truncate(args.prompt)}`
3517
+ }),
3518
+ async execute(args, exec) {
3519
+ const body = buildVideoGenerateBody(args);
3520
+ const session = await options.tokens.session();
3521
+ const fetchFn = options.fetchFn ?? fetch;
3522
+ const headers = {
3523
+ "authorization": `Bearer ${session.accessToken}`,
3524
+ "accept": "application/json"
3525
+ };
3526
+ const submit = await fetchFn(VIDEO_GENERATE_URL, {
3527
+ method: "POST",
3528
+ headers: {
3529
+ ...headers,
3530
+ "content-type": "application/json"
3531
+ },
3532
+ body: JSON.stringify(body),
3533
+ signal: exec.signal
3534
+ });
3535
+ if (!submit.ok) throw await httpLlmError(submit, "video_generate");
3536
+ const requestId = parseVideoStartResponse(await submit.json());
3537
+ const deadline = Date.now() + maxWaitMs;
3538
+ let done;
3539
+ for (;;) {
3540
+ await sleep(pollIntervalMs, exec.signal);
3541
+ const poll = await fetchFn(videoStatusUrl(requestId), {
3542
+ method: "GET",
3543
+ headers,
3544
+ signal: exec.signal
3545
+ });
3546
+ if (!poll.ok) throw await httpLlmError(poll, "video_generate");
3547
+ const status = parseVideoStatusResponse(await poll.json());
3548
+ if (status.status === "done") {
3549
+ done = status;
3550
+ break;
3551
+ }
3552
+ if (status.status === "failed" || status.status === "expired") throw new Error(`video_generate: generation ${status.status} (request ${requestId})` + (status.detail === void 0 ? "" : `: ${status.detail}`));
3553
+ if (Date.now() >= deadline) throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
3554
+ }
3555
+ const download = await fetchFn(done.url, {
3556
+ method: "GET",
3557
+ signal: exec.signal
3558
+ });
3559
+ if (!download.ok) throw await httpLlmError(download, "video_generate download");
3560
+ const data = Buffer.from(await download.arrayBuffer());
3561
+ const directory = options.videosDir ?? videosDirectory();
3562
+ await mkdir(directory, { recursive: true });
3563
+ const path = join(directory, videoFileName());
3564
+ await writeFile(path, data);
3565
+ return {
3566
+ path,
3567
+ url: done.url,
3568
+ ...done.duration === void 0 ? {} : { duration: done.duration }
3569
+ };
3570
+ }
3571
+ });
3572
+ }
3573
+
3294
3574
  //#endregion
3295
3575
  //#region src/index.ts
3296
3576
  const name = "dsh-plugin-subscriptions";
@@ -3420,6 +3700,12 @@ var SubscriptionsAuthController = class {
3420
3700
  dataBase64: Buffer.from(stored.data).toString("base64")
3421
3701
  };
3422
3702
  }
3703
+ async readVideo(name$1, signal) {
3704
+ return {
3705
+ mediaType: "video/mp4",
3706
+ dataBase64: (await readFile(join(videosDirectory(), name$1), { signal })).toString("base64")
3707
+ };
3708
+ }
3423
3709
  async status(provider) {
3424
3710
  const session = await getSession(provider);
3425
3711
  const account = accountOf(provider, session);
@@ -3577,7 +3863,10 @@ function apply(ctx, config) {
3577
3863
  }
3578
3864
  registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments, usageFetchers));
3579
3865
  ctx.inject(["tools"], (toolsCtx) => {
3580
- if (grokTokens !== void 0) toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
3866
+ if (grokTokens !== void 0) {
3867
+ toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
3868
+ toolsCtx.tools.register(createVideoGenerateTool({ tokens: grokTokens }));
3869
+ }
3581
3870
  if (codexTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({
3582
3871
  tokens: codexTokens,
3583
3872
  resolveAttachments,
@@ -57,8 +57,11 @@ export declare const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usa
57
57
  /**
58
58
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
59
59
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
60
- * primary window is the rolling session (5-hour) lane, the secondary window
61
- * the weekly lane; the lookup itself consumes no rate-limit budget.
60
+ * windows are classified by their reported duration (`limit_window_seconds`)
61
+ * rather than by slot, since the backend has been observed to report the
62
+ * weekly lane as `primary_window` without a secondary window; slot order is
63
+ * kept only as a fallback when the duration is absent. The lookup itself
64
+ * consumes no rate-limit budget.
62
65
  * @param session - the stored session (used as-is; never refreshed here).
63
66
  * @param fetchFn - fetch implementation (injectable for tests).
64
67
  * @param signal - caller cancellation from the RPC transport.
@@ -189,8 +189,31 @@ export function isCodexPermanentRefreshError(error) {
189
189
  && PERMANENT_REFRESH_CODES.has(error.oauthCode);
190
190
  }
191
191
  export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
192
+ /** Seconds of the canonical 5-hour session and 7-day weekly windows. */
193
+ const SESSION_WINDOW_SECONDS = 5 * 60 * 60;
194
+ const WEEKLY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
195
+ /** Whether a reported duration approximately matches the expected window length. */
196
+ function matchesWindow(seconds, expected) {
197
+ return seconds >= expected * 0.95 && seconds <= expected * 1.05;
198
+ }
199
+ /**
200
+ * Classify a wham/usage window by its reported duration. The backend has been
201
+ * observed to place the weekly lane in `primary_window` with no secondary
202
+ * window, so slot position alone is unreliable; the caller's positional
203
+ * fallback applies only when the duration is absent.
204
+ */
205
+ function codexWindowKind(window, fallback) {
206
+ const seconds = window.limit_window_seconds;
207
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0)
208
+ return fallback;
209
+ if (matchesWindow(seconds, SESSION_WINDOW_SECONDS))
210
+ return 'session';
211
+ if (matchesWindow(seconds, WEEKLY_WINDOW_SECONDS))
212
+ return 'weekly';
213
+ return 'other';
214
+ }
192
215
  /** Map one wham/usage window into a {@link UsageWindow}; undefined when unusable. */
193
- function codexUsageWindow(value, kind) {
216
+ function codexUsageWindow(value, fallbackKind) {
194
217
  if (typeof value !== 'object' || value === null)
195
218
  return undefined;
196
219
  const window = value;
@@ -203,13 +226,20 @@ function codexUsageWindow(value, kind) {
203
226
  else if (typeof window.reset_after_seconds === 'number' && window.reset_after_seconds > 0) {
204
227
  resetsAt = Date.now() + window.reset_after_seconds * 1000;
205
228
  }
206
- return { kind, usedPercent: window.used_percent, ...resetsAt === undefined ? {} : { resetsAt } };
229
+ return {
230
+ kind: codexWindowKind(window, fallbackKind),
231
+ usedPercent: window.used_percent,
232
+ ...resetsAt === undefined ? {} : { resetsAt },
233
+ };
207
234
  }
208
235
  /**
209
236
  * Fetch the codex subscription usage from the ChatGPT backend wham/usage
210
237
  * endpoint (the source of the codex CLI `/status` rate-limit lines). The
211
- * primary window is the rolling session (5-hour) lane, the secondary window
212
- * the weekly lane; the lookup itself consumes no rate-limit budget.
238
+ * windows are classified by their reported duration (`limit_window_seconds`)
239
+ * rather than by slot, since the backend has been observed to report the
240
+ * weekly lane as `primary_window` without a secondary window; slot order is
241
+ * kept only as a fallback when the duration is absent. The lookup itself
242
+ * consumes no rate-limit budget.
213
243
  * @param session - the stored session (used as-is; never refreshed here).
214
244
  * @param fetchFn - fetch implementation (injectable for tests).
215
245
  * @param signal - caller cancellation from the RPC transport.
@@ -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;