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.
- package/README.md +11 -2
- package/README.zh.md +11 -2
- package/lib/auth/rpc.d.ts +14 -0
- package/lib/auth/rpc.js +18 -0
- package/lib/client/VideoGenerateToolview.d.ts +42 -0
- package/lib/client/VideoGenerateToolview.js +178 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client/index.js +10 -0
- package/lib/client/locales.d.ts +6 -0
- package/lib/client/locales.js +6 -0
- package/lib/client.js +282 -54
- package/lib/client.js.map +1 -1
- package/lib/index.js +408 -41
- package/lib/providers/codex.d.ts +5 -2
- package/lib/providers/codex.js +34 -4
- package/lib/tools/image-generate.d.ts +55 -19
- package/lib/tools/image-generate.js +130 -31
- package/lib/tools/video-generate.d.ts +89 -0
- package/lib/tools/video-generate.js +255 -0
- package/package.json +8 -6
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,
|
|
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
|
-
*
|
|
1531
|
-
*
|
|
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$
|
|
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$
|
|
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",
|
|
@@ -3041,13 +3078,17 @@ function createXSearchTool(options) {
|
|
|
3041
3078
|
|
|
3042
3079
|
//#endregion
|
|
3043
3080
|
//#region src/tools/image-generate.ts
|
|
3044
|
-
/** Endpoint the generation request is posted to. */
|
|
3081
|
+
/** Endpoint the codex generation request is posted to. */
|
|
3045
3082
|
const IMAGE_GENERATE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
|
|
3046
3083
|
/** The image model the codex subscription endpoint serves. */
|
|
3047
3084
|
const IMAGE_GENERATE_MODEL = "gpt-image-2";
|
|
3085
|
+
/** Endpoint the grok generation request is posted to. */
|
|
3086
|
+
const GROK_IMAGE_GENERATE_URL = "https://api.x.ai/v1/images/generations";
|
|
3087
|
+
/** The image model the grok subscription endpoint serves. */
|
|
3088
|
+
const GROK_IMAGE_GENERATE_MODEL = "grok-imagine-image-2.0";
|
|
3048
3089
|
/**
|
|
3049
|
-
* Assemble the request body from tool arguments (hand-checks the
|
|
3050
|
-
* prompt the schema DSL cannot express).
|
|
3090
|
+
* Assemble the codex request body from tool arguments (hand-checks the
|
|
3091
|
+
* non-empty prompt the schema DSL cannot express).
|
|
3051
3092
|
*/
|
|
3052
3093
|
function buildImageGenerateBody(args) {
|
|
3053
3094
|
const prompt = args.prompt.trim();
|
|
@@ -3059,6 +3100,30 @@ function buildImageGenerateBody(args) {
|
|
|
3059
3100
|
...args.quality === void 0 ? {} : { quality: args.quality }
|
|
3060
3101
|
};
|
|
3061
3102
|
}
|
|
3103
|
+
/** The codex `size` values mapped onto grok aspect ratios. */
|
|
3104
|
+
const GROK_ASPECT_RATIOS = {
|
|
3105
|
+
"1024x1024": "1:1",
|
|
3106
|
+
"1024x1536": "2:3",
|
|
3107
|
+
"1536x1024": "3:2",
|
|
3108
|
+
"auto": "auto"
|
|
3109
|
+
};
|
|
3110
|
+
/**
|
|
3111
|
+
* Assemble the grok request body from the same tool arguments: `size` maps
|
|
3112
|
+
* onto the nearest `aspect_ratio`, and `quality` folds into grok's low/medium
|
|
3113
|
+
* pair (`high` → `medium`, `auto` → provider default).
|
|
3114
|
+
*/
|
|
3115
|
+
function buildGrokImageGenerateBody(args) {
|
|
3116
|
+
const prompt = args.prompt.trim();
|
|
3117
|
+
if (prompt.length === 0) throw new Error("image_generate: prompt must be a non-empty string");
|
|
3118
|
+
const quality = args.quality === "low" ? "low" : args.quality === "medium" || args.quality === "high" ? "medium" : void 0;
|
|
3119
|
+
return {
|
|
3120
|
+
prompt,
|
|
3121
|
+
model: GROK_IMAGE_GENERATE_MODEL,
|
|
3122
|
+
response_format: "b64_json",
|
|
3123
|
+
...args.size === void 0 ? {} : { aspect_ratio: GROK_ASPECT_RATIOS[args.size] },
|
|
3124
|
+
...quality === void 0 ? {} : { quality }
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3062
3127
|
/**
|
|
3063
3128
|
* Parse the generations response into decodable images. Throws when the
|
|
3064
3129
|
* payload carries no usable `b64_json` entries.
|
|
@@ -3079,16 +3144,32 @@ function parseImageGenerateResponse(payload) {
|
|
|
3079
3144
|
if (images.length === 0) throw new Error("image_generate: the response carried no image data");
|
|
3080
3145
|
return images;
|
|
3081
3146
|
}
|
|
3082
|
-
/** Directory the generated
|
|
3147
|
+
/** Directory the generated image files are written to. */
|
|
3083
3148
|
function imagesDirectory() {
|
|
3084
3149
|
return dshHomePath("plugins", "subscriptions", "images");
|
|
3085
3150
|
}
|
|
3151
|
+
/**
|
|
3152
|
+
* Sniff a generated image's media type from its magic bytes (codex serves
|
|
3153
|
+
* PNG; grok's format is undocumented, so trust the bytes). Unrecognized data
|
|
3154
|
+
* defaults to PNG, matching the historical behavior.
|
|
3155
|
+
*/
|
|
3156
|
+
function sniffImageMediaType(data) {
|
|
3157
|
+
if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg";
|
|
3158
|
+
if (data.length >= 12 && data.toString("latin1", 0, 4) === "RIFF" && data.toString("latin1", 8, 12) === "WEBP") return "image/webp";
|
|
3159
|
+
return "image/png";
|
|
3160
|
+
}
|
|
3161
|
+
/** File extension for one sniffed media type. */
|
|
3162
|
+
const MEDIA_TYPE_EXTENSIONS = {
|
|
3163
|
+
"image/png": "png",
|
|
3164
|
+
"image/jpeg": "jpg",
|
|
3165
|
+
"image/webp": "webp"
|
|
3166
|
+
};
|
|
3086
3167
|
/** Timestamped, collision-safe file name for one generated image. */
|
|
3087
|
-
function imageFileName(index) {
|
|
3088
|
-
return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}
|
|
3168
|
+
function imageFileName(index, mediaType) {
|
|
3169
|
+
return `image-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}-${index}.${MEDIA_TYPE_EXTENSIONS[mediaType]}`;
|
|
3089
3170
|
}
|
|
3090
3171
|
/** Bound a call-card title's prompt. */
|
|
3091
|
-
function truncate(text, max = 60) {
|
|
3172
|
+
function truncate$1(text, max = 60) {
|
|
3092
3173
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
3093
3174
|
}
|
|
3094
3175
|
/**
|
|
@@ -3143,7 +3224,7 @@ function imageGenerateText(value) {
|
|
|
3143
3224
|
function createImageGenerateTool(options) {
|
|
3144
3225
|
return defineTool({
|
|
3145
3226
|
name: "image_generate",
|
|
3146
|
-
description: "Generate an image with the ChatGPT subscription (gpt-image-2) and save it as
|
|
3227
|
+
description: "Generate an image with the ChatGPT subscription (gpt-image-2) or the Grok subscription (grok-imagine-image-2.0) and save it as an image file. The `provider` parameter picks the preferred provider (default gpt); when the preferred one is logged out the other serves as fallback. Returns the saved file paths; on image-capable models the image itself is attached.",
|
|
3147
3228
|
parameters: {
|
|
3148
3229
|
prompt: {
|
|
3149
3230
|
type: "string",
|
|
@@ -3169,6 +3250,11 @@ function createImageGenerateTool(options) {
|
|
|
3169
3250
|
"auto"
|
|
3170
3251
|
],
|
|
3171
3252
|
description: "Rendering quality; omit for the provider default."
|
|
3253
|
+
},
|
|
3254
|
+
provider: {
|
|
3255
|
+
type: "string",
|
|
3256
|
+
enum: ["gpt", "grok"],
|
|
3257
|
+
description: "Preferred provider (default gpt); the other one serves as fallback when the preferred is logged out."
|
|
3172
3258
|
}
|
|
3173
3259
|
},
|
|
3174
3260
|
output: {
|
|
@@ -3224,36 +3310,64 @@ function createImageGenerateTool(options) {
|
|
|
3224
3310
|
},
|
|
3225
3311
|
presentCall: (args) => ({
|
|
3226
3312
|
card: "generic",
|
|
3227
|
-
title: `image_generate: ${truncate(args.prompt)}`
|
|
3313
|
+
title: `image_generate: ${truncate$1(args.prompt)}`
|
|
3228
3314
|
}),
|
|
3229
3315
|
presentResult: (_args, result) => ({
|
|
3230
3316
|
card: "generic",
|
|
3231
3317
|
content: result.content.filter((block) => block.type === "text")
|
|
3232
3318
|
}),
|
|
3233
3319
|
async execute(args, exec) {
|
|
3234
|
-
const
|
|
3235
|
-
const
|
|
3236
|
-
const
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3320
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
3321
|
+
const preferGrok = args.provider === "grok";
|
|
3322
|
+
const codexReady = options.codexTokens !== void 0 && await options.codexTokens.hasSession();
|
|
3323
|
+
const grokReady = options.grokTokens !== void 0 && await options.grokTokens.hasSession();
|
|
3324
|
+
const useGrok = preferGrok ? grokReady : grokReady && !codexReady;
|
|
3325
|
+
const useCodex = !useGrok && codexReady;
|
|
3326
|
+
let response;
|
|
3327
|
+
if (useCodex && options.codexTokens !== void 0) {
|
|
3328
|
+
const session = await options.codexTokens.session();
|
|
3329
|
+
response = await fetchFn(IMAGE_GENERATE_URL, {
|
|
3330
|
+
method: "POST",
|
|
3331
|
+
headers: {
|
|
3332
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
3333
|
+
"chatgpt-account-id": session.accountId,
|
|
3334
|
+
"originator": "codex_cli_rs",
|
|
3335
|
+
"content-type": "application/json",
|
|
3336
|
+
"accept": "application/json"
|
|
3337
|
+
},
|
|
3338
|
+
body: JSON.stringify(buildImageGenerateBody(args)),
|
|
3339
|
+
signal: exec.signal
|
|
3340
|
+
});
|
|
3341
|
+
} else if (useGrok && options.grokTokens !== void 0) {
|
|
3342
|
+
const session = await options.grokTokens.session();
|
|
3343
|
+
response = await fetchFn(GROK_IMAGE_GENERATE_URL, {
|
|
3344
|
+
method: "POST",
|
|
3345
|
+
headers: {
|
|
3346
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
3347
|
+
"content-type": "application/json",
|
|
3348
|
+
"accept": "application/json"
|
|
3349
|
+
},
|
|
3350
|
+
body: JSON.stringify(buildGrokImageGenerateBody(args)),
|
|
3351
|
+
signal: exec.signal
|
|
3352
|
+
});
|
|
3353
|
+
} else {
|
|
3354
|
+
const manager = preferGrok ? options.grokTokens ?? options.codexTokens : options.codexTokens ?? options.grokTokens;
|
|
3355
|
+
if (manager === void 0) throw new Error("image_generate: no image provider is configured");
|
|
3356
|
+
await manager.session();
|
|
3357
|
+
throw new Error("image_generate: no image provider is logged in");
|
|
3358
|
+
}
|
|
3248
3359
|
if (!response.ok) throw await httpLlmError(response, "image_generate");
|
|
3249
3360
|
const images = parseImageGenerateResponse(await response.json());
|
|
3250
3361
|
const directory = options.imagesDir ?? imagesDirectory();
|
|
3251
3362
|
await mkdir(directory, { recursive: true });
|
|
3252
3363
|
const paths = [];
|
|
3364
|
+
const mediaTypes = [];
|
|
3253
3365
|
for (const [index, image] of images.entries()) {
|
|
3254
|
-
const
|
|
3366
|
+
const mediaType = sniffImageMediaType(image.data);
|
|
3367
|
+
const path = join(directory, imageFileName(index, mediaType));
|
|
3255
3368
|
await writeFile(path, image.data);
|
|
3256
3369
|
paths.push(path);
|
|
3370
|
+
mediaTypes.push(mediaType);
|
|
3257
3371
|
}
|
|
3258
3372
|
const attachments = options.resolveAttachments?.();
|
|
3259
3373
|
const imageCapable = attachments !== void 0 && await routeDeclaresImageInput(options.resolveLlm, exec);
|
|
@@ -3261,7 +3375,7 @@ function createImageGenerateTool(options) {
|
|
|
3261
3375
|
if (attachments !== void 0 && imageCapable) for (const [index, image] of images.entries()) {
|
|
3262
3376
|
const ref = await attachments.saveImage({
|
|
3263
3377
|
data: image.data,
|
|
3264
|
-
mediaType:
|
|
3378
|
+
mediaType: mediaTypes[index],
|
|
3265
3379
|
name: basename(paths[index])
|
|
3266
3380
|
});
|
|
3267
3381
|
refs.push({
|
|
@@ -3291,6 +3405,249 @@ function createImageGenerateTool(options) {
|
|
|
3291
3405
|
});
|
|
3292
3406
|
}
|
|
3293
3407
|
|
|
3408
|
+
//#endregion
|
|
3409
|
+
//#region src/tools/video-generate.ts
|
|
3410
|
+
/** Endpoint the generation request is posted to. */
|
|
3411
|
+
const VIDEO_GENERATE_URL = "https://api.x.ai/v1/videos/generations";
|
|
3412
|
+
/** The video model the grok subscription endpoint serves. */
|
|
3413
|
+
const VIDEO_GENERATE_MODEL = "grok-imagine-video-1.5";
|
|
3414
|
+
/** Polling endpoint for one generation request. */
|
|
3415
|
+
function videoStatusUrl(requestId) {
|
|
3416
|
+
return `https://api.x.ai/v1/videos/${encodeURIComponent(requestId)}`;
|
|
3417
|
+
}
|
|
3418
|
+
/** Default delay between two status polls. */
|
|
3419
|
+
const DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
3420
|
+
/** Default overall deadline for one generation (submit → done). */
|
|
3421
|
+
const DEFAULT_MAX_WAIT_MS = 10 * 6e4;
|
|
3422
|
+
/** xAI's supported clip length range in seconds. */
|
|
3423
|
+
const DURATION_RANGE = {
|
|
3424
|
+
min: 1,
|
|
3425
|
+
max: 15
|
|
3426
|
+
};
|
|
3427
|
+
/**
|
|
3428
|
+
* Assemble the request body from tool arguments (hand-checks the non-empty
|
|
3429
|
+
* prompt and the duration range the schema DSL cannot express).
|
|
3430
|
+
*/
|
|
3431
|
+
function buildVideoGenerateBody(args) {
|
|
3432
|
+
const prompt = args.prompt.trim();
|
|
3433
|
+
if (prompt.length === 0) throw new Error("video_generate: prompt must be a non-empty string");
|
|
3434
|
+
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`);
|
|
3435
|
+
const imageUrl = args.image_url?.trim();
|
|
3436
|
+
return {
|
|
3437
|
+
prompt,
|
|
3438
|
+
model: VIDEO_GENERATE_MODEL,
|
|
3439
|
+
...args.duration === void 0 ? {} : { duration: args.duration },
|
|
3440
|
+
...args.aspect_ratio === void 0 ? {} : { aspect_ratio: args.aspect_ratio },
|
|
3441
|
+
...args.resolution === void 0 ? {} : { resolution: args.resolution },
|
|
3442
|
+
...imageUrl === void 0 || imageUrl.length === 0 ? {} : { image: { url: imageUrl } }
|
|
3443
|
+
};
|
|
3444
|
+
}
|
|
3445
|
+
function isRecord(value) {
|
|
3446
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3447
|
+
}
|
|
3448
|
+
/**
|
|
3449
|
+
* Extract the request id from the submit response. Throws when the payload
|
|
3450
|
+
* carries none.
|
|
3451
|
+
*/
|
|
3452
|
+
function parseVideoStartResponse(payload) {
|
|
3453
|
+
const body = isRecord(payload) ? payload : {};
|
|
3454
|
+
if (typeof body.request_id !== "string" || body.request_id.length === 0) throw new Error("video_generate: the response carried no request_id");
|
|
3455
|
+
return body.request_id;
|
|
3456
|
+
}
|
|
3457
|
+
/**
|
|
3458
|
+
* Decode one poll response. A `done` payload without a video URL and an
|
|
3459
|
+
* unrecognized status both throw (the poll loop cannot make progress on
|
|
3460
|
+
* either).
|
|
3461
|
+
*/
|
|
3462
|
+
function parseVideoStatusResponse(payload) {
|
|
3463
|
+
const body = isRecord(payload) ? payload : {};
|
|
3464
|
+
switch (body.status) {
|
|
3465
|
+
case "pending": return { status: "pending" };
|
|
3466
|
+
case "done": {
|
|
3467
|
+
const video = isRecord(body.video) ? body.video : {};
|
|
3468
|
+
if (typeof video.url !== "string" || video.url.length === 0) throw new Error("video_generate: the completed response carried no video URL");
|
|
3469
|
+
return {
|
|
3470
|
+
status: "done",
|
|
3471
|
+
url: video.url,
|
|
3472
|
+
...typeof video.duration === "number" ? { duration: video.duration } : {}
|
|
3473
|
+
};
|
|
3474
|
+
}
|
|
3475
|
+
case "failed":
|
|
3476
|
+
case "expired": {
|
|
3477
|
+
const error = isRecord(body.error) ? body.error : {};
|
|
3478
|
+
const detail = typeof error.message === "string" && error.message.length > 0 ? error.message : typeof body.error === "string" && body.error.length > 0 ? body.error : void 0;
|
|
3479
|
+
return {
|
|
3480
|
+
status: body.status,
|
|
3481
|
+
...detail === void 0 ? {} : { detail }
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3484
|
+
default: throw new Error(`video_generate: unexpected status ${JSON.stringify(body.status)}`);
|
|
3485
|
+
}
|
|
3486
|
+
}
|
|
3487
|
+
/** Directory the downloaded MP4 files are written to. */
|
|
3488
|
+
function videosDirectory() {
|
|
3489
|
+
return dshHomePath("plugins", "subscriptions", "videos");
|
|
3490
|
+
}
|
|
3491
|
+
/** Timestamped, collision-safe file name for one generated video. */
|
|
3492
|
+
function videoFileName() {
|
|
3493
|
+
return `video-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${Math.random().toString(36).slice(2, 8)}.mp4`;
|
|
3494
|
+
}
|
|
3495
|
+
/** Bound a call-card title's prompt. */
|
|
3496
|
+
function truncate(text, max = 60) {
|
|
3497
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
3498
|
+
}
|
|
3499
|
+
/** Abort-aware sleep between two polls. */
|
|
3500
|
+
function sleep(ms, signal) {
|
|
3501
|
+
if (ms <= 0) return Promise.resolve();
|
|
3502
|
+
return new Promise((resolve, reject) => {
|
|
3503
|
+
const onAbort = () => {
|
|
3504
|
+
clearTimeout(timer);
|
|
3505
|
+
reject(signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("video_generate: aborted"));
|
|
3506
|
+
};
|
|
3507
|
+
const timer = setTimeout(() => {
|
|
3508
|
+
signal.removeEventListener("abort", onAbort);
|
|
3509
|
+
resolve();
|
|
3510
|
+
}, ms);
|
|
3511
|
+
if (signal.aborted) {
|
|
3512
|
+
onAbort();
|
|
3513
|
+
return;
|
|
3514
|
+
}
|
|
3515
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
3516
|
+
});
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* Build the `video_generate` tool definition.
|
|
3520
|
+
* @param options - grok session source, fetch implementation, and video directory.
|
|
3521
|
+
* @returns the tool to register on `ctx.tools`.
|
|
3522
|
+
*/
|
|
3523
|
+
function createVideoGenerateTool(options) {
|
|
3524
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
3525
|
+
const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
|
|
3526
|
+
return defineTool({
|
|
3527
|
+
name: "video_generate",
|
|
3528
|
+
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).`,
|
|
3529
|
+
parameters: {
|
|
3530
|
+
prompt: {
|
|
3531
|
+
type: "string",
|
|
3532
|
+
required: true,
|
|
3533
|
+
description: "What the video should show."
|
|
3534
|
+
},
|
|
3535
|
+
duration: {
|
|
3536
|
+
type: "integer",
|
|
3537
|
+
description: "Clip length in seconds (1-15); omit for the provider default."
|
|
3538
|
+
},
|
|
3539
|
+
aspect_ratio: {
|
|
3540
|
+
type: "string",
|
|
3541
|
+
enum: [
|
|
3542
|
+
"16:9",
|
|
3543
|
+
"9:16",
|
|
3544
|
+
"1:1",
|
|
3545
|
+
"4:3",
|
|
3546
|
+
"3:4",
|
|
3547
|
+
"3:2",
|
|
3548
|
+
"2:3"
|
|
3549
|
+
],
|
|
3550
|
+
description: "Output aspect ratio; omit for the provider default (16:9)."
|
|
3551
|
+
},
|
|
3552
|
+
resolution: {
|
|
3553
|
+
type: "string",
|
|
3554
|
+
enum: [
|
|
3555
|
+
"480p",
|
|
3556
|
+
"720p",
|
|
3557
|
+
"1080p"
|
|
3558
|
+
],
|
|
3559
|
+
description: "Output resolution; omit for the provider default (480p). Higher is slower."
|
|
3560
|
+
},
|
|
3561
|
+
image_url: {
|
|
3562
|
+
type: "string",
|
|
3563
|
+
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."
|
|
3564
|
+
}
|
|
3565
|
+
},
|
|
3566
|
+
output: {
|
|
3567
|
+
schema: {
|
|
3568
|
+
type: "object",
|
|
3569
|
+
properties: {
|
|
3570
|
+
path: {
|
|
3571
|
+
type: "string",
|
|
3572
|
+
required: true
|
|
3573
|
+
},
|
|
3574
|
+
url: {
|
|
3575
|
+
type: "string",
|
|
3576
|
+
required: true
|
|
3577
|
+
},
|
|
3578
|
+
duration: { type: "number" }
|
|
3579
|
+
},
|
|
3580
|
+
additionalProperties: false
|
|
3581
|
+
},
|
|
3582
|
+
render: (_args, value) => [{
|
|
3583
|
+
type: "text",
|
|
3584
|
+
text: `Saved video to ${value.path}` + (value.duration === void 0 ? "" : ` (${String(value.duration)}s)`) + `\nTemporary provider URL (expires soon): ${value.url}`
|
|
3585
|
+
}],
|
|
3586
|
+
presentationMeta: (_args, value) => ({
|
|
3587
|
+
fileName: basename(value.path),
|
|
3588
|
+
...value.duration === void 0 ? {} : { duration: value.duration }
|
|
3589
|
+
})
|
|
3590
|
+
},
|
|
3591
|
+
presentCall: (args) => ({
|
|
3592
|
+
card: "generic",
|
|
3593
|
+
title: `video_generate: ${truncate(args.prompt)}`
|
|
3594
|
+
}),
|
|
3595
|
+
async execute(args, exec) {
|
|
3596
|
+
const body = buildVideoGenerateBody(args);
|
|
3597
|
+
const session = await options.tokens.session();
|
|
3598
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
3599
|
+
const headers = {
|
|
3600
|
+
"authorization": `Bearer ${session.accessToken}`,
|
|
3601
|
+
"accept": "application/json"
|
|
3602
|
+
};
|
|
3603
|
+
const submit = await fetchFn(VIDEO_GENERATE_URL, {
|
|
3604
|
+
method: "POST",
|
|
3605
|
+
headers: {
|
|
3606
|
+
...headers,
|
|
3607
|
+
"content-type": "application/json"
|
|
3608
|
+
},
|
|
3609
|
+
body: JSON.stringify(body),
|
|
3610
|
+
signal: exec.signal
|
|
3611
|
+
});
|
|
3612
|
+
if (!submit.ok) throw await httpLlmError(submit, "video_generate");
|
|
3613
|
+
const requestId = parseVideoStartResponse(await submit.json());
|
|
3614
|
+
const deadline = Date.now() + maxWaitMs;
|
|
3615
|
+
let done;
|
|
3616
|
+
for (;;) {
|
|
3617
|
+
await sleep(pollIntervalMs, exec.signal);
|
|
3618
|
+
const poll = await fetchFn(videoStatusUrl(requestId), {
|
|
3619
|
+
method: "GET",
|
|
3620
|
+
headers,
|
|
3621
|
+
signal: exec.signal
|
|
3622
|
+
});
|
|
3623
|
+
if (!poll.ok) throw await httpLlmError(poll, "video_generate");
|
|
3624
|
+
const status = parseVideoStatusResponse(await poll.json());
|
|
3625
|
+
if (status.status === "done") {
|
|
3626
|
+
done = status;
|
|
3627
|
+
break;
|
|
3628
|
+
}
|
|
3629
|
+
if (status.status === "failed" || status.status === "expired") throw new Error(`video_generate: generation ${status.status} (request ${requestId})` + (status.detail === void 0 ? "" : `: ${status.detail}`));
|
|
3630
|
+
if (Date.now() >= deadline) throw new Error(`video_generate: timed out after ${String(maxWaitMs)}ms waiting for request ${requestId}`);
|
|
3631
|
+
}
|
|
3632
|
+
const download = await fetchFn(done.url, {
|
|
3633
|
+
method: "GET",
|
|
3634
|
+
signal: exec.signal
|
|
3635
|
+
});
|
|
3636
|
+
if (!download.ok) throw await httpLlmError(download, "video_generate download");
|
|
3637
|
+
const data = Buffer.from(await download.arrayBuffer());
|
|
3638
|
+
const directory = options.videosDir ?? videosDirectory();
|
|
3639
|
+
await mkdir(directory, { recursive: true });
|
|
3640
|
+
const path = join(directory, videoFileName());
|
|
3641
|
+
await writeFile(path, data);
|
|
3642
|
+
return {
|
|
3643
|
+
path,
|
|
3644
|
+
url: done.url,
|
|
3645
|
+
...done.duration === void 0 ? {} : { duration: done.duration }
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
});
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3294
3651
|
//#endregion
|
|
3295
3652
|
//#region src/index.ts
|
|
3296
3653
|
const name = "dsh-plugin-subscriptions";
|
|
@@ -3420,6 +3777,12 @@ var SubscriptionsAuthController = class {
|
|
|
3420
3777
|
dataBase64: Buffer.from(stored.data).toString("base64")
|
|
3421
3778
|
};
|
|
3422
3779
|
}
|
|
3780
|
+
async readVideo(name$1, signal) {
|
|
3781
|
+
return {
|
|
3782
|
+
mediaType: "video/mp4",
|
|
3783
|
+
dataBase64: (await readFile(join(videosDirectory(), name$1), { signal })).toString("base64")
|
|
3784
|
+
};
|
|
3785
|
+
}
|
|
3423
3786
|
async status(provider) {
|
|
3424
3787
|
const session = await getSession(provider);
|
|
3425
3788
|
const account = accountOf(provider, session);
|
|
@@ -3577,9 +3940,13 @@ function apply(ctx, config) {
|
|
|
3577
3940
|
}
|
|
3578
3941
|
registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments, usageFetchers));
|
|
3579
3942
|
ctx.inject(["tools"], (toolsCtx) => {
|
|
3580
|
-
if (grokTokens !== void 0)
|
|
3581
|
-
|
|
3582
|
-
tokens:
|
|
3943
|
+
if (grokTokens !== void 0) {
|
|
3944
|
+
toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
|
|
3945
|
+
toolsCtx.tools.register(createVideoGenerateTool({ tokens: grokTokens }));
|
|
3946
|
+
}
|
|
3947
|
+
if (codexTokens !== void 0 || grokTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({
|
|
3948
|
+
...codexTokens === void 0 ? {} : { codexTokens },
|
|
3949
|
+
...grokTokens === void 0 ? {} : { grokTokens },
|
|
3583
3950
|
resolveAttachments,
|
|
3584
3951
|
resolveLlm: () => ctx.get("llm")
|
|
3585
3952
|
}));
|
package/lib/providers/codex.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
61
|
-
*
|
|
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.
|