dsh-plugin-subscriptions 0.4.0 → 0.4.2

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.
@@ -1,12 +1,16 @@
1
1
  /**
2
- * `image_generate` tool: generate images through the ChatGPT/Codex
3
- * subscription's image endpoint, save them as PNG files under the harness
4
- * home, and — when the deployment mounts an attachment store and the calling
5
- * route declares image input — also commit the bytes as durable attachments
6
- * so the images render inline and enter model context (the same path
7
- * `read_image` uses). Mirrors codex-rs `codex-api/src/images.rs`: POST
8
- * `/backend-api/codex/images/generations` with the responses call's auth
9
- * headers; the response carries base64 PNG data.
2
+ * `image_generate` tool: generate images through a subscription image
3
+ * endpoint, save them under the harness home, and — when the deployment
4
+ * mounts an attachment store and the calling route declares image input —
5
+ * also commit the bytes as durable attachments so the images render inline
6
+ * and enter model context (the same path `read_image` uses).
7
+ *
8
+ * Provider selection: the `provider` argument names the preferred provider
9
+ * (default `gpt`, i.e. the ChatGPT/Codex subscription serving gpt-image-2
10
+ * mirrors codex-rs `codex-api/src/images.rs`); when the preferred one is
11
+ * logged out the other serves as fallback (`grok` is grok-imagine-image-2.0
12
+ * via `api.x.ai/v1/images/generations` with `response_format: 'b64_json'`).
13
+ * Both answer the OpenAI images shape (`data[].b64_json`).
10
14
  */
11
15
  import { mkdir, writeFile } from 'node:fs/promises';
12
16
  import { basename, join } from 'node:path';
@@ -15,13 +19,17 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment';
15
19
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
16
20
  import { defineTool } from '@deepseek-ai/dsh-tools';
17
21
  import { httpLlmError, TokenManager } from '../providers/common.js';
18
- /** Endpoint the generation request is posted to. */
22
+ /** Endpoint the codex generation request is posted to. */
19
23
  export const IMAGE_GENERATE_URL = 'https://chatgpt.com/backend-api/codex/images/generations';
20
24
  /** The image model the codex subscription endpoint serves. */
21
25
  export const IMAGE_GENERATE_MODEL = 'gpt-image-2';
26
+ /** Endpoint the grok generation request is posted to. */
27
+ export const GROK_IMAGE_GENERATE_URL = 'https://api.x.ai/v1/images/generations';
28
+ /** The image model the grok subscription endpoint serves. */
29
+ export const GROK_IMAGE_GENERATE_MODEL = 'grok-imagine-image-2.0';
22
30
  /**
23
- * Assemble the request body from tool arguments (hand-checks the non-empty
24
- * prompt the schema DSL cannot express).
31
+ * Assemble the codex request body from tool arguments (hand-checks the
32
+ * non-empty prompt the schema DSL cannot express).
25
33
  */
26
34
  export function buildImageGenerateBody(args) {
27
35
  const prompt = args.prompt.trim();
@@ -34,6 +42,33 @@ export function buildImageGenerateBody(args) {
34
42
  ...args.quality === undefined ? {} : { quality: args.quality },
35
43
  };
36
44
  }
45
+ /** The codex `size` values mapped onto grok aspect ratios. */
46
+ const GROK_ASPECT_RATIOS = {
47
+ '1024x1024': '1:1',
48
+ '1024x1536': '2:3',
49
+ '1536x1024': '3:2',
50
+ 'auto': 'auto',
51
+ };
52
+ /**
53
+ * Assemble the grok request body from the same tool arguments: `size` maps
54
+ * onto the nearest `aspect_ratio`, and `quality` folds into grok's low/medium
55
+ * pair (`high` → `medium`, `auto` → provider default).
56
+ */
57
+ export function buildGrokImageGenerateBody(args) {
58
+ const prompt = args.prompt.trim();
59
+ if (prompt.length === 0)
60
+ throw new Error('image_generate: prompt must be a non-empty string');
61
+ const quality = args.quality === 'low' ? 'low'
62
+ : args.quality === 'medium' || args.quality === 'high' ? 'medium'
63
+ : undefined;
64
+ return {
65
+ prompt,
66
+ model: GROK_IMAGE_GENERATE_MODEL,
67
+ response_format: 'b64_json',
68
+ ...args.size === undefined ? {} : { aspect_ratio: GROK_ASPECT_RATIOS[args.size] },
69
+ ...quality === undefined ? {} : { quality },
70
+ };
71
+ }
37
72
  /**
38
73
  * Parse the generations response into decodable images. Throws when the
39
74
  * payload carries no usable `b64_json` entries.
@@ -59,14 +94,33 @@ export function parseImageGenerateResponse(payload) {
59
94
  throw new Error('image_generate: the response carried no image data');
60
95
  return images;
61
96
  }
62
- /** Directory the generated PNG files are written to. */
97
+ /** Directory the generated image files are written to. */
63
98
  export function imagesDirectory() {
64
99
  return dshHomePath('plugins', 'subscriptions', 'images');
65
100
  }
101
+ /**
102
+ * Sniff a generated image's media type from its magic bytes (codex serves
103
+ * PNG; grok's format is undocumented, so trust the bytes). Unrecognized data
104
+ * defaults to PNG, matching the historical behavior.
105
+ */
106
+ export function sniffImageMediaType(data) {
107
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
108
+ return 'image/jpeg';
109
+ if (data.length >= 12 && data.toString('latin1', 0, 4) === 'RIFF' && data.toString('latin1', 8, 12) === 'WEBP') {
110
+ return 'image/webp';
111
+ }
112
+ return 'image/png';
113
+ }
114
+ /** File extension for one sniffed media type. */
115
+ const MEDIA_TYPE_EXTENSIONS = {
116
+ 'image/png': 'png',
117
+ 'image/jpeg': 'jpg',
118
+ 'image/webp': 'webp',
119
+ };
66
120
  /** Timestamped, collision-safe file name for one generated image. */
67
- function imageFileName(index) {
121
+ function imageFileName(index, mediaType) {
68
122
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
69
- return `image-${stamp}-${Math.random().toString(36).slice(2, 8)}-${index}.png`;
123
+ return `image-${stamp}-${Math.random().toString(36).slice(2, 8)}-${index}.${MEDIA_TYPE_EXTENSIONS[mediaType]}`;
70
124
  }
71
125
  /** Bound a call-card title's prompt. */
72
126
  function truncate(text, max = 60) {
@@ -127,7 +181,10 @@ function imageGenerateText(value) {
127
181
  export function createImageGenerateTool(options) {
128
182
  return defineTool({
129
183
  name: 'image_generate',
130
- description: 'Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. '
184
+ description: 'Generate an image with the ChatGPT subscription (gpt-image-2) or the Grok '
185
+ + 'subscription (grok-imagine-image-2.0) and save it as an image file. The `provider` '
186
+ + 'parameter picks the preferred provider (default gpt); when the preferred one is logged '
187
+ + 'out the other serves as fallback. '
131
188
  + 'Returns the saved file paths; on image-capable models the image itself is attached.',
132
189
  parameters: {
133
190
  prompt: { type: 'string', required: true, description: 'What the image should show.' },
@@ -141,6 +198,11 @@ export function createImageGenerateTool(options) {
141
198
  enum: ['low', 'medium', 'high', 'auto'],
142
199
  description: 'Rendering quality; omit for the provider default.',
143
200
  },
201
+ provider: {
202
+ type: 'string',
203
+ enum: ['gpt', 'grok'],
204
+ description: 'Preferred provider (default gpt); the other one serves as fallback when the preferred is logged out.',
205
+ },
144
206
  },
145
207
  output: {
146
208
  schema: {
@@ -180,30 +242,67 @@ export function createImageGenerateTool(options) {
180
242
  content: result.content.filter(block => block.type === 'text'),
181
243
  }),
182
244
  async execute(args, exec) {
183
- const body = buildImageGenerateBody(args);
184
- const session = await options.tokens.session();
185
- const response = await (options.fetchFn ?? fetch)(IMAGE_GENERATE_URL, {
186
- method: 'POST',
187
- headers: {
188
- 'authorization': `Bearer ${session.accessToken}`,
189
- 'chatgpt-account-id': session.accountId,
190
- 'originator': 'codex_cli_rs',
191
- 'content-type': 'application/json',
192
- 'accept': 'application/json',
193
- },
194
- body: JSON.stringify(body),
195
- signal: exec.signal,
196
- });
245
+ const fetchFn = options.fetchFn ?? fetch;
246
+ // Provider selection: the preferred provider (default gpt) when logged
247
+ // in, the other one as the fallback. A configured-but-logged-out manager
248
+ // still resolves through `session()` below so the standard log-in hint
249
+ // surfaces.
250
+ const preferGrok = args.provider === 'grok';
251
+ const codexReady = options.codexTokens !== undefined && await options.codexTokens.hasSession();
252
+ const grokReady = options.grokTokens !== undefined && await options.grokTokens.hasSession();
253
+ const useGrok = preferGrok ? grokReady : grokReady && !codexReady;
254
+ const useCodex = !useGrok && codexReady;
255
+ let response;
256
+ if (useCodex && options.codexTokens !== undefined) {
257
+ const session = await options.codexTokens.session();
258
+ response = await fetchFn(IMAGE_GENERATE_URL, {
259
+ method: 'POST',
260
+ headers: {
261
+ 'authorization': `Bearer ${session.accessToken}`,
262
+ 'chatgpt-account-id': session.accountId,
263
+ 'originator': 'codex_cli_rs',
264
+ 'content-type': 'application/json',
265
+ 'accept': 'application/json',
266
+ },
267
+ body: JSON.stringify(buildImageGenerateBody(args)),
268
+ signal: exec.signal,
269
+ });
270
+ }
271
+ else if (useGrok && options.grokTokens !== undefined) {
272
+ const session = await options.grokTokens.session();
273
+ response = await fetchFn(GROK_IMAGE_GENERATE_URL, {
274
+ method: 'POST',
275
+ headers: {
276
+ 'authorization': `Bearer ${session.accessToken}`,
277
+ 'content-type': 'application/json',
278
+ 'accept': 'application/json',
279
+ },
280
+ body: JSON.stringify(buildGrokImageGenerateBody(args)),
281
+ signal: exec.signal,
282
+ });
283
+ }
284
+ else {
285
+ const manager = preferGrok
286
+ ? options.grokTokens ?? options.codexTokens
287
+ : options.codexTokens ?? options.grokTokens;
288
+ if (manager === undefined)
289
+ throw new Error('image_generate: no image provider is configured');
290
+ await manager.session(); // logged out: throws the provider's log-in hint
291
+ throw new Error('image_generate: no image provider is logged in');
292
+ }
197
293
  if (!response.ok)
198
294
  throw await httpLlmError(response, 'image_generate');
199
295
  const images = parseImageGenerateResponse(await response.json());
200
296
  const directory = options.imagesDir ?? imagesDirectory();
201
297
  await mkdir(directory, { recursive: true });
202
298
  const paths = [];
299
+ const mediaTypes = [];
203
300
  for (const [index, image] of images.entries()) {
204
- const path = join(directory, imageFileName(index));
301
+ const mediaType = sniffImageMediaType(image.data);
302
+ const path = join(directory, imageFileName(index, mediaType));
205
303
  await writeFile(path, image.data);
206
304
  paths.push(path);
305
+ mediaTypes.push(mediaType);
207
306
  }
208
307
  // Inline display requires durable attachment references, and those may
209
308
  // only enter session history on a route that declares image input.
@@ -216,7 +315,7 @@ export function createImageGenerateTool(options) {
216
315
  for (const [index, image] of images.entries()) {
217
316
  const ref = await attachments.saveImage({
218
317
  data: image.data,
219
- mediaType: 'image/png',
318
+ mediaType: mediaTypes[index],
220
319
  name: basename(paths[index]),
221
320
  });
222
321
  refs.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscriptions",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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,12 +48,6 @@
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
- },
57
51
  "peerDependencies": {
58
52
  "@deepseek-ai/cordis": "^4.0.1",
59
53
  "@deepseek-ai/dsh-attachment": "^0.1.0-rc.5",
@@ -81,5 +75,9 @@
81
75
  "react": "^18.2.0",
82
76
  "tsdown": "^0.15.0",
83
77
  "typescript": "^5.8.0"
78
+ },
79
+ "scripts": {
80
+ "build": "tsc && tsdown",
81
+ "test": "tsc -p tsconfig.test.json && node --test lib-test/test/"
84
82
  }
85
- }
83
+ }