dsh-plugin-subscriptions 0.1.0 → 0.1.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.
package/lib/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, attributionHeaders, errorChain, isContextWindowExceededError, isQuotaExceededError } from "@deepseek-ai/dsh-llm";
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";
5
6
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { dirname, join } from "node:path";
7
+ import { basename, dirname, join } from "node:path";
7
8
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
8
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
9
10
 
@@ -363,6 +364,13 @@ async function deleteSession(provider, path = authFilePath()) {
363
364
  //#region src/auth/rpc.ts
364
365
  /** The RPC channel this plugin registers on the host connection. */
365
366
  const SUBSCRIPTIONS_AUTH_CHANNEL = "/subscriptions-auth";
367
+ /** Media types the attachment store accepts (ImageMediaType). */
368
+ const IMAGE_MEDIA_TYPES = [
369
+ "image/png",
370
+ "image/jpeg",
371
+ "image/webp",
372
+ "image/gif"
373
+ ];
366
374
  /** Payload carried no usable provider id — an RPC client bug, not a server failure. */
367
375
  var BadRequest = class extends Error {};
368
376
  function ok(value) {
@@ -401,7 +409,34 @@ function readString(payload, field) {
401
409
  if (typeof value !== "string" || value.length === 0) throw new BadRequest(`payload.${field} must be a non-empty string`);
402
410
  return value;
403
411
  }
404
- async function dispatch(controller, endpoint, payload) {
412
+ /** Validate the `image` endpoint's payload into a full attachment reference. */
413
+ function readImageRef(payload) {
414
+ if (typeof payload !== "object" || payload === null) throw new BadRequest("payload must be an object");
415
+ const record = payload;
416
+ const attachmentId = record.attachmentId;
417
+ if (typeof attachmentId !== "string" || attachmentId.length === 0) throw new BadRequest("payload.attachmentId must be a non-empty string");
418
+ const mediaType = record.mediaType;
419
+ if (typeof mediaType !== "string" || !IMAGE_MEDIA_TYPES.includes(mediaType)) throw new BadRequest(`payload.mediaType must be one of ${IMAGE_MEDIA_TYPES.join(", ")}`);
420
+ for (const field of [
421
+ "bytes",
422
+ "width",
423
+ "height"
424
+ ]) {
425
+ const value = record[field];
426
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new BadRequest(`payload.${field} must be a positive integer`);
427
+ }
428
+ const name$1 = record.name;
429
+ if (name$1 !== void 0 && typeof name$1 !== "string") throw new BadRequest("payload.name must be a string when present");
430
+ return {
431
+ attachmentId: AttachmentId(attachmentId),
432
+ mediaType,
433
+ bytes: record.bytes,
434
+ width: record.width,
435
+ height: record.height,
436
+ ...name$1 === void 0 ? {} : { name: name$1 }
437
+ };
438
+ }
439
+ async function dispatch(controller, endpoint, payload, signal) {
405
440
  switch (endpoint) {
406
441
  case "status": {
407
442
  const entries = await Promise.all(PROVIDER_IDS.map(async (provider) => [provider, await controller.status(provider)]));
@@ -419,6 +454,7 @@ async function dispatch(controller, endpoint, payload) {
419
454
  case "logout":
420
455
  await controller.logout(readProvider(payload));
421
456
  return ok({ ok: true });
457
+ case "image": return ok(await controller.readImage(readImageRef(payload), signal));
422
458
  default: throw new BadRequest(`unknown /subscriptions-auth endpoint "${endpoint}"`);
423
459
  }
424
460
  }
@@ -430,9 +466,9 @@ async function dispatch(controller, endpoint, payload) {
430
466
  function registerAuthRpc(ctx, controller) {
431
467
  ctx.inject(["connection"], (ctx$1) => {
432
468
  const connection = ctx$1.get("connection");
433
- ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload) => {
469
+ ctx$1.effect(() => connection.rpc.handle(SUBSCRIPTIONS_AUTH_CHANNEL, async (endpoint, payload, signal) => {
434
470
  try {
435
- return await dispatch(controller, endpoint, payload);
471
+ return await dispatch(controller, endpoint, payload, signal);
436
472
  } catch (error) {
437
473
  return failure(error);
438
474
  }
@@ -1364,11 +1400,10 @@ var CodexAdapter = class extends LlmAdapter {
1364
1400
  }));
1365
1401
  }
1366
1402
  async listModels(provider) {
1367
- const session = await this.options.tokens.peek();
1368
- if (session === void 0) return [];
1403
+ if (await this.options.tokens.peek() === void 0) return [];
1369
1404
  if (!this.options.discovery) return this.staticModels(provider);
1370
1405
  try {
1371
- return (await this.catalog.get(() => fetchCodexModels(session, this.options.fetchFn))).map((model) => ({
1406
+ return (await this.catalog.get(async () => fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn))).map((model) => ({
1372
1407
  provider,
1373
1408
  id: model.id,
1374
1409
  name: model.name,
@@ -1376,6 +1411,7 @@ var CodexAdapter = class extends LlmAdapter {
1376
1411
  inputModalities: CODEX_MODALITIES
1377
1412
  }));
1378
1413
  } catch (error) {
1414
+ if (error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL")) return [];
1379
1415
  if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
1380
1416
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
1381
1417
  return this.staticModels(provider);
@@ -2245,17 +2281,17 @@ var GrokAdapter = class extends LlmAdapter {
2245
2281
  }));
2246
2282
  }
2247
2283
  async listModels(provider) {
2248
- const session = await this.options.tokens.peek();
2249
- if (session === void 0) return [];
2284
+ if (await this.options.tokens.peek() === void 0) return [];
2250
2285
  if (!this.options.discovery) return this.staticModels(provider);
2251
2286
  try {
2252
- return (await this.catalog.get(() => fetchGrokModels(session, this.options.fetchFn))).map((model) => ({
2287
+ return (await this.catalog.get(async () => fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn))).map((model) => ({
2253
2288
  provider,
2254
2289
  id: model.id,
2255
2290
  name: model.name,
2256
2291
  inputModalities: grokModalities(model.id)
2257
2292
  }));
2258
2293
  } catch (error) {
2294
+ if (error instanceof LlmError && (error.code === "MISSING_CREDENTIAL" || error.code === "INVALID_CREDENTIAL")) return [];
2259
2295
  if (error instanceof OAuthEndpointError && error.status === 401) this.catalog.invalidate();
2260
2296
  this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
2261
2297
  return this.staticModels(provider);
@@ -2557,6 +2593,50 @@ function truncate(text, max = 60) {
2557
2593
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
2558
2594
  }
2559
2595
  /**
2596
+ * Non-throwing image-capability check for the calling route (read_image's
2597
+ * gate, softened: a generated image that cannot enter history degrades to the
2598
+ * text-only result instead of failing the call). Resolves the session's
2599
+ * latest routed provider/model and answers whether the exact route declares
2600
+ * image input; any resolution failure means "no".
2601
+ */
2602
+ async function routeDeclaresImageInput(resolveLlm, exec) {
2603
+ const llm = resolveLlm?.();
2604
+ const routed = exec.agent?.session.requestHeader()?.config;
2605
+ const provider = routed?.provider ?? exec.agent?.options.provider;
2606
+ const model = routed?.model ?? exec.agent?.options.model;
2607
+ if (llm === void 0 || provider === void 0 || model === void 0) return false;
2608
+ try {
2609
+ return (await llm.resolveModelInfo(provider, model, exec.signal)).inputModalities?.includes("image") === true;
2610
+ } catch {
2611
+ return false;
2612
+ }
2613
+ }
2614
+ /** Re-brand one canonical image entry into the attachment reference an ImageBlock carries. */
2615
+ function imageRefFromValue(image) {
2616
+ return {
2617
+ attachmentId: AttachmentId(image.attachmentId),
2618
+ mediaType: image.mediaType,
2619
+ bytes: image.bytes,
2620
+ width: image.width,
2621
+ height: image.height,
2622
+ ...image.name === void 0 ? {} : { name: image.name }
2623
+ };
2624
+ }
2625
+ /** Project the canonical value into the model-facing text + image blocks. */
2626
+ function imageGenerateContent(value) {
2627
+ return [imageGenerateText(value), ...(value.images ?? []).map((image) => ({
2628
+ type: "image",
2629
+ attachment: imageRefFromValue(image)
2630
+ }))];
2631
+ }
2632
+ /** The text summary of one generation, shared by the model content and the UI card. */
2633
+ function imageGenerateText(value) {
2634
+ return {
2635
+ type: "text",
2636
+ text: `Saved ${value.paths.length} image(s):\n${value.paths.map((path) => `- ${path}`).join("\n")}` + (value.revisedPrompt === void 0 ? "" : `\n\nRevised prompt: ${value.revisedPrompt}`)
2637
+ };
2638
+ }
2639
+ /**
2560
2640
  * Build the `image_generate` tool definition.
2561
2641
  * @param options - codex session source, fetch implementation, and image directory.
2562
2642
  * @returns the tool to register on `ctx.tools`.
@@ -2564,7 +2644,7 @@ function truncate(text, max = 60) {
2564
2644
  function createImageGenerateTool(options) {
2565
2645
  return defineTool({
2566
2646
  name: "image_generate",
2567
- description: "Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. Returns the saved file paths.",
2647
+ description: "Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. Returns the saved file paths; on image-capable models the image itself is attached.",
2568
2648
  parameters: {
2569
2649
  prompt: {
2570
2650
  type: "string",
@@ -2601,19 +2681,56 @@ function createImageGenerateTool(options) {
2601
2681
  items: { type: "string" },
2602
2682
  required: true
2603
2683
  },
2684
+ images: {
2685
+ type: "array",
2686
+ items: {
2687
+ type: "object",
2688
+ additionalProperties: false,
2689
+ properties: {
2690
+ attachmentId: {
2691
+ type: "string",
2692
+ required: true
2693
+ },
2694
+ mediaType: {
2695
+ type: "string",
2696
+ enum: [
2697
+ "image/png",
2698
+ "image/jpeg",
2699
+ "image/webp",
2700
+ "image/gif"
2701
+ ],
2702
+ required: true
2703
+ },
2704
+ bytes: {
2705
+ type: "integer",
2706
+ required: true
2707
+ },
2708
+ width: {
2709
+ type: "integer",
2710
+ required: true
2711
+ },
2712
+ height: {
2713
+ type: "integer",
2714
+ required: true
2715
+ },
2716
+ name: { type: "string" }
2717
+ }
2718
+ }
2719
+ },
2604
2720
  revisedPrompt: { type: "string" }
2605
2721
  },
2606
2722
  additionalProperties: false
2607
2723
  },
2608
- render: (_args, value) => [{
2609
- type: "text",
2610
- text: `Saved ${value.paths.length} image(s):\n${value.paths.map((path) => `- ${path}`).join("\n")}` + (value.revisedPrompt === void 0 ? "" : `\n\nRevised prompt: ${value.revisedPrompt}`)
2611
- }]
2724
+ render: (_args, value) => imageGenerateContent(value)
2612
2725
  },
2613
2726
  presentCall: (args) => ({
2614
2727
  card: "generic",
2615
2728
  title: `image_generate: ${truncate(args.prompt)}`
2616
2729
  }),
2730
+ presentResult: (_args, result) => ({
2731
+ card: "generic",
2732
+ content: result.content.filter((block) => block.type === "text")
2733
+ }),
2617
2734
  async execute(args, exec) {
2618
2735
  const body = buildImageGenerateBody(args);
2619
2736
  const session = await options.tokens.session();
@@ -2639,11 +2756,38 @@ function createImageGenerateTool(options) {
2639
2756
  await writeFile(path, image.data);
2640
2757
  paths.push(path);
2641
2758
  }
2759
+ const attachments = options.resolveAttachments?.();
2760
+ const imageCapable = attachments !== void 0 && await routeDeclaresImageInput(options.resolveLlm, exec);
2761
+ const refs = [];
2762
+ if (attachments !== void 0 && imageCapable) for (const [index, image] of images.entries()) {
2763
+ const ref = await attachments.saveImage({
2764
+ data: image.data,
2765
+ mediaType: "image/png",
2766
+ name: basename(paths[index])
2767
+ });
2768
+ refs.push({
2769
+ attachmentId: ref.attachmentId,
2770
+ mediaType: ref.mediaType,
2771
+ bytes: ref.bytes,
2772
+ width: ref.width,
2773
+ height: ref.height,
2774
+ ...ref.name === void 0 ? {} : { name: ref.name }
2775
+ });
2776
+ }
2642
2777
  const revisedPrompt = images.find((image) => image.revisedPrompt !== void 0)?.revisedPrompt;
2643
- return {
2778
+ const value = {
2644
2779
  paths,
2780
+ ...refs.length > 0 ? { images: refs } : {},
2645
2781
  ...revisedPrompt === void 0 ? {} : { revisedPrompt }
2646
2782
  };
2783
+ if (exec.parent !== void 0 && refs.length > 0) exec.deferContext(createUserMessage({
2784
+ content: imageGenerateContent(value),
2785
+ source: {
2786
+ kind: "plugin",
2787
+ plugin: "dsh-plugin-subscriptions"
2788
+ }
2789
+ }));
2790
+ return value;
2647
2791
  }
2648
2792
  });
2649
2793
  }
@@ -2768,9 +2912,19 @@ function planOf(provider, session) {
2768
2912
  var SubscriptionsAuthController = class {
2769
2913
  /** Last login failure per provider, surfaced as `detail` until the next success. */
2770
2914
  lastError = /* @__PURE__ */ new Map();
2771
- constructor(flows, onAuthChanged) {
2915
+ constructor(flows, onAuthChanged, resolveAttachments) {
2772
2916
  this.flows = flows;
2773
2917
  this.onAuthChanged = onAuthChanged;
2918
+ this.resolveAttachments = resolveAttachments;
2919
+ }
2920
+ async readImage(ref, signal) {
2921
+ const attachments = this.resolveAttachments();
2922
+ if (attachments === void 0) throw new Error("no attachment service is mounted; generated-image bytes are unavailable");
2923
+ const stored = await attachments.readImage(ref, signal);
2924
+ return {
2925
+ mediaType: stored.ref.mediaType,
2926
+ dataBase64: Buffer.from(stored.data).toString("base64")
2927
+ };
2774
2928
  }
2775
2929
  async status(provider) {
2776
2930
  const session = await getSession(provider);
@@ -2921,10 +3075,14 @@ function apply(ctx, config) {
2921
3075
  break;
2922
3076
  }
2923
3077
  }
2924
- registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged));
3078
+ registerAuthRpc(ctx, new SubscriptionsAuthController(flows, authChanged, resolveAttachments));
2925
3079
  ctx.inject(["tools"], (toolsCtx) => {
2926
3080
  if (grokTokens !== void 0) toolsCtx.tools.register(createXSearchTool({ tokens: grokTokens }));
2927
- if (codexTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({ tokens: codexTokens }));
3081
+ if (codexTokens !== void 0) toolsCtx.tools.register(createImageGenerateTool({
3082
+ tokens: codexTokens,
3083
+ resolveAttachments,
3084
+ resolveLlm: () => ctx.get("llm")
3085
+ }));
2928
3086
  });
2929
3087
  }
2930
3088
 
@@ -295,7 +295,10 @@ export class CodexAdapter extends LlmAdapter {
295
295
  if (!this.options.discovery)
296
296
  return this.staticModels(provider);
297
297
  try {
298
- const discovered = await this.catalog.get(() => fetchCodexModels(session, this.options.fetchFn));
298
+ // The fetcher runs only on a cache miss, and resolves the session
299
+ // through the refresh-aware path so an expired access token renews here
300
+ // instead of failing discovery into the static fallback.
301
+ const discovered = await this.catalog.get(async () => fetchCodexModels(await this.options.tokens.session(), this.options.fetchFn));
299
302
  return discovered.map(model => ({
300
303
  provider,
301
304
  id: model.id,
@@ -305,6 +308,11 @@ export class CodexAdapter extends LlmAdapter {
305
308
  }));
306
309
  }
307
310
  catch (error) {
311
+ // A permanent refresh failure deletes the stored session: the provider
312
+ // is logged out, so hide it instead of showing a stale static catalog.
313
+ if (error instanceof LlmError
314
+ && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL'))
315
+ return [];
308
316
  if (error instanceof OAuthEndpointError && error.status === 401)
309
317
  this.catalog.invalidate();
310
318
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
@@ -252,7 +252,10 @@ export class GrokAdapter extends LlmAdapter {
252
252
  if (!this.options.discovery)
253
253
  return this.staticModels(provider);
254
254
  try {
255
- const discovered = await this.catalog.get(() => fetchGrokModels(session, this.options.fetchFn));
255
+ // The fetcher runs only on a cache miss, and resolves the session
256
+ // through the refresh-aware path so an expired access token renews here
257
+ // instead of failing discovery into the static fallback.
258
+ const discovered = await this.catalog.get(async () => fetchGrokModels(await this.options.tokens.session(), this.options.fetchFn));
256
259
  return discovered.map(model => ({
257
260
  provider,
258
261
  id: model.id,
@@ -261,6 +264,11 @@ export class GrokAdapter extends LlmAdapter {
261
264
  }));
262
265
  }
263
266
  catch (error) {
267
+ // A permanent refresh failure deletes the stored session: the provider
268
+ // is logged out, so hide it instead of showing a stale static catalog.
269
+ if (error instanceof LlmError
270
+ && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL'))
271
+ return [];
264
272
  if (error instanceof OAuthEndpointError && error.status === 401)
265
273
  this.catalog.invalidate();
266
274
  this.options.onWarn?.(`grok model discovery failed; using the built-in catalog (${errorChain(error)})`);
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * `image_generate` tool: generate images through the ChatGPT/Codex
3
- * subscription's image endpoint and save them as PNG files under the harness
4
- * home. Mirrors codex-rs `codex-api/src/images.rs`: POST
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
5
8
  * `/backend-api/codex/images/generations` with the responses call's auth
6
9
  * headers; the response carries base64 PNG data.
7
10
  */
11
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
+ import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
8
13
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
9
14
  import type { CodexSession } from '../auth/store.js';
10
15
  import { TokenManager } from '../providers/common.js';
@@ -21,6 +26,10 @@ export interface ImageGenerateToolOptions {
21
26
  fetchFn?: FetchFn;
22
27
  /** Directory override for saved images (defaults under the harness home). */
23
28
  imagesDir?: string;
29
+ /** Lazy attachment-store lookup; absent or unmounted store keeps the text-only result. */
30
+ resolveAttachments?: () => AttachmentStore | undefined;
31
+ /** Lazy llm-service lookup for the image-capability route check. */
32
+ resolveLlm?: () => LlmRuntime | undefined;
24
33
  }
25
34
  /** The wire request body for one generation call. */
26
35
  export interface ImageGenerateRequestBody {
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * `image_generate` tool: generate images through the ChatGPT/Codex
3
- * subscription's image endpoint and save them as PNG files under the harness
4
- * home. Mirrors codex-rs `codex-api/src/images.rs`: POST
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
5
8
  * `/backend-api/codex/images/generations` with the responses call's auth
6
9
  * headers; the response carries base64 PNG data.
7
10
  */
8
11
  import { mkdir, writeFile } from 'node:fs/promises';
9
- import { join } from 'node:path';
12
+ import { basename, join } from 'node:path';
10
13
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths';
14
+ import { AttachmentId } from '@deepseek-ai/dsh-attachment';
15
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
11
16
  import { defineTool } from '@deepseek-ai/dsh-tools';
12
17
  import { httpLlmError, TokenManager } from '../providers/common.js';
13
18
  /** Endpoint the generation request is posted to. */
@@ -67,6 +72,53 @@ function imageFileName(index) {
67
72
  function truncate(text, max = 60) {
68
73
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
69
74
  }
75
+ /**
76
+ * Non-throwing image-capability check for the calling route (read_image's
77
+ * gate, softened: a generated image that cannot enter history degrades to the
78
+ * text-only result instead of failing the call). Resolves the session's
79
+ * latest routed provider/model and answers whether the exact route declares
80
+ * image input; any resolution failure means "no".
81
+ */
82
+ async function routeDeclaresImageInput(resolveLlm, exec) {
83
+ const llm = resolveLlm?.();
84
+ const routed = exec.agent?.session.requestHeader()?.config;
85
+ const provider = routed?.provider ?? exec.agent?.options.provider;
86
+ const model = routed?.model ?? exec.agent?.options.model;
87
+ if (llm === undefined || provider === undefined || model === undefined)
88
+ return false;
89
+ try {
90
+ const active = await llm.resolveModelInfo(provider, model, exec.signal);
91
+ return active.inputModalities?.includes('image') === true;
92
+ }
93
+ catch {
94
+ // An unresolvable route cannot be proven image-capable: degrade to text.
95
+ return false;
96
+ }
97
+ }
98
+ /** Re-brand one canonical image entry into the attachment reference an ImageBlock carries. */
99
+ function imageRefFromValue(image) {
100
+ return {
101
+ attachmentId: AttachmentId(image.attachmentId),
102
+ mediaType: image.mediaType,
103
+ bytes: image.bytes,
104
+ width: image.width,
105
+ height: image.height,
106
+ ...image.name === undefined ? {} : { name: image.name },
107
+ };
108
+ }
109
+ /** Project the canonical value into the model-facing text + image blocks. */
110
+ function imageGenerateContent(value) {
111
+ return [
112
+ imageGenerateText(value),
113
+ ...(value.images ?? []).map(image => ({ type: 'image', attachment: imageRefFromValue(image) })),
114
+ ];
115
+ }
116
+ /** The text summary of one generation, shared by the model content and the UI card. */
117
+ function imageGenerateText(value) {
118
+ const text = `Saved ${value.paths.length} image(s):\n${value.paths.map(path => `- ${path}`).join('\n')}`
119
+ + (value.revisedPrompt === undefined ? '' : `\n\nRevised prompt: ${value.revisedPrompt}`);
120
+ return { type: 'text', text };
121
+ }
70
122
  /**
71
123
  * Build the `image_generate` tool definition.
72
124
  * @param options - codex session source, fetch implementation, and image directory.
@@ -76,7 +128,7 @@ export function createImageGenerateTool(options) {
76
128
  return defineTool({
77
129
  name: 'image_generate',
78
130
  description: 'Generate an image with the ChatGPT subscription (gpt-image-2) and save it as a PNG file. '
79
- + 'Returns the saved file paths.',
131
+ + 'Returns the saved file paths; on image-capable models the image itself is attached.',
80
132
  parameters: {
81
133
  prompt: { type: 'string', required: true, description: 'What the image should show.' },
82
134
  size: {
@@ -95,20 +147,38 @@ export function createImageGenerateTool(options) {
95
147
  type: 'object',
96
148
  properties: {
97
149
  paths: { type: 'array', items: { type: 'string' }, required: true },
150
+ images: {
151
+ type: 'array',
152
+ items: {
153
+ type: 'object',
154
+ additionalProperties: false,
155
+ properties: {
156
+ attachmentId: { type: 'string', required: true },
157
+ mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true },
158
+ bytes: { type: 'integer', required: true },
159
+ width: { type: 'integer', required: true },
160
+ height: { type: 'integer', required: true },
161
+ name: { type: 'string' },
162
+ },
163
+ },
164
+ },
98
165
  revisedPrompt: { type: 'string' },
99
166
  },
100
167
  additionalProperties: false,
101
168
  },
102
- render: (_args, value) => [{
103
- type: 'text',
104
- text: `Saved ${value.paths.length} image(s):\n${value.paths.map(path => `- ${path}`).join('\n')}`
105
- + (value.revisedPrompt === undefined ? '' : `\n\nRevised prompt: ${value.revisedPrompt}`),
106
- }],
169
+ render: (_args, value) => imageGenerateContent(value),
107
170
  },
108
171
  presentCall: args => ({
109
172
  card: 'generic',
110
173
  title: `image_generate: ${truncate(args.prompt)}`,
111
174
  }),
175
+ // The web UI has no image surface on tool cards and flattens result blocks
176
+ // to text/JSON, so the completed card shows the text summary only; the
177
+ // image block stays model-facing in the render output.
178
+ presentResult: (_args, result) => ({
179
+ card: 'generic',
180
+ content: result.content.filter(block => block.type === 'text'),
181
+ }),
112
182
  async execute(args, exec) {
113
183
  const body = buildImageGenerateBody(args);
114
184
  const session = await options.tokens.session();
@@ -135,8 +205,46 @@ export function createImageGenerateTool(options) {
135
205
  await writeFile(path, image.data);
136
206
  paths.push(path);
137
207
  }
208
+ // Inline display requires durable attachment references, and those may
209
+ // only enter session history on a route that declares image input.
210
+ // Either condition failing degrades to the text-only result.
211
+ const attachments = options.resolveAttachments?.();
212
+ const imageCapable = attachments !== undefined
213
+ && await routeDeclaresImageInput(options.resolveLlm, exec);
214
+ const refs = [];
215
+ if (attachments !== undefined && imageCapable) {
216
+ for (const [index, image] of images.entries()) {
217
+ const ref = await attachments.saveImage({
218
+ data: image.data,
219
+ mediaType: 'image/png',
220
+ name: basename(paths[index]),
221
+ });
222
+ refs.push({
223
+ attachmentId: ref.attachmentId,
224
+ mediaType: ref.mediaType,
225
+ bytes: ref.bytes,
226
+ width: ref.width,
227
+ height: ref.height,
228
+ ...ref.name === undefined ? {} : { name: ref.name },
229
+ });
230
+ }
231
+ }
138
232
  const revisedPrompt = images.find(image => image.revisedPrompt !== undefined)?.revisedPrompt;
139
- return { paths, ...revisedPrompt === undefined ? {} : { revisedPrompt } };
233
+ const value = {
234
+ paths,
235
+ ...refs.length > 0 ? { images: refs } : {},
236
+ ...revisedPrompt === undefined ? {} : { revisedPrompt },
237
+ };
238
+ // Nested (Code Mode) dispatches have no card: defer the image content as
239
+ // a user message so the next model request still sees it (read_image's
240
+ // pattern).
241
+ if (exec.parent !== undefined && refs.length > 0) {
242
+ exec.deferContext(createUserMessage({
243
+ content: imageGenerateContent(value),
244
+ source: { kind: 'plugin', plugin: 'dsh-plugin-subscriptions' },
245
+ }));
246
+ }
247
+ return value;
140
248
  },
141
249
  });
142
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subscriptions",
3
- "version": "0.1.0",
3
+ "version": "0.1.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": {