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/LICENSE +21 -0
- package/README.md +22 -1
- package/README.zh.md +23 -2
- package/lib/auth/rpc.d.ts +14 -0
- package/lib/auth/rpc.js +40 -3
- package/lib/client/ImageGenerateToolview.d.ts +48 -0
- package/lib/client/ImageGenerateToolview.js +137 -0
- package/lib/client/index.d.ts +1 -0
- package/lib/client/index.js +11 -0
- package/lib/client/locales.d.ts +16 -0
- package/lib/client/locales.js +16 -0
- package/lib/client.js +237 -31
- package/lib/client.js.map +1 -1
- package/lib/index.js +178 -20
- package/lib/providers/codex.js +9 -1
- package/lib/providers/grok.js +9 -1
- package/lib/tools/image-generate.d.ts +11 -2
- package/lib/tools/image-generate.js +118 -10
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
package/lib/providers/codex.js
CHANGED
|
@@ -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
|
-
|
|
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)})`);
|
package/lib/providers/grok.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
4
|
-
* home
|
|
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
|
|
4
|
-
* home
|
|
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
|
-
|
|
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.
|
|
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": {
|