dsh-draw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +201 -0
- package/README.es.md +194 -0
- package/README.hi.md +194 -0
- package/README.md +194 -0
- package/README.pt.md +194 -0
- package/README.zh.md +194 -0
- package/SECURITY.md +39 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/cordis.patch.yml +48 -0
- package/lib/client.js +4787 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1429 -0
- package/lib/typert.host.js +26 -0
- package/lib/types/client/DrawResultCard.d.ts +58 -0
- package/lib/types/client/DrawResultCard.d.ts.map +1 -0
- package/lib/types/client/DrawResultCard.js +48 -0
- package/lib/types/client/DrawSettingsTab.d.ts +31 -0
- package/lib/types/client/DrawSettingsTab.d.ts.map +1 -0
- package/lib/types/client/DrawSettingsTab.js +129 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +98 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +57 -0
- package/lib/types/client/present.d.ts +80 -0
- package/lib/types/client/present.d.ts.map +1 -0
- package/lib/types/client/present.js +86 -0
- package/lib/types/client/remote.d.ts +268 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/remote.js +15 -0
- package/lib/types/client/styles.d.ts +11 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +43 -0
- package/lib/types/config.d.ts +160 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +230 -0
- package/lib/types/drawer.d.ts +114 -0
- package/lib/types/drawer.d.ts.map +1 -0
- package/lib/types/drawer.js +138 -0
- package/lib/types/engine.d.ts +58 -0
- package/lib/types/engine.d.ts.map +1 -0
- package/lib/types/engine.js +135 -0
- package/lib/types/http.d.ts +89 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +127 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +78 -0
- package/lib/types/quota.d.ts +69 -0
- package/lib/types/quota.d.ts.map +1 -0
- package/lib/types/quota.js +56 -0
- package/lib/types/router.d.ts +141 -0
- package/lib/types/router.d.ts.map +1 -0
- package/lib/types/router.js +207 -0
- package/lib/types/sanitize.d.ts +40 -0
- package/lib/types/sanitize.d.ts.map +1 -0
- package/lib/types/sanitize.js +103 -0
- package/lib/types/service.d.ts +59 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/service.js +131 -0
- package/lib/types/session-events.d.ts +66 -0
- package/lib/types/session-events.d.ts.map +1 -0
- package/lib/types/session-events.js +32 -0
- package/lib/types/tool.d.ts +30 -0
- package/lib/types/tool.d.ts.map +1 -0
- package/lib/types/tool.js +131 -0
- package/lib/types/translate.d.ts +64 -0
- package/lib/types/translate.d.ts.map +1 -0
- package/lib/types/translate.js +56 -0
- package/lib/types/typert.host.d.ts +250 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/typert.host.js +23 -0
- package/lib/types/version.d.ts +10 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/version.js +9 -0
- package/lib/types/wire.d.ts +699 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/lib/types/wire.js +273 -0
- package/lib/wire-Cc4JZ3jR.js +4370 -0
- package/package.json +179 -0
- package/src/client/DrawResultCard.tsx +100 -0
- package/src/client/DrawSettingsTab.tsx +159 -0
- package/src/client/index.ts +123 -0
- package/src/client/locales.ts +84 -0
- package/src/client/present.ts +137 -0
- package/src/client/remote.ts +44 -0
- package/src/client/styles.ts +44 -0
- package/src/config.ts +358 -0
- package/src/drawer.ts +234 -0
- package/src/engine.ts +182 -0
- package/src/http.ts +161 -0
- package/src/index.ts +93 -0
- package/src/quota.ts +98 -0
- package/src/router.ts +309 -0
- package/src/sanitize.ts +113 -0
- package/src/service.ts +169 -0
- package/src/session-events.ts +70 -0
- package/src/tool.ts +145 -0
- package/src/translate.ts +101 -0
- package/src/typert.host.ts +25 -0
- package/src/version.ts +10 -0
- package/src/wire.ts +417 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1429 @@
|
|
|
1
|
+
import { i as statusToView, n as imageToWire, r as probeToWire, t as DRAW_INVOCATIONS } from "./wire-Cc4JZ3jR.js";
|
|
2
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
5
|
+
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
|
+
import { AttachmentId } from "@deepseek-ai/dsh-attachment";
|
|
7
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
+
//#region src/config.ts
|
|
9
|
+
/**
|
|
10
|
+
* Plugin configuration for `dsh-draw` and its explicit resolve step.
|
|
11
|
+
* `resolveConfig` re-judges every default and bound so programmatic
|
|
12
|
+
* construction that bypasses Schemastery normalization still fails loud
|
|
13
|
+
* instead of running with hidden defaults (the explicit-resolve contract).
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-draw/config
|
|
16
|
+
*/
|
|
17
|
+
/** Default per-generation HTTP timeout in milliseconds. */
|
|
18
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 12e4;
|
|
19
|
+
/** Ceiling for one generation call: engines can take minutes per image. */
|
|
20
|
+
const MAX_REQUEST_TIMEOUT_MS = 6e5;
|
|
21
|
+
/** Default prompt length cap (characters). */
|
|
22
|
+
const DEFAULT_MAX_PROMPT_LENGTH = 4e3;
|
|
23
|
+
/** Ceiling on the prompt length cap. */
|
|
24
|
+
const MAX_PROMPT_LENGTH = 32e3;
|
|
25
|
+
/** Ceiling on the per-session generation-call cap. */
|
|
26
|
+
const MAX_GENERATIONS_PER_SESSION = 1e5;
|
|
27
|
+
/** Default per-session image-byte cap (200 MiB). */
|
|
28
|
+
const DEFAULT_MAX_BYTES_PER_SESSION = 209715200;
|
|
29
|
+
/** Ceiling on the per-session image-byte cap (4 GiB). */
|
|
30
|
+
const MAX_BYTES_PER_SESSION = 4294967296;
|
|
31
|
+
/** Default engine cooldown after the failure threshold trips (ms). */
|
|
32
|
+
const DEFAULT_COOLDOWN_MS = 6e4;
|
|
33
|
+
/** Ceiling on the engine cooldown. */
|
|
34
|
+
const MAX_COOLDOWN_MS = 36e5;
|
|
35
|
+
/** The two shipped engine presets: OpenAI Images and Zhipu CogView. */
|
|
36
|
+
const DEFAULT_ENGINES = Object.freeze([Object.freeze({
|
|
37
|
+
id: "openai",
|
|
38
|
+
baseUrl: "https://api.openai.com/v1",
|
|
39
|
+
model: "gpt-image-1",
|
|
40
|
+
apiKeyRef: "OPENAI_API_KEY",
|
|
41
|
+
enabled: true,
|
|
42
|
+
sizeMap: Object.freeze({
|
|
43
|
+
square: "1024x1024",
|
|
44
|
+
landscape: "1536x1024",
|
|
45
|
+
portrait: "1024x1536",
|
|
46
|
+
auto: "auto"
|
|
47
|
+
}),
|
|
48
|
+
qualitySupported: true,
|
|
49
|
+
styleSupported: true,
|
|
50
|
+
responseFormat: "b64_json",
|
|
51
|
+
imageMediaType: "image/png"
|
|
52
|
+
}), Object.freeze({
|
|
53
|
+
id: "cogview",
|
|
54
|
+
baseUrl: "https://open.bigmodel.cn/api/paas/v4",
|
|
55
|
+
model: "cogview-3-flash",
|
|
56
|
+
apiKeyRef: "ZHIPU_API_KEY",
|
|
57
|
+
enabled: true,
|
|
58
|
+
sizeMap: Object.freeze({
|
|
59
|
+
square: "1024x1024",
|
|
60
|
+
landscape: "1344x768",
|
|
61
|
+
portrait: "768x1344",
|
|
62
|
+
auto: "1024x1024"
|
|
63
|
+
}),
|
|
64
|
+
qualitySupported: false,
|
|
65
|
+
styleSupported: false,
|
|
66
|
+
responseFormat: "url",
|
|
67
|
+
imageMediaType: "image/png"
|
|
68
|
+
})]);
|
|
69
|
+
/** Default size map for user-configured engines (the OpenAI vocabulary). */
|
|
70
|
+
const DEFAULT_SIZE_MAP = Object.freeze({
|
|
71
|
+
square: "1024x1024",
|
|
72
|
+
landscape: "1536x1024",
|
|
73
|
+
portrait: "1024x1536",
|
|
74
|
+
auto: "auto"
|
|
75
|
+
});
|
|
76
|
+
/** Schemastery schema for loader-validated configuration. */
|
|
77
|
+
const Config = z.object({
|
|
78
|
+
engines: z.array(z.object({
|
|
79
|
+
id: z.string().min(1).max(64),
|
|
80
|
+
baseUrl: z.string().min(1).max(2048),
|
|
81
|
+
model: z.string().min(1).max(128),
|
|
82
|
+
apiKeyRef: z.string().min(1).max(128),
|
|
83
|
+
enabled: z.boolean(),
|
|
84
|
+
sizeMap: z.object({
|
|
85
|
+
square: z.string().min(1).max(64),
|
|
86
|
+
landscape: z.string().min(1).max(64),
|
|
87
|
+
portrait: z.string().min(1).max(64),
|
|
88
|
+
auto: z.string().min(1).max(64)
|
|
89
|
+
}),
|
|
90
|
+
qualitySupported: z.boolean(),
|
|
91
|
+
styleSupported: z.boolean(),
|
|
92
|
+
responseFormat: z.union(["b64_json", "url"]),
|
|
93
|
+
imageMediaType: z.union([
|
|
94
|
+
"image/png",
|
|
95
|
+
"image/jpeg",
|
|
96
|
+
"image/webp",
|
|
97
|
+
"image/gif"
|
|
98
|
+
])
|
|
99
|
+
})).default([...DEFAULT_ENGINES]),
|
|
100
|
+
defaultEngine: z.string().min(1).max(64).default("openai"),
|
|
101
|
+
requestTimeoutMs: z.number().min(1e3).max(MAX_REQUEST_TIMEOUT_MS).default(DEFAULT_REQUEST_TIMEOUT_MS),
|
|
102
|
+
maxImagesPerCall: z.number().min(1).max(10).default(4),
|
|
103
|
+
maxPromptLength: z.number().min(1).max(MAX_PROMPT_LENGTH).default(DEFAULT_MAX_PROMPT_LENGTH),
|
|
104
|
+
maxGenerationsPerSession: z.number().min(1).max(MAX_GENERATIONS_PER_SESSION).default(200),
|
|
105
|
+
maxBytesPerSession: z.number().min(1048576).max(MAX_BYTES_PER_SESSION).default(DEFAULT_MAX_BYTES_PER_SESSION),
|
|
106
|
+
failureThreshold: z.number().min(1).max(10).default(2),
|
|
107
|
+
cooldownMs: z.number().min(1e3).max(MAX_COOLDOWN_MS).default(DEFAULT_COOLDOWN_MS)
|
|
108
|
+
});
|
|
109
|
+
const ENGINE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
110
|
+
/** Credential references are environment-variable names (POSIX identifiers). */
|
|
111
|
+
const API_KEY_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
112
|
+
/**
|
|
113
|
+
* Resolve raw config to the runtime policy, re-validating defaults and bounds
|
|
114
|
+
* plus the cross-field facts a per-field schema cannot see (duplicate engine
|
|
115
|
+
* ids, credential-bearing base URLs, an unknown default engine).
|
|
116
|
+
*
|
|
117
|
+
* @param config - raw loader config; `undefined` for a bare row.
|
|
118
|
+
* @returns the frozen resolved config.
|
|
119
|
+
* @throws on any invalid value (fail-loud at load).
|
|
120
|
+
*/
|
|
121
|
+
function resolveConfig(config) {
|
|
122
|
+
const rawEngines = config?.engines ?? DEFAULT_ENGINES;
|
|
123
|
+
const engines = [];
|
|
124
|
+
const seen = /* @__PURE__ */ new Set();
|
|
125
|
+
for (const engine of rawEngines) {
|
|
126
|
+
const id = typeof engine?.id === "string" ? engine.id : "";
|
|
127
|
+
if (!ENGINE_ID_PATTERN.test(id)) throw new Error(`dsh-draw: engine id ${JSON.stringify(id)} must match ${ENGINE_ID_PATTERN}`);
|
|
128
|
+
if (seen.has(id)) throw new Error(`dsh-draw: duplicate engine id ${JSON.stringify(id)}`);
|
|
129
|
+
seen.add(id);
|
|
130
|
+
const baseUrl = engine.baseUrl ?? "";
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = new URL(baseUrl);
|
|
134
|
+
} catch {
|
|
135
|
+
throw new Error(`dsh-draw: engine "${id}" baseUrl ${JSON.stringify(baseUrl)} is not a valid URL`);
|
|
136
|
+
}
|
|
137
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new Error(`dsh-draw: engine "${id}" baseUrl must use http or https`);
|
|
138
|
+
if (parsed.username !== "" || parsed.password !== "") throw new Error(`dsh-draw: engine "${id}" baseUrl must not embed credentials — use the apiKeyRef credential reference instead`);
|
|
139
|
+
const model = typeof engine.model === "string" && engine.model.length > 0 && engine.model.length <= 128 ? engine.model : "";
|
|
140
|
+
if (model === "") throw new Error(`dsh-draw: engine "${id}" model must be a non-empty string of at most 128 characters`);
|
|
141
|
+
const apiKeyRef = typeof engine.apiKeyRef === "string" && API_KEY_REF_PATTERN.test(engine.apiKeyRef) ? engine.apiKeyRef : "";
|
|
142
|
+
if (apiKeyRef === "") throw new Error(`dsh-draw: engine "${id}" apiKeyRef ${JSON.stringify(engine.apiKeyRef)} must be an environment-variable name`);
|
|
143
|
+
const sizeMap = engine.sizeMap === void 0 ? DEFAULT_SIZE_MAP : engine.sizeMap;
|
|
144
|
+
for (const key of [
|
|
145
|
+
"square",
|
|
146
|
+
"landscape",
|
|
147
|
+
"portrait",
|
|
148
|
+
"auto"
|
|
149
|
+
]) {
|
|
150
|
+
const value = sizeMap[key];
|
|
151
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 64) throw new Error(`dsh-draw: engine "${id}" sizeMap.${key} must be a non-empty string of at most 64 characters`);
|
|
152
|
+
}
|
|
153
|
+
const enabled = engine.enabled ?? true;
|
|
154
|
+
if (typeof enabled !== "boolean") throw new TypeError(`dsh-draw: engine "${id}" enabled must be a boolean`);
|
|
155
|
+
const qualitySupported = engine.qualitySupported ?? false;
|
|
156
|
+
if (typeof qualitySupported !== "boolean") throw new TypeError(`dsh-draw: engine "${id}" qualitySupported must be a boolean`);
|
|
157
|
+
const styleSupported = engine.styleSupported ?? false;
|
|
158
|
+
if (typeof styleSupported !== "boolean") throw new TypeError(`dsh-draw: engine "${id}" styleSupported must be a boolean`);
|
|
159
|
+
const responseFormat = engine.responseFormat ?? "b64_json";
|
|
160
|
+
if (responseFormat !== "b64_json" && responseFormat !== "url") throw new Error(`dsh-draw: engine "${id}" responseFormat must be "b64_json" or "url"`);
|
|
161
|
+
const imageMediaType = engine.imageMediaType ?? "image/png";
|
|
162
|
+
if (imageMediaType !== "image/png" && imageMediaType !== "image/jpeg" && imageMediaType !== "image/webp" && imageMediaType !== "image/gif") throw new Error(`dsh-draw: engine "${id}" imageMediaType ${JSON.stringify(imageMediaType)} is not a supported raster type`);
|
|
163
|
+
engines.push({
|
|
164
|
+
id,
|
|
165
|
+
baseUrl: baseUrl.replace(/\/+$/u, ""),
|
|
166
|
+
model,
|
|
167
|
+
apiKeyRef,
|
|
168
|
+
enabled,
|
|
169
|
+
sizeMap: Object.freeze({ ...sizeMap }),
|
|
170
|
+
qualitySupported,
|
|
171
|
+
styleSupported,
|
|
172
|
+
responseFormat,
|
|
173
|
+
imageMediaType
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (engines.length === 0) throw new Error("dsh-draw: at least one engine must be configured");
|
|
177
|
+
const defaultEngine = config?.defaultEngine ?? engines[0].id;
|
|
178
|
+
if (!seen.has(defaultEngine)) throw new Error(`dsh-draw: defaultEngine ${JSON.stringify(defaultEngine)} does not name a configured engine`);
|
|
179
|
+
const requestTimeoutMs = config?.requestTimeoutMs ?? 12e4;
|
|
180
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs < 1e3 || requestTimeoutMs > 6e5) throw new Error(`dsh-draw: config.requestTimeoutMs must be a finite number between 1000 and ${MAX_REQUEST_TIMEOUT_MS}`);
|
|
181
|
+
const maxImagesPerCall = config?.maxImagesPerCall ?? 4;
|
|
182
|
+
if (!Number.isInteger(maxImagesPerCall) || maxImagesPerCall < 1 || maxImagesPerCall > 10) throw new Error(`dsh-draw: config.maxImagesPerCall must be an integer between 1 and 10`);
|
|
183
|
+
const maxPromptLength = config?.maxPromptLength ?? 4e3;
|
|
184
|
+
if (!Number.isInteger(maxPromptLength) || maxPromptLength < 1 || maxPromptLength > 32e3) throw new Error(`dsh-draw: config.maxPromptLength must be an integer between 1 and ${MAX_PROMPT_LENGTH}`);
|
|
185
|
+
const maxGenerationsPerSession = config?.maxGenerationsPerSession ?? 200;
|
|
186
|
+
if (!Number.isInteger(maxGenerationsPerSession) || maxGenerationsPerSession < 1 || maxGenerationsPerSession > 1e5) throw new Error(`dsh-draw: config.maxGenerationsPerSession must be an integer between 1 and ${MAX_GENERATIONS_PER_SESSION}`);
|
|
187
|
+
const maxBytesPerSession = config?.maxBytesPerSession ?? 209715200;
|
|
188
|
+
if (!Number.isInteger(maxBytesPerSession) || maxBytesPerSession < 1048576 || maxBytesPerSession > 4294967296) throw new Error(`dsh-draw: config.maxBytesPerSession must be an integer between 1048576 and ${MAX_BYTES_PER_SESSION}`);
|
|
189
|
+
const failureThreshold = config?.failureThreshold ?? 2;
|
|
190
|
+
if (!Number.isInteger(failureThreshold) || failureThreshold < 1 || failureThreshold > 10) throw new Error(`dsh-draw: config.failureThreshold must be an integer between 1 and 10`);
|
|
191
|
+
const cooldownMs = config?.cooldownMs ?? 6e4;
|
|
192
|
+
if (!Number.isInteger(cooldownMs) || cooldownMs < 1e3 || cooldownMs > 36e5) throw new Error(`dsh-draw: config.cooldownMs must be an integer between 1000 and ${MAX_COOLDOWN_MS}`);
|
|
193
|
+
return Object.freeze({
|
|
194
|
+
engines: Object.freeze(engines),
|
|
195
|
+
defaultEngine,
|
|
196
|
+
requestTimeoutMs,
|
|
197
|
+
maxImagesPerCall,
|
|
198
|
+
maxPromptLength,
|
|
199
|
+
maxGenerationsPerSession,
|
|
200
|
+
maxBytesPerSession,
|
|
201
|
+
failureThreshold,
|
|
202
|
+
cooldownMs
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
/** Engine id by which an engine is addressed, or `undefined` for an unknown id. */
|
|
206
|
+
function engineById(config, id) {
|
|
207
|
+
return config.engines.find((engine) => engine.id === id);
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
//#region src/session-events.ts
|
|
211
|
+
/**
|
|
212
|
+
* Append one `draw/generated` event to a session.
|
|
213
|
+
*
|
|
214
|
+
* @param session - owning session.
|
|
215
|
+
* @param event - accounting payload.
|
|
216
|
+
* @returns the logged event.
|
|
217
|
+
*/
|
|
218
|
+
function appendDrawGenerated(session, event) {
|
|
219
|
+
return session.append("draw/generated", event);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Collect the `draw/generated` events of a session in log order. Declaration
|
|
223
|
+
* merging narrows `event.type === 'draw/generated'` to the payload type.
|
|
224
|
+
*
|
|
225
|
+
* @param session - session whose log is scanned.
|
|
226
|
+
* @returns the accounting events.
|
|
227
|
+
*/
|
|
228
|
+
function drawGeneratedEvents(session) {
|
|
229
|
+
return session.events.filter((event) => event.type === "draw/generated");
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/quota.ts
|
|
233
|
+
/**
|
|
234
|
+
* Fold one session's `draw/generated` events into current usage.
|
|
235
|
+
*
|
|
236
|
+
* @param session - session whose log is folded.
|
|
237
|
+
* @returns generation and byte totals.
|
|
238
|
+
*/
|
|
239
|
+
function quotaState(session) {
|
|
240
|
+
let generations = 0;
|
|
241
|
+
let bytes = 0;
|
|
242
|
+
for (const event of drawGeneratedEvents(session)) {
|
|
243
|
+
generations += 1;
|
|
244
|
+
bytes += event.data.bytes;
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
generations,
|
|
248
|
+
bytes
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Check the generation-call axis before any engine is contacted: a session at
|
|
253
|
+
* its cap fails fast without spending engine credits.
|
|
254
|
+
*
|
|
255
|
+
* @param session - owning session.
|
|
256
|
+
* @param limits - configured limits.
|
|
257
|
+
* @returns allowance or denial.
|
|
258
|
+
*/
|
|
259
|
+
function checkQuotaGenerations(session, limits) {
|
|
260
|
+
const state = quotaState(session);
|
|
261
|
+
if (state.generations >= limits.maxGenerations) return {
|
|
262
|
+
allowed: false,
|
|
263
|
+
reason: "generations",
|
|
264
|
+
state
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
allowed: true,
|
|
268
|
+
state
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Check the byte axis after the engine produced images but before anything is
|
|
273
|
+
* stored: the incoming bytes must fit under the cap, otherwise the images are
|
|
274
|
+
* discarded without touching the attachment store.
|
|
275
|
+
*
|
|
276
|
+
* @param session - owning session.
|
|
277
|
+
* @param limits - configured limits.
|
|
278
|
+
* @param incomingBytes - bytes the new images would add.
|
|
279
|
+
* @returns allowance or denial.
|
|
280
|
+
*/
|
|
281
|
+
function checkQuotaBytes(session, limits, incomingBytes) {
|
|
282
|
+
const state = quotaState(session);
|
|
283
|
+
if (state.bytes + incomingBytes > limits.maxBytes) return {
|
|
284
|
+
allowed: false,
|
|
285
|
+
reason: "bytes",
|
|
286
|
+
state
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
allowed: true,
|
|
290
|
+
state
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region src/translate.ts
|
|
295
|
+
const SIZES = [
|
|
296
|
+
"square",
|
|
297
|
+
"landscape",
|
|
298
|
+
"portrait",
|
|
299
|
+
"auto"
|
|
300
|
+
];
|
|
301
|
+
const QUALITIES = [
|
|
302
|
+
"low",
|
|
303
|
+
"medium",
|
|
304
|
+
"high",
|
|
305
|
+
"auto"
|
|
306
|
+
];
|
|
307
|
+
const STYLES = ["natural", "vivid"];
|
|
308
|
+
/**
|
|
309
|
+
* Normalize unvalidated tool args to a {@link StandardImageRequest}; malformed
|
|
310
|
+
* values fall back to defaults. The prompt passes through verbatim — length
|
|
311
|
+
* validation is the drawer's job (an over-long prompt fails the call loudly
|
|
312
|
+
* instead of being silently truncated).
|
|
313
|
+
*
|
|
314
|
+
* @param args - unvalidated tool arguments.
|
|
315
|
+
* @param maxImagesPerCall - configured cap the requested count clamps to.
|
|
316
|
+
* @returns the normalized request.
|
|
317
|
+
*/
|
|
318
|
+
function normalizeRequest(args, maxImagesPerCall) {
|
|
319
|
+
const record = typeof args === "object" && args !== null ? args : {};
|
|
320
|
+
const prompt = typeof record["prompt"] === "string" ? record["prompt"] : "";
|
|
321
|
+
const rawSize = record["size"];
|
|
322
|
+
const size = typeof rawSize === "string" && SIZES.includes(rawSize) ? rawSize : "square";
|
|
323
|
+
const rawCount = record["count"];
|
|
324
|
+
const parsedCount = typeof rawCount === "number" ? Math.trunc(rawCount) : 1;
|
|
325
|
+
const count = Math.min(Math.max(Number.isFinite(parsedCount) ? parsedCount : 1, 1), maxImagesPerCall);
|
|
326
|
+
const rawQuality = record["quality"];
|
|
327
|
+
const quality = typeof rawQuality === "string" && QUALITIES.includes(rawQuality) ? rawQuality : "auto";
|
|
328
|
+
const rawStyle = record["style"];
|
|
329
|
+
const style = typeof rawStyle === "string" && STYLES.includes(rawStyle) ? rawStyle : void 0;
|
|
330
|
+
const rawEngine = record["engine"];
|
|
331
|
+
const engine = typeof rawEngine === "string" && rawEngine.length > 0 ? rawEngine : void 0;
|
|
332
|
+
return {
|
|
333
|
+
prompt,
|
|
334
|
+
size,
|
|
335
|
+
count,
|
|
336
|
+
quality,
|
|
337
|
+
...style !== void 0 ? { style } : {},
|
|
338
|
+
...engine !== void 0 ? { engine } : {}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Translate one standardized request into a concrete engine request.
|
|
343
|
+
*
|
|
344
|
+
* @param engine - resolved engine configuration.
|
|
345
|
+
* @param request - normalized standard request.
|
|
346
|
+
* @returns the engine request fields.
|
|
347
|
+
*/
|
|
348
|
+
function translateRequest(engine, request) {
|
|
349
|
+
const size = engine.sizeMap[request.size ?? "square"];
|
|
350
|
+
return {
|
|
351
|
+
model: engine.model,
|
|
352
|
+
prompt: request.prompt,
|
|
353
|
+
size,
|
|
354
|
+
n: request.count ?? 1,
|
|
355
|
+
...engine.qualitySupported && request.quality !== "auto" ? { quality: request.quality } : {},
|
|
356
|
+
...engine.styleSupported && request.style !== void 0 ? { style: request.style } : {},
|
|
357
|
+
responseFormat: engine.responseFormat
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/drawer.ts
|
|
362
|
+
/**
|
|
363
|
+
* The shared generation path.
|
|
364
|
+
*/
|
|
365
|
+
var Drawer = class {
|
|
366
|
+
config;
|
|
367
|
+
router;
|
|
368
|
+
deps;
|
|
369
|
+
/**
|
|
370
|
+
* @param config - resolved plugin configuration.
|
|
371
|
+
* @param router - the engine router.
|
|
372
|
+
* @param deps - per-call dependencies (public: the remote service reads them for probes).
|
|
373
|
+
*/
|
|
374
|
+
constructor(config, router, deps) {
|
|
375
|
+
this.config = config;
|
|
376
|
+
this.router = router;
|
|
377
|
+
this.deps = deps;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Run one generation end to end: normalize and validate, check quota,
|
|
381
|
+
* route through the engine chain, commit images to the attachment store,
|
|
382
|
+
* and append the audit event.
|
|
383
|
+
*
|
|
384
|
+
* @param args - unvalidated standard request (tool args or card regenerate args).
|
|
385
|
+
* @param options - cancellation, session, and source.
|
|
386
|
+
* @returns the outcome.
|
|
387
|
+
*/
|
|
388
|
+
async generate(args, options) {
|
|
389
|
+
const request = normalizeRequest(args, this.config.maxImagesPerCall);
|
|
390
|
+
if (request.prompt.trim().length === 0) return {
|
|
391
|
+
ok: false,
|
|
392
|
+
reason: "invalid-prompt",
|
|
393
|
+
message: "image_generate: prompt must be a non-empty string"
|
|
394
|
+
};
|
|
395
|
+
if (request.prompt.length > this.config.maxPromptLength) return {
|
|
396
|
+
ok: false,
|
|
397
|
+
reason: "invalid-prompt",
|
|
398
|
+
message: `image_generate: prompt exceeds the configured ${this.config.maxPromptLength}-character cap`
|
|
399
|
+
};
|
|
400
|
+
const session = options.session;
|
|
401
|
+
if (session === void 0) return {
|
|
402
|
+
ok: false,
|
|
403
|
+
reason: "no-session",
|
|
404
|
+
message: "image_generate: no session owns this call — quota accounting and the audit event need a session"
|
|
405
|
+
};
|
|
406
|
+
const limits = {
|
|
407
|
+
maxGenerations: this.config.maxGenerationsPerSession,
|
|
408
|
+
maxBytes: this.config.maxBytesPerSession
|
|
409
|
+
};
|
|
410
|
+
const generationCheck = checkQuotaGenerations(session, limits);
|
|
411
|
+
if (!generationCheck.allowed) return {
|
|
412
|
+
ok: false,
|
|
413
|
+
reason: "quota-generations",
|
|
414
|
+
message: `image_generate: session generation quota exhausted (${generationCheck.state.generations}/${limits.maxGenerations} calls)`,
|
|
415
|
+
quota: generationCheck.state
|
|
416
|
+
};
|
|
417
|
+
const startedAt = Date.now();
|
|
418
|
+
const routed = await this.router.generate(request, this.deps.engine, options.signal);
|
|
419
|
+
if (!routed.ok) return {
|
|
420
|
+
ok: false,
|
|
421
|
+
reason: "all-engines-failed",
|
|
422
|
+
message: `image_generate: no configured engine produced images${routed.attempts.length === 0 ? "" : ` (${routed.attempts.map((attempt) => attempt.engine).join(", ")})`}`,
|
|
423
|
+
attempts: routed.attempts,
|
|
424
|
+
quota: generationCheck.state
|
|
425
|
+
};
|
|
426
|
+
const attachments = this.deps.attachments?.();
|
|
427
|
+
if (attachments === void 0) return {
|
|
428
|
+
ok: false,
|
|
429
|
+
reason: "attachments-unavailable",
|
|
430
|
+
message: "image_generate: the attachment store is not composed — images cannot be saved durably",
|
|
431
|
+
quota: generationCheck.state,
|
|
432
|
+
attempts: routed.attempts
|
|
433
|
+
};
|
|
434
|
+
const totalBytes = routed.images.reduce((sum, image) => sum + image.data.byteLength, 0);
|
|
435
|
+
const byteCheck = checkQuotaBytes(session, limits, totalBytes);
|
|
436
|
+
if (!byteCheck.allowed) return {
|
|
437
|
+
ok: false,
|
|
438
|
+
reason: "quota-bytes",
|
|
439
|
+
message: `image_generate: session image-byte quota exhausted (${byteCheck.state.bytes}/${limits.maxBytes} bytes)`,
|
|
440
|
+
quota: byteCheck.state,
|
|
441
|
+
attempts: routed.attempts
|
|
442
|
+
};
|
|
443
|
+
let refs;
|
|
444
|
+
try {
|
|
445
|
+
refs = await this.saveImages(attachments, routed.engine, routed.images, options.signal);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return {
|
|
448
|
+
ok: false,
|
|
449
|
+
reason: "attachments-unavailable",
|
|
450
|
+
message: `image_generate: saving images failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
451
|
+
quota: generationCheck.state,
|
|
452
|
+
attempts: routed.attempts
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
const images = refs.map((ref) => ({
|
|
456
|
+
attachmentId: String(ref.attachmentId),
|
|
457
|
+
mediaType: ref.mediaType,
|
|
458
|
+
bytes: ref.bytes,
|
|
459
|
+
width: ref.width,
|
|
460
|
+
height: ref.height,
|
|
461
|
+
...ref.name !== void 0 ? { name: ref.name } : {}
|
|
462
|
+
}));
|
|
463
|
+
const elapsedMs = Date.now() - startedAt;
|
|
464
|
+
appendDrawGenerated(session, {
|
|
465
|
+
engine: routed.engine,
|
|
466
|
+
model: routed.model,
|
|
467
|
+
source: options.source,
|
|
468
|
+
prompt: request.prompt,
|
|
469
|
+
size: request.size ?? "square",
|
|
470
|
+
quality: request.quality ?? "auto",
|
|
471
|
+
count: images.length,
|
|
472
|
+
bytes: totalBytes,
|
|
473
|
+
attachmentIds: images.map((image) => image.attachmentId),
|
|
474
|
+
elapsedMs
|
|
475
|
+
});
|
|
476
|
+
const after = checkQuotaGenerations(session, limits);
|
|
477
|
+
return {
|
|
478
|
+
ok: true,
|
|
479
|
+
engine: routed.engine,
|
|
480
|
+
model: routed.model,
|
|
481
|
+
size: request.size ?? "square",
|
|
482
|
+
images,
|
|
483
|
+
quota: after.state,
|
|
484
|
+
limits,
|
|
485
|
+
fallbackUsed: routed.fallbackUsed,
|
|
486
|
+
elapsedMs,
|
|
487
|
+
attempts: routed.attempts
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/** Save every produced image to the attachment store; an empty image fails the batch. */
|
|
491
|
+
async saveImages(attachments, engineId, produced, signal) {
|
|
492
|
+
const refs = [];
|
|
493
|
+
for (let index = 0; index < produced.length; index += 1) {
|
|
494
|
+
signal?.throwIfAborted();
|
|
495
|
+
const image = produced[index];
|
|
496
|
+
if (image.data.byteLength === 0) throw new Error(`engine "${engineId}" produced an empty image`);
|
|
497
|
+
refs.push(await attachments.saveImage({
|
|
498
|
+
data: image.data,
|
|
499
|
+
mediaType: image.mediaType,
|
|
500
|
+
name: `${engineId}-${index + 1}.${image.mediaType === "image/jpeg" ? "jpg" : image.mediaType.slice(6)}`
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
return refs;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
//#endregion
|
|
507
|
+
//#region src/http.ts
|
|
508
|
+
/**
|
|
509
|
+
* A transport-level failure. `status` is absent for network/timeout failures;
|
|
510
|
+
* `code` routes fallback and cooldown decisions.
|
|
511
|
+
*/
|
|
512
|
+
var HttpError = class extends Error {
|
|
513
|
+
/** Stable failure code. */
|
|
514
|
+
code;
|
|
515
|
+
/** HTTP status when a response existed. */
|
|
516
|
+
status;
|
|
517
|
+
/** @param code - stable failure code. @param message - display-safe message. @param options - optional status and cause. */
|
|
518
|
+
constructor(code, message, options) {
|
|
519
|
+
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
520
|
+
this.name = "HttpError";
|
|
521
|
+
this.code = code;
|
|
522
|
+
if (options?.status !== void 0) this.status = options.status;
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
/**
|
|
526
|
+
* Fuse a caller signal with a per-call timeout into one signal and a disposer
|
|
527
|
+
* that clears the timer when the call settles.
|
|
528
|
+
*
|
|
529
|
+
* @param signal - caller signal, or undefined.
|
|
530
|
+
* @param timeoutMs - positive timeout.
|
|
531
|
+
* @returns the fused signal plus its disposer.
|
|
532
|
+
*/
|
|
533
|
+
function fusedSignal(signal, timeoutMs) {
|
|
534
|
+
if (signal === void 0) {
|
|
535
|
+
const controller = new AbortController();
|
|
536
|
+
const timer = setTimeout(() => controller.abort(new HttpError("timeout", `request exceeded ${timeoutMs} ms`)), timeoutMs);
|
|
537
|
+
return {
|
|
538
|
+
signal: controller.signal,
|
|
539
|
+
dispose: () => clearTimeout(timer)
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (signal.aborted) return {
|
|
543
|
+
signal,
|
|
544
|
+
dispose: () => void 0
|
|
545
|
+
};
|
|
546
|
+
const controller = new AbortController();
|
|
547
|
+
const timer = setTimeout(() => controller.abort(new HttpError("timeout", `request exceeded ${timeoutMs} ms`)), timeoutMs);
|
|
548
|
+
const forward = () => {
|
|
549
|
+
controller.abort(signal.reason);
|
|
550
|
+
};
|
|
551
|
+
signal.addEventListener("abort", forward, { once: true });
|
|
552
|
+
const dispose = () => {
|
|
553
|
+
clearTimeout(timer);
|
|
554
|
+
signal.removeEventListener("abort", forward);
|
|
555
|
+
};
|
|
556
|
+
return {
|
|
557
|
+
signal: controller.signal,
|
|
558
|
+
dispose
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
/** Map an undici/fetch rejection to an {@link HttpError} by its observable identity. */
|
|
562
|
+
function mapFetchFailure(signal, error) {
|
|
563
|
+
const reason = signal.reason;
|
|
564
|
+
if (signal.aborted && reason instanceof HttpError) return reason;
|
|
565
|
+
if (signal.aborted) return new HttpError("aborted", reason instanceof Error ? reason.message : "request aborted", { cause: error });
|
|
566
|
+
if (error instanceof Error && error.name === "TimeoutError") return new HttpError("timeout", error.message, { cause: error });
|
|
567
|
+
return new HttpError("network", error instanceof Error ? error.message : String(error), { cause: error });
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The production transport: global `fetch` (undici under Node ≥ 22) with the
|
|
571
|
+
* timeout fused onto the caller signal. The response body is read to bytes;
|
|
572
|
+
* oversized or unreadable bodies surface as `invalid-response`.
|
|
573
|
+
*
|
|
574
|
+
* @param timeoutMs - per-call timeout.
|
|
575
|
+
* @param maxBytes - response byte cap (the engine's image cap plus headroom).
|
|
576
|
+
* @returns a transport ready for the router.
|
|
577
|
+
*/
|
|
578
|
+
function defaultHttpTransport(timeoutMs, maxBytes) {
|
|
579
|
+
return { async request(request) {
|
|
580
|
+
const { signal, dispose } = fusedSignal(request.signal, timeoutMs);
|
|
581
|
+
try {
|
|
582
|
+
let response;
|
|
583
|
+
try {
|
|
584
|
+
response = await fetch(request.url, {
|
|
585
|
+
method: request.method,
|
|
586
|
+
...request.headers === void 0 ? {} : { headers: request.headers },
|
|
587
|
+
...request.body === void 0 ? {} : { body: request.body },
|
|
588
|
+
signal,
|
|
589
|
+
redirect: "follow"
|
|
590
|
+
});
|
|
591
|
+
} catch (error) {
|
|
592
|
+
throw mapFetchFailure(signal, error);
|
|
593
|
+
}
|
|
594
|
+
let body;
|
|
595
|
+
try {
|
|
596
|
+
body = new Uint8Array(await response.arrayBuffer());
|
|
597
|
+
} catch (error) {
|
|
598
|
+
throw new HttpError("invalid-response", `failed to read response body: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
599
|
+
}
|
|
600
|
+
if (body.byteLength > maxBytes) throw new HttpError("invalid-response", `response body exceeds ${maxBytes} bytes`, { status: response.status });
|
|
601
|
+
return {
|
|
602
|
+
status: response.status,
|
|
603
|
+
body
|
|
604
|
+
};
|
|
605
|
+
} finally {
|
|
606
|
+
dispose();
|
|
607
|
+
}
|
|
608
|
+
} };
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Decode a standard base64 string to bytes. A malformed string fails loud —
|
|
612
|
+
* a provider change would otherwise silently corrupt an image.
|
|
613
|
+
*
|
|
614
|
+
* @param data - base64 payload without a data: prefix.
|
|
615
|
+
* @returns decoded bytes.
|
|
616
|
+
* @throws when the payload is not valid base64.
|
|
617
|
+
*/
|
|
618
|
+
function decodeBase64(data) {
|
|
619
|
+
if (data.length === 0) throw new HttpError("invalid-response", "empty base64 image payload");
|
|
620
|
+
const binary = atob(data);
|
|
621
|
+
const bytes = new Uint8Array(binary.length);
|
|
622
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
|
623
|
+
return bytes;
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/sanitize.ts
|
|
627
|
+
/**
|
|
628
|
+
* Display sanitization for dsh-draw surfaces. Everything shown to a human —
|
|
629
|
+
* the tool result text, the settings panel snapshot, probe details, engine
|
|
630
|
+
* errors — passes through these pure functions so URL credentials, API keys,
|
|
631
|
+
* bearer tokens, and JWTs never reach a display. Secrets themselves are
|
|
632
|
+
* credential references; this module redacts what can still leak through
|
|
633
|
+
* configured URLs and provider error text.
|
|
634
|
+
*
|
|
635
|
+
* @module dsh-draw/sanitize
|
|
636
|
+
*/
|
|
637
|
+
/** Replacement for every redacted credential value. */
|
|
638
|
+
const REDACTED = "***";
|
|
639
|
+
/** Query/field keys whose values are credentials regardless of their name casing. */
|
|
640
|
+
const CREDENTIAL_KEY = /^(?:access[_-]?token|api[_-]?key|apikey|auth|authorization|client[_-]?secret|key|password|passwd|pwd|secret|sig|signature|token)$/iu;
|
|
641
|
+
/** Credential keys for the unparseable-URL fallback and embedded-text scans. */
|
|
642
|
+
const CREDENTIAL_KEY_SOURCE = "(?:access[_-]?token|api[_-]?key|apikey|auth(?:orization)?|client[_-]?secret|key|passw(?:or)?d|passwd|pwd|secret|sig(?:nature)?|token)";
|
|
643
|
+
/** Whole userinfo before `@` (unparseable URLs only — parsed URLs redact just the password). */
|
|
644
|
+
const USERINFO = /([a-z][a-z0-9+.-]*:\/\/)([^/@\s]+)@/giu;
|
|
645
|
+
/** `?key=value` / `&key=value` credential pairs inside arbitrary text. */
|
|
646
|
+
const QUERY_CREDENTIAL = new RegExp(`([?&](?:[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*)=)[^&#\\s]*`, "giu");
|
|
647
|
+
/** `#key=value` credential pairs in URL fragments and arbitrary text. */
|
|
648
|
+
const FRAGMENT_CREDENTIAL = new RegExp(`(#[^=&#\\s]*${CREDENTIAL_KEY_SOURCE}[^=&#\\s]*=)[^&#\\s]*`, "giu");
|
|
649
|
+
/** `Authorization: <value>`-style header lines in arbitrary text (quoted value first). */
|
|
650
|
+
const HEADER_CREDENTIAL_QUOTED = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*["'])[^"']*(["'])`, "giu");
|
|
651
|
+
/** `Authorization: <value>`-style header lines with unquoted values. */
|
|
652
|
+
const HEADER_CREDENTIAL_BARE = new RegExp(`(\\b${CREDENTIAL_KEY_SOURCE}\\s*[:=]\\s*)[^\\s,;)\\]}]+`, "giu");
|
|
653
|
+
/** Environment-variable-shaped credentials (`GITHUB_TOKEN=…`) in spawn errors. */
|
|
654
|
+
const ENV_VAR_CREDENTIAL = /\b[A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASSWD)[A-Za-z0-9_]*\s*=\s*[^\s,;)\]}]+/gu;
|
|
655
|
+
/** Bearer tokens, including the `Bearer ` keyword and the token itself. */
|
|
656
|
+
const BEARER = /(bearer)\s+[A-Za-z0-9._~+/=-]+/giu;
|
|
657
|
+
/** Quoted JSON-ish `"token": "value"` pairs in arbitrary text. */
|
|
658
|
+
const QUOTED_CREDENTIAL = new RegExp(`(["'](?:access[_-]?token|api[_-]?key|client[_-]?secret|secret|token)["']\\s*[:=]\\s*["'])[^"']*(["'])`, "giu");
|
|
659
|
+
/** Raw JWT bodies, wherever they appear. */
|
|
660
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/gu;
|
|
661
|
+
/**
|
|
662
|
+
* Redact a URL for display: userinfo password, credential query values, and
|
|
663
|
+
* credential fragment pairs. Query keys are read through `URLSearchParams`,
|
|
664
|
+
* so percent-encoded key names are decoded before matching. Unparseable
|
|
665
|
+
* inputs fall back to pattern redaction (whole userinfo, credential query
|
|
666
|
+
* pairs, credential fragment pairs) instead of throwing.
|
|
667
|
+
*
|
|
668
|
+
* @param url - candidate URL text.
|
|
669
|
+
* @returns display-safe URL text.
|
|
670
|
+
*/
|
|
671
|
+
function sanitizeUrl(url) {
|
|
672
|
+
let parsed;
|
|
673
|
+
try {
|
|
674
|
+
parsed = new URL(url);
|
|
675
|
+
} catch {
|
|
676
|
+
return url.replace(USERINFO, "$1***@").replace(QUERY_CREDENTIAL, `$1***`).replace(FRAGMENT_CREDENTIAL, `$1***`);
|
|
677
|
+
}
|
|
678
|
+
if (parsed.password !== "") parsed.password = "***";
|
|
679
|
+
for (const key of [...parsed.searchParams.keys()]) if (CREDENTIAL_KEY.test(key)) parsed.searchParams.set(key, "***");
|
|
680
|
+
if (parsed.hash !== "") parsed.hash = parsed.hash.replace(FRAGMENT_CREDENTIAL, `$1***`);
|
|
681
|
+
return parsed.toString();
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Redact credential-shaped fragments from free text: header lines, bearer
|
|
685
|
+
* tokens, raw JWTs, embedded query pairs, and quoted token values.
|
|
686
|
+
*
|
|
687
|
+
* @param text - candidate display text.
|
|
688
|
+
* @returns display-safe text.
|
|
689
|
+
*/
|
|
690
|
+
function sanitizeText(text) {
|
|
691
|
+
return text.replace(BEARER, `$1 ***`).replace(HEADER_CREDENTIAL_QUOTED, `$1***$2`).replace(HEADER_CREDENTIAL_BARE, `$1***`).replace(ENV_VAR_CREDENTIAL, (value) => {
|
|
692
|
+
const equals = value.indexOf("=");
|
|
693
|
+
return equals < 0 ? value : `${value.slice(0, equals)}=***`;
|
|
694
|
+
}).replace(QUOTED_CREDENTIAL, `$1***$2`).replace(QUERY_CREDENTIAL, `$1***`).replace(FRAGMENT_CREDENTIAL, `$1***`).replace(JWT, "***");
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Stringify an arbitrary thrown value safely and redact it for display.
|
|
698
|
+
* Never throws: unrenderable values degrade to a fixed marker.
|
|
699
|
+
*
|
|
700
|
+
* @param error - thrown value from an engine call or probe.
|
|
701
|
+
* @returns display-safe error text.
|
|
702
|
+
*/
|
|
703
|
+
function sanitizeError(error) {
|
|
704
|
+
let text;
|
|
705
|
+
try {
|
|
706
|
+
text = typeof error === "string" ? error : String(error);
|
|
707
|
+
} catch {
|
|
708
|
+
text = "<unrenderable error>";
|
|
709
|
+
}
|
|
710
|
+
return sanitizeText(text);
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
713
|
+
//#region src/engine.ts
|
|
714
|
+
/**
|
|
715
|
+
* A single engine call failure. `message` is display-safe (never carries the
|
|
716
|
+
* API key); `status` carries the HTTP status when a response existed.
|
|
717
|
+
*/
|
|
718
|
+
var EngineCallError = class extends Error {
|
|
719
|
+
/** Which stage failed. */
|
|
720
|
+
phase;
|
|
721
|
+
/** Stable machine code: `unconfigured`, `auth`, `http`, `parse`. */
|
|
722
|
+
code;
|
|
723
|
+
/** HTTP status when a response existed. */
|
|
724
|
+
status;
|
|
725
|
+
/** @param phase - failing stage. @param code - stable code. @param message - display-safe message. @param options - optional status and cause. */
|
|
726
|
+
constructor(phase, code, message, options) {
|
|
727
|
+
super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
|
|
728
|
+
this.name = "EngineCallError";
|
|
729
|
+
this.phase = phase;
|
|
730
|
+
this.code = code;
|
|
731
|
+
if (options?.status !== void 0) this.status = options.status;
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
/** Timeout for one image URL download (fraction of the request budget). */
|
|
735
|
+
const DOWNLOAD_TIMEOUT_MS = 6e4;
|
|
736
|
+
/**
|
|
737
|
+
* Call one engine for the given translated request.
|
|
738
|
+
*
|
|
739
|
+
* @param engine - resolved engine configuration.
|
|
740
|
+
* @param request - translated request body fields.
|
|
741
|
+
* @param deps - transport and credential resolution.
|
|
742
|
+
* @param signal - caller cancellation.
|
|
743
|
+
* @returns the produced images.
|
|
744
|
+
* @throws {@link EngineCallError} with a phase the router can act on.
|
|
745
|
+
*/
|
|
746
|
+
async function callEngine(engine, request, deps, signal) {
|
|
747
|
+
const credential = await deps.resolveCredential(engine.apiKeyRef);
|
|
748
|
+
if (credential === void 0) throw new EngineCallError("credential", "unconfigured", `engine "${engine.id}" has no resolved credential reference ${engine.apiKeyRef}`);
|
|
749
|
+
const headers = {
|
|
750
|
+
"content-type": "application/json",
|
|
751
|
+
authorization: `Bearer ${credential}`
|
|
752
|
+
};
|
|
753
|
+
const body = {
|
|
754
|
+
model: request.model,
|
|
755
|
+
prompt: request.prompt,
|
|
756
|
+
size: request.size,
|
|
757
|
+
n: request.n,
|
|
758
|
+
...request.quality !== void 0 ? { quality: request.quality } : {},
|
|
759
|
+
...request.style !== void 0 ? { style: request.style } : {},
|
|
760
|
+
...request.responseFormat === "b64_json" ? { response_format: request.responseFormat } : {}
|
|
761
|
+
};
|
|
762
|
+
const response = await deps.transport.request({
|
|
763
|
+
method: "POST",
|
|
764
|
+
url: `${engine.baseUrl}/images/generations`,
|
|
765
|
+
headers,
|
|
766
|
+
body: new TextEncoder().encode(JSON.stringify(body)),
|
|
767
|
+
...signal === void 0 ? {} : { signal }
|
|
768
|
+
});
|
|
769
|
+
if (response.status === 401 || response.status === 403) throw new EngineCallError("request", "auth", `engine "${engine.id}" rejected the credential (HTTP ${response.status})`, { status: response.status });
|
|
770
|
+
if (response.status < 200 || response.status >= 300) throw new EngineCallError("request", "http", `engine "${engine.id}" failed with HTTP ${response.status}`, { status: response.status });
|
|
771
|
+
let parsed;
|
|
772
|
+
try {
|
|
773
|
+
parsed = JSON.parse(new TextDecoder().decode(response.body));
|
|
774
|
+
} catch (cause) {
|
|
775
|
+
throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned a non-JSON response`, { cause });
|
|
776
|
+
}
|
|
777
|
+
const items = Array.isArray(parsed.data) ? parsed.data : void 0;
|
|
778
|
+
if (items === void 0) throw new EngineCallError("parse", "parse", `engine "${engine.id}" response has no data array`);
|
|
779
|
+
const images = [];
|
|
780
|
+
for (const raw of items) {
|
|
781
|
+
if (typeof raw !== "object" || raw === null) throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned a malformed image entry`);
|
|
782
|
+
const item = raw;
|
|
783
|
+
if (typeof item.b64_json === "string" && item.b64_json.length > 0) {
|
|
784
|
+
images.push({
|
|
785
|
+
data: decodeBase64(item.b64_json),
|
|
786
|
+
mediaType: engine.imageMediaType
|
|
787
|
+
});
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (typeof item.url === "string" && item.url.length > 0) {
|
|
791
|
+
images.push({
|
|
792
|
+
data: await downloadImageUrl(engine, item.url, credential, deps, signal),
|
|
793
|
+
mediaType: engine.imageMediaType
|
|
794
|
+
});
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned an image entry without bytes or a URL`);
|
|
798
|
+
}
|
|
799
|
+
if (images.length === 0) throw new EngineCallError("parse", "parse", `engine "${engine.id}" returned no images`);
|
|
800
|
+
return images;
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Download one image URL with the engine's bearer credential. The download is
|
|
804
|
+
* one GET request on the same transport; a non-2xx status is an engine
|
|
805
|
+
* failure, not silent emptiness.
|
|
806
|
+
*/
|
|
807
|
+
async function downloadImageUrl(engine, url, credential, deps, signal) {
|
|
808
|
+
const { signal: downloadSignal, dispose } = fusedSignal(signal, DOWNLOAD_TIMEOUT_MS);
|
|
809
|
+
try {
|
|
810
|
+
const response = await deps.transport.request({
|
|
811
|
+
method: "GET",
|
|
812
|
+
url,
|
|
813
|
+
headers: { authorization: `Bearer ${credential}` },
|
|
814
|
+
signal: downloadSignal
|
|
815
|
+
});
|
|
816
|
+
if (response.status < 200 || response.status >= 300) throw new EngineCallError("request", "http", `engine "${engine.id}" image download failed with HTTP ${response.status}`, { status: response.status });
|
|
817
|
+
return response.body;
|
|
818
|
+
} catch (error) {
|
|
819
|
+
if (error instanceof EngineCallError) throw error;
|
|
820
|
+
throw new EngineCallError("request", "http", `engine "${engine.id}" image download failed: ${sanitizeError(error)}`, { cause: error });
|
|
821
|
+
} finally {
|
|
822
|
+
dispose();
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/router.ts
|
|
827
|
+
/**
|
|
828
|
+
* The engine chain with per-engine health and cooldown. All mutations are
|
|
829
|
+
* synchronous bookkeeping guarded by `generate`'s single-writer path (the
|
|
830
|
+
* tool is not concurrency-safe, so generations serialize).
|
|
831
|
+
*/
|
|
832
|
+
var EngineRouter = class {
|
|
833
|
+
config;
|
|
834
|
+
health = /* @__PURE__ */ new Map();
|
|
835
|
+
now;
|
|
836
|
+
/**
|
|
837
|
+
* @param config - resolved plugin configuration (engine order and bounds).
|
|
838
|
+
* @param options - failure threshold, cooldown, and clock.
|
|
839
|
+
*/
|
|
840
|
+
constructor(config, options) {
|
|
841
|
+
this.config = config;
|
|
842
|
+
this.now = options.now ?? Date.now;
|
|
843
|
+
for (const engine of config.engines) this.health.set(engine.id, {
|
|
844
|
+
consecutiveFailures: 0,
|
|
845
|
+
cooldownUntil: null,
|
|
846
|
+
lastError: null,
|
|
847
|
+
lastStatus: null
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Route one standardized request through the configured chain. The chain
|
|
852
|
+
* order is the config array order, except an explicit `request.engine`
|
|
853
|
+
* promotes that engine to the front; every engine is attempted at most
|
|
854
|
+
* once, and an engine in cooldown or without a resolved credential is
|
|
855
|
+
* skipped with a recorded attempt.
|
|
856
|
+
*
|
|
857
|
+
* @param request - normalized standard request.
|
|
858
|
+
* @param deps - transport and credential resolution.
|
|
859
|
+
* @param signal - caller cancellation.
|
|
860
|
+
* @returns success with images, or the complete failure record.
|
|
861
|
+
*/
|
|
862
|
+
async generate(request, deps, signal) {
|
|
863
|
+
const ordered = this.chainOrder(request.engine);
|
|
864
|
+
const attempts = [];
|
|
865
|
+
let tried = 0;
|
|
866
|
+
for (const engine of ordered) {
|
|
867
|
+
if (tried > 0) signal?.throwIfAborted();
|
|
868
|
+
const skip = this.skipReason(engine);
|
|
869
|
+
if (skip !== void 0) {
|
|
870
|
+
attempts.push({
|
|
871
|
+
engine: engine.id,
|
|
872
|
+
code: skip.code,
|
|
873
|
+
message: skip.message
|
|
874
|
+
});
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
tried += 1;
|
|
878
|
+
try {
|
|
879
|
+
const images = await callEngine(engine, this.translate(engine, request), deps, signal);
|
|
880
|
+
this.recordSuccess(engine.id);
|
|
881
|
+
attempts.push({
|
|
882
|
+
engine: engine.id,
|
|
883
|
+
code: "ok"
|
|
884
|
+
});
|
|
885
|
+
return {
|
|
886
|
+
ok: true,
|
|
887
|
+
engine: engine.id,
|
|
888
|
+
model: engine.model,
|
|
889
|
+
size: request.size ?? "square",
|
|
890
|
+
images,
|
|
891
|
+
fallbackUsed: tried > 1,
|
|
892
|
+
attempts
|
|
893
|
+
};
|
|
894
|
+
} catch (error) {
|
|
895
|
+
const view = this.recordFailure(engine.id, error);
|
|
896
|
+
attempts.push(view);
|
|
897
|
+
if (view.phase === "credential" || view.code === "auth" || view.code === "parse") continue;
|
|
898
|
+
if (signal !== void 0 && signal.aborted) throw signal.reason;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return {
|
|
903
|
+
ok: false,
|
|
904
|
+
attempts
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/** One engine's current health snapshot. */
|
|
908
|
+
statusOf(engineId) {
|
|
909
|
+
const health = this.health.get(engineId);
|
|
910
|
+
if (health === void 0) return void 0;
|
|
911
|
+
return {
|
|
912
|
+
engineId,
|
|
913
|
+
consecutiveFailures: health.consecutiveFailures,
|
|
914
|
+
cooldownUntil: health.cooldownUntil,
|
|
915
|
+
lastError: health.lastError,
|
|
916
|
+
lastStatus: health.lastStatus
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/** Health snapshots for every configured engine in chain order. */
|
|
920
|
+
statuses() {
|
|
921
|
+
return this.config.engines.map((engine) => this.statusOf(engine.id)).filter((status) => status !== void 0);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Probe one engine with a cheap authenticated `GET {baseUrl}/models` call.
|
|
925
|
+
* The probe reports reachability and credential validity without mutating
|
|
926
|
+
* routing health — it is a settings-panel check, not the router's memory.
|
|
927
|
+
*
|
|
928
|
+
* @param engine - engine to probe.
|
|
929
|
+
* @param deps - transport and credential resolution.
|
|
930
|
+
* @returns the probe outcome.
|
|
931
|
+
*/
|
|
932
|
+
async probe(engine, deps) {
|
|
933
|
+
const target = sanitizeUrl(`${engine.baseUrl}/models`);
|
|
934
|
+
const credential = await deps.resolveCredential(engine.apiKeyRef);
|
|
935
|
+
if (credential === void 0) return {
|
|
936
|
+
engineId: engine.id,
|
|
937
|
+
reachable: false,
|
|
938
|
+
httpStatus: null,
|
|
939
|
+
target,
|
|
940
|
+
note: `credential reference ${engine.apiKeyRef} is not configured`,
|
|
941
|
+
credentialConfigured: false
|
|
942
|
+
};
|
|
943
|
+
try {
|
|
944
|
+
const response = await deps.transport.request({
|
|
945
|
+
method: "GET",
|
|
946
|
+
url: `${engine.baseUrl}/models`,
|
|
947
|
+
headers: { authorization: `Bearer ${credential}` }
|
|
948
|
+
});
|
|
949
|
+
if (response.status === 401 || response.status === 403) return {
|
|
950
|
+
engineId: engine.id,
|
|
951
|
+
reachable: true,
|
|
952
|
+
httpStatus: response.status,
|
|
953
|
+
target,
|
|
954
|
+
note: `endpoint answered but rejected the credential (HTTP ${response.status})`,
|
|
955
|
+
credentialConfigured: true
|
|
956
|
+
};
|
|
957
|
+
if (response.status === 404 || response.status === 405 || response.status === 501) return {
|
|
958
|
+
engineId: engine.id,
|
|
959
|
+
reachable: true,
|
|
960
|
+
httpStatus: response.status,
|
|
961
|
+
target,
|
|
962
|
+
note: `endpoint answered (HTTP ${response.status}); the models listing may be absent but generation can still work`,
|
|
963
|
+
credentialConfigured: true
|
|
964
|
+
};
|
|
965
|
+
if (response.status < 200 || response.status >= 300) return {
|
|
966
|
+
engineId: engine.id,
|
|
967
|
+
reachable: true,
|
|
968
|
+
httpStatus: response.status,
|
|
969
|
+
target,
|
|
970
|
+
note: `endpoint answered with HTTP ${response.status}`,
|
|
971
|
+
credentialConfigured: true
|
|
972
|
+
};
|
|
973
|
+
return {
|
|
974
|
+
engineId: engine.id,
|
|
975
|
+
reachable: true,
|
|
976
|
+
httpStatus: response.status,
|
|
977
|
+
target,
|
|
978
|
+
note: "endpoint reachable and credential accepted",
|
|
979
|
+
credentialConfigured: true
|
|
980
|
+
};
|
|
981
|
+
} catch (error) {
|
|
982
|
+
return {
|
|
983
|
+
engineId: engine.id,
|
|
984
|
+
reachable: false,
|
|
985
|
+
httpStatus: null,
|
|
986
|
+
target,
|
|
987
|
+
note: sanitizeError(error),
|
|
988
|
+
credentialConfigured: true
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
/** Chain order: an explicit engine override first, then config order minus the override. */
|
|
993
|
+
chainOrder(override) {
|
|
994
|
+
const engines = [...this.config.engines];
|
|
995
|
+
if (override === void 0 || override === this.config.defaultEngine) {
|
|
996
|
+
const index = engines.findIndex((engine) => engine.id === this.config.defaultEngine);
|
|
997
|
+
if (index > 0) {
|
|
998
|
+
const [preferred] = engines.splice(index, 1);
|
|
999
|
+
engines.unshift(preferred);
|
|
1000
|
+
}
|
|
1001
|
+
return engines;
|
|
1002
|
+
}
|
|
1003
|
+
const index = engines.findIndex((engine) => engine.id === override);
|
|
1004
|
+
if (index < 0) return engines;
|
|
1005
|
+
const [preferred] = engines.splice(index, 1);
|
|
1006
|
+
engines.unshift(preferred);
|
|
1007
|
+
return engines;
|
|
1008
|
+
}
|
|
1009
|
+
/** Why an engine may not even be attempted: disabled, cooling down. */
|
|
1010
|
+
skipReason(engine) {
|
|
1011
|
+
if (!engine.enabled) return {
|
|
1012
|
+
code: "disabled",
|
|
1013
|
+
message: `engine "${engine.id}" is disabled`
|
|
1014
|
+
};
|
|
1015
|
+
const health = this.health.get(engine.id);
|
|
1016
|
+
const now = this.now();
|
|
1017
|
+
const cooldown = health?.cooldownUntil;
|
|
1018
|
+
if (cooldown !== null && cooldown !== void 0 && cooldown > now) return {
|
|
1019
|
+
code: "cooldown",
|
|
1020
|
+
message: `engine "${engine.id}" is cooling down after repeated failures`
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/** Translate the standard request against one engine (the pure translate step). */
|
|
1024
|
+
translate(engine, request) {
|
|
1025
|
+
return translateRequest(engine, request);
|
|
1026
|
+
}
|
|
1027
|
+
/** Record a success: reset consecutive failures and cooldown. */
|
|
1028
|
+
recordSuccess(engineId) {
|
|
1029
|
+
const health = this.health.get(engineId);
|
|
1030
|
+
if (health === void 0) return;
|
|
1031
|
+
health.consecutiveFailures = 0;
|
|
1032
|
+
health.cooldownUntil = null;
|
|
1033
|
+
health.lastError = null;
|
|
1034
|
+
health.lastStatus = null;
|
|
1035
|
+
}
|
|
1036
|
+
/** Record a failure: bump the counter, trip cooldown at the threshold, and build the attempt view. */
|
|
1037
|
+
recordFailure(engineId, error) {
|
|
1038
|
+
const health = this.health.get(engineId);
|
|
1039
|
+
const engineError = error instanceof EngineCallError ? error : void 0;
|
|
1040
|
+
const phase = engineError?.phase ?? "request";
|
|
1041
|
+
const code = engineError?.code ?? "http";
|
|
1042
|
+
const message = sanitizeText(sanitizeError(error));
|
|
1043
|
+
const status = engineError?.status;
|
|
1044
|
+
if (health !== void 0) {
|
|
1045
|
+
health.consecutiveFailures += 1;
|
|
1046
|
+
health.lastError = message;
|
|
1047
|
+
health.lastStatus = status ?? null;
|
|
1048
|
+
if (health.consecutiveFailures >= this.config.failureThreshold) health.cooldownUntil = this.now() + this.config.cooldownMs;
|
|
1049
|
+
}
|
|
1050
|
+
return {
|
|
1051
|
+
engine: engineId,
|
|
1052
|
+
phase,
|
|
1053
|
+
code,
|
|
1054
|
+
message,
|
|
1055
|
+
...status !== void 0 ? { status } : {}
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
//#endregion
|
|
1060
|
+
//#region src/version.ts
|
|
1061
|
+
/**
|
|
1062
|
+
* The plugin version, hardcoded beside `package.json` so the release script
|
|
1063
|
+
* bumps both and `tests/version.spec.ts` trips when they drift. Served in the
|
|
1064
|
+
* `draw/status` snapshot so the settings panel shows which plugin build runs.
|
|
1065
|
+
*
|
|
1066
|
+
* @module dsh-draw/version
|
|
1067
|
+
*/
|
|
1068
|
+
/** Plugin version; must equal the `version` field in `package.json`. */
|
|
1069
|
+
const PLUGIN_VERSION = "0.1.0";
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region src/service.ts
|
|
1072
|
+
/**
|
|
1073
|
+
* The `draw` Typert Remote service.
|
|
1074
|
+
*/
|
|
1075
|
+
var DrawService = class extends TypertRemoteService {
|
|
1076
|
+
/** Per-call bindings (replaced on plugin reload). */
|
|
1077
|
+
options;
|
|
1078
|
+
/**
|
|
1079
|
+
* @param ctx - the mounting context.
|
|
1080
|
+
* @param options - runtime bindings.
|
|
1081
|
+
*/
|
|
1082
|
+
constructor(ctx, options) {
|
|
1083
|
+
super(ctx, "draw");
|
|
1084
|
+
this.options = options;
|
|
1085
|
+
}
|
|
1086
|
+
/** Resolve one engine's credential view for the status snapshot. */
|
|
1087
|
+
async credentialView(reference) {
|
|
1088
|
+
const credentials = this.options.credentials;
|
|
1089
|
+
if (credentials === void 0) return {
|
|
1090
|
+
configured: false,
|
|
1091
|
+
writable: false
|
|
1092
|
+
};
|
|
1093
|
+
try {
|
|
1094
|
+
const info = await credentials.describe(credentialRef(reference));
|
|
1095
|
+
return {
|
|
1096
|
+
configured: info.configured,
|
|
1097
|
+
writable: info.writable,
|
|
1098
|
+
...info.source !== void 0 ? { source: info.source } : {}
|
|
1099
|
+
};
|
|
1100
|
+
} catch {
|
|
1101
|
+
return {
|
|
1102
|
+
configured: false,
|
|
1103
|
+
writable: false
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/** Read-only panel snapshot: engine chain, health, credential facts, quota. */
|
|
1108
|
+
async status() {
|
|
1109
|
+
const { config, router } = this.options;
|
|
1110
|
+
const engines = [];
|
|
1111
|
+
for (const engine of config.engines) {
|
|
1112
|
+
const status = router.statusOf(engine.id);
|
|
1113
|
+
const view = statusToView(status ?? {
|
|
1114
|
+
engineId: engine.id,
|
|
1115
|
+
consecutiveFailures: 0,
|
|
1116
|
+
cooldownUntil: null,
|
|
1117
|
+
lastError: null,
|
|
1118
|
+
lastStatus: null
|
|
1119
|
+
}, {
|
|
1120
|
+
id: engine.id,
|
|
1121
|
+
model: engine.model,
|
|
1122
|
+
baseUrl: engine.baseUrl,
|
|
1123
|
+
apiKeyRef: engine.apiKeyRef,
|
|
1124
|
+
enabled: engine.enabled,
|
|
1125
|
+
preferred: engine.id === config.defaultEngine
|
|
1126
|
+
}, await this.credentialView(engine.apiKeyRef));
|
|
1127
|
+
engines.push(view);
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1131
|
+
engines,
|
|
1132
|
+
quota: {
|
|
1133
|
+
maxGenerationsPerSession: config.maxGenerationsPerSession,
|
|
1134
|
+
maxBytesPerSession: config.maxBytesPerSession
|
|
1135
|
+
},
|
|
1136
|
+
requestTimeoutMs: config.requestTimeoutMs,
|
|
1137
|
+
maxImagesPerCall: config.maxImagesPerCall
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
/** Probe one engine's connectivity without mutating routing health. */
|
|
1141
|
+
async probe(engineId) {
|
|
1142
|
+
const engine = engineById(this.options.config, engineId);
|
|
1143
|
+
if (engine === void 0) return {
|
|
1144
|
+
engineId,
|
|
1145
|
+
reachable: false,
|
|
1146
|
+
httpStatus: null,
|
|
1147
|
+
target: "",
|
|
1148
|
+
note: `unknown engine "${engineId}"`,
|
|
1149
|
+
credentialConfigured: false
|
|
1150
|
+
};
|
|
1151
|
+
const outcome = await this.options.router.probe(engine, {
|
|
1152
|
+
transport: this.options.drawer.deps.engine.transport,
|
|
1153
|
+
resolveCredential: this.options.drawer.deps.engine.resolveCredential
|
|
1154
|
+
});
|
|
1155
|
+
return probeToWire(outcome);
|
|
1156
|
+
}
|
|
1157
|
+
/** Store one API key under the engine's credential reference (credentials seam). */
|
|
1158
|
+
async setCredential(engineId, value) {
|
|
1159
|
+
const engine = engineById(this.options.config, engineId);
|
|
1160
|
+
if (engine === void 0) throw new TypeError(`unknown engine "${engineId}"`);
|
|
1161
|
+
const credentials = this.options.credentials;
|
|
1162
|
+
if (credentials === void 0) throw new TypeError("the credential service is not composed on this profile");
|
|
1163
|
+
if (typeof value !== "string" || value.length === 0) throw new TypeError("credential value must be a non-empty string");
|
|
1164
|
+
await credentials.set(credentialRef(engine.apiKeyRef), value);
|
|
1165
|
+
return {
|
|
1166
|
+
engineId,
|
|
1167
|
+
reference: engine.apiKeyRef,
|
|
1168
|
+
note: `stored under the ${engine.apiKeyRef} credential reference`
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
/** Remove a stored API key for the engine's credential reference. */
|
|
1172
|
+
async unsetCredential(engineId) {
|
|
1173
|
+
const engine = engineById(this.options.config, engineId);
|
|
1174
|
+
if (engine === void 0) throw new TypeError(`unknown engine "${engineId}"`);
|
|
1175
|
+
const credentials = this.options.credentials;
|
|
1176
|
+
if (credentials === void 0) throw new TypeError("the credential service is not composed on this profile");
|
|
1177
|
+
await credentials.unset(credentialRef(engine.apiKeyRef));
|
|
1178
|
+
return {
|
|
1179
|
+
engineId,
|
|
1180
|
+
reference: engine.apiKeyRef,
|
|
1181
|
+
note: `removed from the ${engine.apiKeyRef} credential reference`
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
/** Re-run a generation from the result card through the full drawer path. */
|
|
1185
|
+
async regenerate(sessionId, args) {
|
|
1186
|
+
const sessions = this.options.drawer.deps.sessions?.();
|
|
1187
|
+
if (sessions === void 0) throw new TypeError("the session store is not composed on this profile");
|
|
1188
|
+
const session = sessions.get(SessionId(sessionId));
|
|
1189
|
+
if (session === void 0) throw new TypeError(`unknown session "${sessionId}"`);
|
|
1190
|
+
const outcome = await this.options.drawer.generate(args, {
|
|
1191
|
+
session,
|
|
1192
|
+
source: "regenerate"
|
|
1193
|
+
});
|
|
1194
|
+
if (!outcome.ok) throw new TypeError(outcome.message);
|
|
1195
|
+
return projectRegenerate(outcome);
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
/** Project a successful draw onto the regenerate wire shape. */
|
|
1199
|
+
function projectRegenerate(outcome) {
|
|
1200
|
+
return {
|
|
1201
|
+
engine: outcome.engine,
|
|
1202
|
+
model: outcome.model,
|
|
1203
|
+
size: outcome.size,
|
|
1204
|
+
images: outcome.images.map(imageToWire),
|
|
1205
|
+
quota: outcome.quota,
|
|
1206
|
+
quotaLimits: outcome.limits,
|
|
1207
|
+
fallbackUsed: outcome.fallbackUsed,
|
|
1208
|
+
elapsedMs: outcome.elapsedMs,
|
|
1209
|
+
attempts: outcome.attempts
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1212
|
+
//#endregion
|
|
1213
|
+
//#region src/tool.ts
|
|
1214
|
+
/**
|
|
1215
|
+
* The `image_generate` tool: the model-facing unified image generation entry.
|
|
1216
|
+
* Standard parameters (prompt/size/count/quality/style/engine) flow into the
|
|
1217
|
+
* shared drawer path; the canonical value carries the durable attachment
|
|
1218
|
+
* references plus quota and routing facts, and the render output embeds the
|
|
1219
|
+
* images as attachment content blocks so both text-only surfaces and the web
|
|
1220
|
+
* conversation show them.
|
|
1221
|
+
*
|
|
1222
|
+
* @module dsh-draw/tool
|
|
1223
|
+
*/
|
|
1224
|
+
/**
|
|
1225
|
+
* Build the `image_generate` tool over one drawer.
|
|
1226
|
+
*
|
|
1227
|
+
* @param drawer - the shared generation path.
|
|
1228
|
+
* @param config - resolved config (prompt cap for the description, timeout budget).
|
|
1229
|
+
* @returns the tool definition.
|
|
1230
|
+
*/
|
|
1231
|
+
function imageGenerateTool(drawer, config) {
|
|
1232
|
+
return defineTool({
|
|
1233
|
+
name: "image_generate",
|
|
1234
|
+
description: `Generate 1..${config.maxImagesPerCall} static images from a text prompt through the configured image engines (OpenAI Images, Zhipu CogView, or any OpenAI-compatible endpoint). Standard parameters are translated per engine; a failing engine falls back down the configured chain. Results are saved as durable attachments and count against the per-session quota. Prompt cap: ${config.maxPromptLength} characters.`,
|
|
1235
|
+
parameters: {
|
|
1236
|
+
prompt: {
|
|
1237
|
+
type: "string",
|
|
1238
|
+
description: "Image prompt.",
|
|
1239
|
+
required: true
|
|
1240
|
+
},
|
|
1241
|
+
size: {
|
|
1242
|
+
type: "string",
|
|
1243
|
+
enum: [
|
|
1244
|
+
"square",
|
|
1245
|
+
"landscape",
|
|
1246
|
+
"portrait",
|
|
1247
|
+
"auto"
|
|
1248
|
+
],
|
|
1249
|
+
description: "Composition (default square)."
|
|
1250
|
+
},
|
|
1251
|
+
count: {
|
|
1252
|
+
type: "integer",
|
|
1253
|
+
description: "Number of images (default 1; capped by configuration)."
|
|
1254
|
+
},
|
|
1255
|
+
quality: {
|
|
1256
|
+
type: "string",
|
|
1257
|
+
enum: [
|
|
1258
|
+
"low",
|
|
1259
|
+
"medium",
|
|
1260
|
+
"high",
|
|
1261
|
+
"auto"
|
|
1262
|
+
],
|
|
1263
|
+
description: "Quality tier (default auto; dropped for engines without it)."
|
|
1264
|
+
},
|
|
1265
|
+
style: {
|
|
1266
|
+
type: "string",
|
|
1267
|
+
enum: ["natural", "vivid"],
|
|
1268
|
+
description: "Style preset (dropped for engines without it)."
|
|
1269
|
+
},
|
|
1270
|
+
engine: {
|
|
1271
|
+
type: "string",
|
|
1272
|
+
description: "Engine id override (must name a configured engine; omitted = chain order)."
|
|
1273
|
+
}
|
|
1274
|
+
},
|
|
1275
|
+
output: {
|
|
1276
|
+
schema: {
|
|
1277
|
+
type: "object",
|
|
1278
|
+
properties: {
|
|
1279
|
+
ok: {
|
|
1280
|
+
type: "boolean",
|
|
1281
|
+
const: true
|
|
1282
|
+
},
|
|
1283
|
+
engine: { type: "string" },
|
|
1284
|
+
model: { type: "string" },
|
|
1285
|
+
size: { type: "string" },
|
|
1286
|
+
images: {
|
|
1287
|
+
type: "array",
|
|
1288
|
+
items: {
|
|
1289
|
+
type: "object",
|
|
1290
|
+
properties: {
|
|
1291
|
+
attachmentId: { type: "string" },
|
|
1292
|
+
mediaType: { type: "string" },
|
|
1293
|
+
bytes: { type: "integer" },
|
|
1294
|
+
width: { type: "integer" },
|
|
1295
|
+
height: { type: "integer" },
|
|
1296
|
+
name: { type: "string" }
|
|
1297
|
+
},
|
|
1298
|
+
additionalProperties: false
|
|
1299
|
+
}
|
|
1300
|
+
},
|
|
1301
|
+
quota: {
|
|
1302
|
+
type: "object",
|
|
1303
|
+
properties: {
|
|
1304
|
+
generations: { type: "integer" },
|
|
1305
|
+
bytes: { type: "integer" }
|
|
1306
|
+
},
|
|
1307
|
+
additionalProperties: false
|
|
1308
|
+
},
|
|
1309
|
+
limits: {
|
|
1310
|
+
type: "object",
|
|
1311
|
+
properties: {
|
|
1312
|
+
maxGenerations: { type: "integer" },
|
|
1313
|
+
maxBytes: { type: "integer" }
|
|
1314
|
+
},
|
|
1315
|
+
additionalProperties: false
|
|
1316
|
+
},
|
|
1317
|
+
fallbackUsed: { type: "boolean" },
|
|
1318
|
+
elapsedMs: { type: "integer" },
|
|
1319
|
+
attempts: {
|
|
1320
|
+
type: "array",
|
|
1321
|
+
items: {
|
|
1322
|
+
type: "object",
|
|
1323
|
+
properties: {
|
|
1324
|
+
engine: { type: "string" },
|
|
1325
|
+
phase: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
1326
|
+
code: { type: "string" },
|
|
1327
|
+
message: { oneOf: [{ type: "string" }, { type: "null" }] },
|
|
1328
|
+
status: { oneOf: [{ type: "integer" }, { type: "null" }] }
|
|
1329
|
+
},
|
|
1330
|
+
additionalProperties: false
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
},
|
|
1334
|
+
additionalProperties: false
|
|
1335
|
+
},
|
|
1336
|
+
render(_args, value) {
|
|
1337
|
+
const result = value;
|
|
1338
|
+
const lines = [`Generated ${result.images.length} image(s) via ${result.engine} (${result.model}, ${result.size})${result.fallbackUsed ? " after fallback" : ""} in ${result.elapsedMs} ms.`, `Quota: ${result.quota.generations}/${result.limits.maxGenerations} calls, ${result.quota.bytes}/${result.limits.maxBytes} bytes this session.`];
|
|
1339
|
+
for (const attempt of result.attempts) {
|
|
1340
|
+
if (attempt.code === "ok") continue;
|
|
1341
|
+
lines.push(`- ${attempt.engine}: ${attempt.code}${attempt.message === void 0 ? "" : ` (${attempt.message})`}`);
|
|
1342
|
+
}
|
|
1343
|
+
const blocks = [{
|
|
1344
|
+
type: "text",
|
|
1345
|
+
text: lines.join("\n")
|
|
1346
|
+
}];
|
|
1347
|
+
for (const image of result.images) {
|
|
1348
|
+
const attachment = {
|
|
1349
|
+
attachmentId: AttachmentId(image.attachmentId),
|
|
1350
|
+
mediaType: image.mediaType,
|
|
1351
|
+
bytes: image.bytes,
|
|
1352
|
+
width: image.width,
|
|
1353
|
+
height: image.height,
|
|
1354
|
+
...image.name !== void 0 ? { name: image.name } : {}
|
|
1355
|
+
};
|
|
1356
|
+
blocks.push({
|
|
1357
|
+
type: "image",
|
|
1358
|
+
attachment
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
return blocks;
|
|
1362
|
+
},
|
|
1363
|
+
presentationMeta(_args, value) {
|
|
1364
|
+
return value;
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
timeoutMs: config.requestTimeoutMs,
|
|
1368
|
+
async execute(args, exec) {
|
|
1369
|
+
const outcome = await drawer.generate(args, {
|
|
1370
|
+
session: exec.agent?.session,
|
|
1371
|
+
source: "tool",
|
|
1372
|
+
signal: exec.signal
|
|
1373
|
+
});
|
|
1374
|
+
if (!outcome.ok) throw new Error(outcome.message);
|
|
1375
|
+
return {
|
|
1376
|
+
...outcome,
|
|
1377
|
+
images: [...outcome.images],
|
|
1378
|
+
attempts: [...outcome.attempts]
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
//#endregion
|
|
1384
|
+
//#region src/index.ts
|
|
1385
|
+
const name = "dsh-draw";
|
|
1386
|
+
/** Hard services: the tool registry every contribution lands in. */
|
|
1387
|
+
const inject = ["tools"];
|
|
1388
|
+
/** Response byte ceiling: one engine call may carry several full-size images. */
|
|
1389
|
+
const MAX_RESPONSE_BYTES = 67108864;
|
|
1390
|
+
/**
|
|
1391
|
+
* Mount the plugin: router, drawer, the `image_generate` tool, and the `draw`
|
|
1392
|
+
* Remote service. Every registration is an effect on this fiber, so
|
|
1393
|
+
* unload/hot-reload removes the tool and the service together.
|
|
1394
|
+
*
|
|
1395
|
+
* @param ctx - context carrying tools plus the optional attachment/credentials seams.
|
|
1396
|
+
* @param config - raw loader config; defaults applied through {@link resolveConfig}.
|
|
1397
|
+
*/
|
|
1398
|
+
async function apply(ctx, config) {
|
|
1399
|
+
const resolved = resolveConfig(config);
|
|
1400
|
+
const logger = ctx.logger("draw");
|
|
1401
|
+
const transport = ctx.get("dsh-draw/transport") ?? defaultHttpTransport(resolved.requestTimeoutMs, MAX_RESPONSE_BYTES);
|
|
1402
|
+
const router = new EngineRouter(resolved, {
|
|
1403
|
+
failureThreshold: resolved.failureThreshold,
|
|
1404
|
+
cooldownMs: resolved.cooldownMs
|
|
1405
|
+
});
|
|
1406
|
+
const credentials = () => ctx.get("credentials");
|
|
1407
|
+
const drawer = new Drawer(resolved, router, {
|
|
1408
|
+
engine: {
|
|
1409
|
+
transport,
|
|
1410
|
+
resolveCredential: async (reference) => {
|
|
1411
|
+
const service = credentials();
|
|
1412
|
+
if (service === void 0) return void 0;
|
|
1413
|
+
return (await service.resolve(credentialRef(reference)))?.value;
|
|
1414
|
+
}
|
|
1415
|
+
},
|
|
1416
|
+
attachments: () => ctx.get("attachments"),
|
|
1417
|
+
sessions: () => ctx.get("sessions")
|
|
1418
|
+
});
|
|
1419
|
+
ctx.effect(() => ctx.tools.register(imageGenerateTool(drawer, resolved)), "dsh-draw: image_generate tool");
|
|
1420
|
+
await ctx.plugin(DrawService, {
|
|
1421
|
+
config: resolved,
|
|
1422
|
+
router,
|
|
1423
|
+
drawer,
|
|
1424
|
+
credentials: credentials()
|
|
1425
|
+
});
|
|
1426
|
+
logger.info(`image generation enabled: ${resolved.engines.map((engine) => engine.id).join(", ")} (preferred ${resolved.defaultEngine})`);
|
|
1427
|
+
}
|
|
1428
|
+
//#endregion
|
|
1429
|
+
export { Config, DEFAULT_ENGINES, DRAW_INVOCATIONS, DrawService, Drawer, EngineCallError, EngineRouter, HttpError, PLUGIN_VERSION, REDACTED, appendDrawGenerated, apply, callEngine, checkQuotaBytes, checkQuotaGenerations, defaultHttpTransport, drawGeneratedEvents, engineById, fusedSignal, imageGenerateTool, imageToWire, inject, name, normalizeRequest, probeToWire, quotaState, resolveConfig, sanitizeError, sanitizeText, sanitizeUrl, statusToView, translateRequest };
|