codex-copilot-dx 0.7.9 → 0.8.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/README.md +11 -5
- package/package.json +1 -1
- package/src/adapter.mjs +1 -1
- package/src/cli-animation.mjs +43 -8
- package/src/cli-image.mjs +12 -3
- package/src/cli-options.mjs +1 -1
- package/src/codex-config-doctor.mjs +8 -3
- package/src/config-toml.mjs +147 -0
- package/src/config.mjs +74 -89
- package/src/copilot-responses-compat.mjs +4 -0
- package/src/image-mcp.mjs +62 -7
- package/src/image-provider-config.mjs +4 -1
- package/src/image-provider.mjs +96 -41
- package/src/image-references.mjs +41 -0
- package/src/image-skill.mjs +11 -7
- package/src/image-tool-client.mjs +16 -7
- package/src/responses-handler.mjs +38 -0
- package/src/responses-proxy.mjs +55 -22
- package/src/responses-request.mjs +3 -1
- package/src/terminal-animation-gallery.mjs +85 -0
package/src/image-mcp.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { isLoopbackAddress } from "./observability.mjs";
|
|
2
|
-
import { generateImage } from "./image-provider.mjs";
|
|
2
|
+
import { generateImage, supportsImageEditing } from "./image-provider.mjs";
|
|
3
3
|
import { readImageProviderConfig } from "./image-provider-config.mjs";
|
|
4
|
+
import { createImageReferenceStore } from "./image-references.mjs";
|
|
4
5
|
|
|
5
6
|
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
6
7
|
const MCP_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
|
|
7
8
|
const MCP_MAX_BODY_BYTES = 256 * 1024;
|
|
8
9
|
const IMAGE_TOOL_NAME = "generate_image";
|
|
10
|
+
const EDIT_TOOL_NAME = "edit_image";
|
|
9
11
|
|
|
10
12
|
function sameHostSocket(socket) {
|
|
11
13
|
const remote = String(socket?.remoteAddress || "").replace(/^::ffff:/, "");
|
|
@@ -66,6 +68,25 @@ function imageTool() {
|
|
|
66
68
|
};
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
function editTool() {
|
|
72
|
+
const tool = imageTool();
|
|
73
|
+
return {
|
|
74
|
+
...tool,
|
|
75
|
+
name: EDIT_TOOL_NAME,
|
|
76
|
+
title: "Edit image",
|
|
77
|
+
description: "Edit a previously generated CCDX image using its exact image_id and a change instruction. Preserve the source and return a new image with a new ID for further edits. Source images must still be retained by this running adapter. Does not accept file paths, URLs, masks, or arbitrary uploads.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
...tool.inputSchema,
|
|
80
|
+
properties: {
|
|
81
|
+
image_id: { type: "string", description: "The image_id returned in the result of the specific CCDX image to edit." },
|
|
82
|
+
prompt: { type: "string", description: "Describe the requested changes and what to preserve from the source image." },
|
|
83
|
+
size: { type: "string", enum: [...tool.inputSchema.properties.size.enum], description: "Optional output dimensions; defaults to the source image's requested size." },
|
|
84
|
+
},
|
|
85
|
+
required: ["image_id", "prompt"],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
function safeToolError(error, apiKey) {
|
|
70
91
|
if (typeof error?.message === "string" && error.message) {
|
|
71
92
|
let message = error.message.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
@@ -79,6 +100,7 @@ export function createImageMcpHandler({
|
|
|
79
100
|
configLoader = () => readImageProviderConfig(),
|
|
80
101
|
generateImageFn = generateImage,
|
|
81
102
|
maxConcurrent = 2,
|
|
103
|
+
imageReferences = createImageReferenceStore(),
|
|
82
104
|
} = {}) {
|
|
83
105
|
let active = 0;
|
|
84
106
|
return async function imageMcpHandler(req, res) {
|
|
@@ -91,6 +113,17 @@ export function createImageMcpHandler({
|
|
|
91
113
|
writeJson(res, 405, jsonRpcError(null, -32600, "Only POST is supported"));
|
|
92
114
|
return;
|
|
93
115
|
}
|
|
116
|
+
// This endpoint serves native Codex/Node clients, not browser pages. A
|
|
117
|
+
// loopback socket alone does not make a browser-supplied Origin trusted.
|
|
118
|
+
if (Object.hasOwn(req.headers || {}, "origin")) {
|
|
119
|
+
writeJson(res, 403, jsonRpcError(null, -32001, "Browser origins are not allowed for Image MCP"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const mediaType = String(req.headers?.["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
|
|
123
|
+
if (mediaType !== "application/json") {
|
|
124
|
+
writeJson(res, 415, jsonRpcError(null, -32600, "Image MCP requires application/json"));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
94
127
|
|
|
95
128
|
let request;
|
|
96
129
|
try { request = await readBody(req); }
|
|
@@ -109,12 +142,14 @@ export function createImageMcpHandler({
|
|
|
109
142
|
const reply = (result) => writeJson(res, 200, { jsonrpc: "2.0", id: request.id, result });
|
|
110
143
|
if (request.method === "initialize") {
|
|
111
144
|
const requestedVersion = String(request.params?.protocolVersion || "");
|
|
145
|
+
const config = configLoader();
|
|
146
|
+
if (!config) imageReferences.clear();
|
|
112
147
|
reply({
|
|
113
148
|
protocolVersion: MCP_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : MCP_PROTOCOL_VERSION,
|
|
114
149
|
capabilities: { tools: { listChanged: false } },
|
|
115
150
|
serverInfo: { name: "ccdx-image", version: "1" },
|
|
116
|
-
instructions:
|
|
117
|
-
?
|
|
151
|
+
instructions: config
|
|
152
|
+
? `The user enabled CCDX as their image provider. Call generate_image for new images.${supportsImageEditing(config) ? " For changes to a CCDX image, call edit_image with that image's returned image_id. Keep each returned ID with its image; there is no global last image." : " Editing is not supported by this configured provider."} Display the returned image. CCDX handles credentials and the API; no Python, SDK installation, extra API key or alternate endpoint is needed. Do not automatically retry failed generations or edits.`
|
|
118
153
|
: "Image generation is disabled. Enable it with ccdx enable-image before requesting images.",
|
|
119
154
|
});
|
|
120
155
|
return;
|
|
@@ -125,22 +160,29 @@ export function createImageMcpHandler({
|
|
|
125
160
|
}
|
|
126
161
|
if (request.method === "tools/list") {
|
|
127
162
|
const config = configLoader();
|
|
128
|
-
|
|
163
|
+
if (!config) imageReferences.clear();
|
|
164
|
+
reply({ tools: config ? [imageTool(), ...(supportsImageEditing(config) ? [editTool()] : [])] : [] });
|
|
129
165
|
return;
|
|
130
166
|
}
|
|
131
167
|
if (request.method !== "tools/call") {
|
|
132
168
|
writeJson(res, 200, jsonRpcError(request.id, -32601, "Method not found"));
|
|
133
169
|
return;
|
|
134
170
|
}
|
|
135
|
-
if (request.params?.name
|
|
171
|
+
if (![IMAGE_TOOL_NAME, EDIT_TOOL_NAME].includes(request.params?.name)) {
|
|
136
172
|
writeJson(res, 200, jsonRpcError(request.id, -32602, "Unknown image tool"));
|
|
137
173
|
return;
|
|
138
174
|
}
|
|
139
175
|
const config = configLoader();
|
|
140
176
|
if (!config) {
|
|
177
|
+
imageReferences.clear();
|
|
141
178
|
reply({ content: [{ type: "text", text: "Image generation is disabled. Run ccdx enable-image first." }], isError: true });
|
|
142
179
|
return;
|
|
143
180
|
}
|
|
181
|
+
const editing = request.params.name === EDIT_TOOL_NAME;
|
|
182
|
+
if (editing && !supportsImageEditing(config)) {
|
|
183
|
+
reply({ content: [{ type: "text", text: "The configured image provider does not support CCDX editing. No edit was submitted." }], isError: true });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
144
186
|
if (active >= maxConcurrent) {
|
|
145
187
|
reply({ content: [{ type: "text", text: "Image generation is busy. Try again after the current request finishes." }], isError: true });
|
|
146
188
|
return;
|
|
@@ -151,11 +193,24 @@ export function createImageMcpHandler({
|
|
|
151
193
|
req.once?.("aborted", cancel);
|
|
152
194
|
res.once?.("close", cancel);
|
|
153
195
|
try {
|
|
154
|
-
const
|
|
196
|
+
const args = request.params?.arguments || {};
|
|
197
|
+
// Only an explicit retained handle can add source pixels to a request.
|
|
198
|
+
const source = editing ? imageReferences.resolve(config, args.image_id) : null;
|
|
199
|
+
const parameters = { prompt: args.prompt, size: args.size ?? source?.size ?? "1024x1024" };
|
|
200
|
+
if (source) parameters.image = source.image;
|
|
201
|
+
const image = await generateImageFn(config, parameters, { signal: abort.signal });
|
|
202
|
+
let imageId = null;
|
|
203
|
+
if (supportsImageEditing(config)) {
|
|
204
|
+
try { imageId = imageReferences.remember(config, { ...image, size: parameters.size }); } catch {}
|
|
205
|
+
}
|
|
206
|
+
const note = imageId ? ` Image ID: ${imageId}. Use this ID for further edits while retained by this adapter.`
|
|
207
|
+
: supportsImageEditing(config) ? " The image is available, but an editing reference could not be retained." : "";
|
|
155
208
|
reply({
|
|
209
|
+
// Codex prioritizes structuredContent over image blocks. Keep IDs in metadata/text.
|
|
210
|
+
...(imageId ? { _meta: { "ccdx/image_id": imageId } } : {}),
|
|
156
211
|
content: [
|
|
157
212
|
{ type: "image", data: image.data, mimeType: image.mimeType },
|
|
158
|
-
{ type: "text", text:
|
|
213
|
+
{ type: "text", text: `${editing ? "Edited" : "Generated"} ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.${note}` },
|
|
159
214
|
],
|
|
160
215
|
});
|
|
161
216
|
} catch (error) {
|
|
@@ -170,6 +170,7 @@ export async function inspectImageProvider({
|
|
|
170
170
|
apiKey,
|
|
171
171
|
fetchImpl = fetch,
|
|
172
172
|
timeoutMs = IMAGE_PROVIDER_TIMEOUT_MS,
|
|
173
|
+
selectModel,
|
|
173
174
|
} = {}) {
|
|
174
175
|
const normalizedEndpoint = normalizeImageEndpoint(endpoint);
|
|
175
176
|
const normalizedKey = checkedApiKey(apiKey);
|
|
@@ -178,11 +179,13 @@ export async function inspectImageProvider({
|
|
|
178
179
|
if (!models.response.ok) throw new Error(`Image API model lookup failed with HTTP ${models.response.status}`);
|
|
179
180
|
const modelIds = imageModelIds(models.body);
|
|
180
181
|
if (!modelIds.length) throw new Error("Image API model lookup returned no image models");
|
|
182
|
+
const model = selectModel ? await selectModel([...modelIds]) : modelIds[0];
|
|
183
|
+
if (!modelIds.includes(model)) throw new Error("Selected image model is not in the provider catalog");
|
|
181
184
|
|
|
182
185
|
const probe = await fetchJson(normalizedEndpoint, {
|
|
183
186
|
method: "POST",
|
|
184
187
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
185
|
-
body: JSON.stringify({ model
|
|
188
|
+
body: JSON.stringify({ model }),
|
|
186
189
|
}, { fetchImpl, timeoutMs });
|
|
187
190
|
const protocol = detectProtocol(probe.response.status, probe.body);
|
|
188
191
|
if (!protocol) throw new Error("Image API protocol is unsupported or could not be detected safely");
|
package/src/image-provider.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import https from "node:https";
|
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
|
|
5
5
|
export const IMAGE_MAX_BYTES = 32 * 1024 * 1024;
|
|
6
|
+
export const IMAGE_INPUT_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
7
|
export const IMAGE_GENERATION_TIMEOUT_MS = 180_000;
|
|
7
8
|
export const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
8
9
|
export const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
@@ -28,15 +29,59 @@ function checkedSize(value) {
|
|
|
28
29
|
return size;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
function
|
|
32
|
+
export function supportsImageEditing(config) {
|
|
33
|
+
return ["qwen-image-3.0-pro", "qwen-image-3.0"].includes(config?.model)
|
|
34
|
+
&& ["qwen-messages", "openai-images"].includes(config?.protocol);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function checkedImage(config, value) {
|
|
38
|
+
if (value === undefined) return undefined;
|
|
39
|
+
if (!supportsImageEditing(config)) {
|
|
40
|
+
throw providerError("Image editing is not supported by the configured provider", "ccdx_image_edit_unsupported");
|
|
41
|
+
}
|
|
42
|
+
if (typeof value !== "string") {
|
|
43
|
+
throw providerError("Source image must be a PNG, JPEG, or WebP base64 data URI", "ccdx_image_input_invalid");
|
|
44
|
+
}
|
|
45
|
+
if (value.length > 4 * Math.ceil(IMAGE_INPUT_MAX_BYTES / 3) + 32) {
|
|
46
|
+
throw providerError("Source image exceeds the 10 MiB size limit", "ccdx_image_input_too_large");
|
|
47
|
+
}
|
|
48
|
+
const match = /^data:(image\/(?:png|jpeg|webp));base64,/.exec(value);
|
|
49
|
+
if (!match) {
|
|
50
|
+
throw providerError("Source image must be a PNG, JPEG, or WebP base64 data URI", "ccdx_image_input_invalid");
|
|
51
|
+
}
|
|
52
|
+
const encoded = value.slice(match[0].length);
|
|
53
|
+
if (encoded.length > 4 * Math.ceil(IMAGE_INPUT_MAX_BYTES / 3)) {
|
|
54
|
+
throw providerError("Source image exceeds the 10 MiB size limit", "ccdx_image_input_too_large");
|
|
55
|
+
}
|
|
56
|
+
if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) {
|
|
57
|
+
throw providerError("Source image contains invalid base64 data", "ccdx_image_input_invalid");
|
|
58
|
+
}
|
|
59
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
60
|
+
if (bytes.length > IMAGE_INPUT_MAX_BYTES) {
|
|
61
|
+
throw providerError("Source image exceeds the 10 MiB size limit", "ccdx_image_input_too_large");
|
|
62
|
+
}
|
|
63
|
+
if (bytes.toString("base64") !== encoded) {
|
|
64
|
+
throw providerError("Source image contains non-canonical base64 data", "ccdx_image_input_invalid");
|
|
65
|
+
}
|
|
66
|
+
let metadata;
|
|
67
|
+
try { metadata = imageMetadata(bytes); } catch {
|
|
68
|
+
throw providerError("Source image has an unsupported image format", "ccdx_image_input_invalid");
|
|
69
|
+
}
|
|
70
|
+
if (metadata.mimeType !== match[1]) {
|
|
71
|
+
throw providerError("Source image format does not match its data URI", "ccdx_image_input_invalid");
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function generationBody(config, prompt, size, image) {
|
|
32
77
|
if (config.protocol === "qwen-messages") {
|
|
33
78
|
return {
|
|
34
79
|
model: config.model,
|
|
35
|
-
input: { messages: [{ role: "user", content: [{ text: prompt }] }] },
|
|
80
|
+
input: { messages: [{ role: "user", content: [...(image ? [{ image }] : []), { text: prompt }] }] },
|
|
36
81
|
parameters: { n: 1, size: size.replace("x", "*"), watermark: false },
|
|
37
82
|
};
|
|
38
83
|
}
|
|
39
|
-
return { model: config.model, prompt, n: 1, size };
|
|
84
|
+
return { model: config.model, prompt, n: 1, size, ...(image ? { image } : {}) };
|
|
40
85
|
}
|
|
41
86
|
|
|
42
87
|
async function boundedJson(response) {
|
|
@@ -152,64 +197,73 @@ export async function downloadPublicImage(urlValue, {
|
|
|
152
197
|
if (url.protocol !== "https:" || url.username || url.password) {
|
|
153
198
|
throw providerError("Image API returned an unsafe image URL", "ccdx_image_url_unsafe");
|
|
154
199
|
}
|
|
155
|
-
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
|
|
156
|
-
if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
|
|
157
|
-
throw providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe");
|
|
158
|
-
}
|
|
159
200
|
return new Promise((resolve, reject) => {
|
|
160
201
|
let settled = false;
|
|
202
|
+
let request;
|
|
161
203
|
const finish = (error, value) => {
|
|
162
204
|
if (settled) return;
|
|
163
205
|
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
164
207
|
signal?.removeEventListener("abort", onAbort);
|
|
165
|
-
if (error)
|
|
208
|
+
if (error) {
|
|
209
|
+
request?.destroy(error);
|
|
210
|
+
reject(error);
|
|
211
|
+
}
|
|
166
212
|
else resolve(value);
|
|
167
213
|
};
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
finish(providerError(
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
const declared = Number(response.headers["content-length"]);
|
|
181
|
-
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
182
|
-
response.destroy();
|
|
183
|
-
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
214
|
+
const timer = setTimeout(() => finish(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
|
|
215
|
+
timer.unref?.();
|
|
216
|
+
const onAbort = () => finish(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
|
|
217
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
218
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
// DNS and transfer share one deadline; a late lookup cannot start a cancelled download.
|
|
220
|
+
Promise.resolve().then(() => settled ? undefined : lookup(url.hostname, { all: true, verbatim: true })).then((addresses) => {
|
|
221
|
+
if (settled) return;
|
|
222
|
+
if (!Array.isArray(addresses) || !addresses.length || addresses.some(({ address }) => !publicAddress(address))) {
|
|
223
|
+
finish(providerError("Image API returned a non-public image URL", "ccdx_image_url_unsafe"));
|
|
184
224
|
return;
|
|
185
225
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
226
|
+
request = https.get(url, {
|
|
227
|
+
headers: { Accept: "image/*,application/octet-stream;q=0.5" },
|
|
228
|
+
lookup: (_hostname, options, callback) => {
|
|
229
|
+
if (options?.all) callback(null, addresses);
|
|
230
|
+
else callback(null, addresses[0].address, addresses[0].family);
|
|
231
|
+
},
|
|
232
|
+
}, (response) => {
|
|
233
|
+
response.on("error", (error) => finish(error));
|
|
234
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
235
|
+
response.resume();
|
|
236
|
+
finish(providerError(`Image download failed with HTTP ${response.statusCode}`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const declared = Number(response.headers["content-length"]);
|
|
240
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
191
241
|
response.destroy();
|
|
192
242
|
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
193
243
|
return;
|
|
194
244
|
}
|
|
195
|
-
chunks
|
|
245
|
+
const chunks = [];
|
|
246
|
+
let total = 0;
|
|
247
|
+
response.on("data", (chunk) => {
|
|
248
|
+
total += chunk.length;
|
|
249
|
+
if (total > maxBytes) {
|
|
250
|
+
response.destroy();
|
|
251
|
+
finish(providerError("Generated image exceeds the size limit", "ccdx_image_too_large"));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
chunks.push(chunk);
|
|
255
|
+
});
|
|
256
|
+
response.on("end", () => finish(null, Buffer.concat(chunks, total)));
|
|
196
257
|
});
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
});
|
|
200
|
-
const timer = setTimeout(() => request.destroy(providerError("Image download timed out", "ccdx_image_timeout")), timeoutMs);
|
|
201
|
-
timer.unref?.();
|
|
202
|
-
request.on("close", () => clearTimeout(timer));
|
|
203
|
-
request.on("error", (error) => finish(error));
|
|
204
|
-
const onAbort = () => request.destroy(signal.reason instanceof Error ? signal.reason : providerError("Image request was cancelled"));
|
|
205
|
-
if (signal?.aborted) onAbort();
|
|
206
|
-
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
258
|
+
request.on("error", (error) => finish(error));
|
|
259
|
+
}).catch((error) => finish(error));
|
|
207
260
|
});
|
|
208
261
|
}
|
|
209
262
|
|
|
210
263
|
export async function generateImage(config, {
|
|
211
264
|
prompt,
|
|
212
265
|
size = "1024x1024",
|
|
266
|
+
image,
|
|
213
267
|
} = {}, {
|
|
214
268
|
fetchImpl = fetch,
|
|
215
269
|
downloadImage = downloadPublicImage,
|
|
@@ -218,6 +272,7 @@ export async function generateImage(config, {
|
|
|
218
272
|
} = {}) {
|
|
219
273
|
const normalizedPrompt = checkedPrompt(prompt);
|
|
220
274
|
const normalizedSize = checkedSize(size);
|
|
275
|
+
const normalizedImage = checkedImage(config, image);
|
|
221
276
|
const timeout = AbortSignal.timeout(timeoutMs);
|
|
222
277
|
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
223
278
|
let response;
|
|
@@ -229,7 +284,7 @@ export async function generateImage(config, {
|
|
|
229
284
|
"Content-Type": "application/json",
|
|
230
285
|
Accept: "application/json",
|
|
231
286
|
},
|
|
232
|
-
body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize)),
|
|
287
|
+
body: JSON.stringify(generationBody(config, normalizedPrompt, normalizedSize, normalizedImage)),
|
|
233
288
|
signal: requestSignal,
|
|
234
289
|
});
|
|
235
290
|
} catch (error) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createByteLruCache } from "./bounded-result-cache.mjs";
|
|
3
|
+
import { IMAGE_INPUT_MAX_BYTES } from "./image-provider.mjs";
|
|
4
|
+
|
|
5
|
+
const IMAGE_ID = /^ccdx_img_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
6
|
+
|
|
7
|
+
function providerScope(config) {
|
|
8
|
+
return createHash("sha256").update(JSON.stringify([
|
|
9
|
+
config.endpoint, config.model, config.protocol, config.api_key,
|
|
10
|
+
])).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createImageReferenceStore({ maxBytes = 64 * 1024 * 1024 } = {}) {
|
|
14
|
+
const cache = createByteLruCache({ maxBytes });
|
|
15
|
+
return {
|
|
16
|
+
remember(config, image) {
|
|
17
|
+
if (!["image/png", "image/jpeg", "image/webp"].includes(image?.mimeType)
|
|
18
|
+
|| typeof image?.data !== "string" || !image.data
|
|
19
|
+
|| image.data.length > Math.ceil(IMAGE_INPUT_MAX_BYTES / 3) * 4
|
|
20
|
+
|| Buffer.byteLength(image.data, "base64") > IMAGE_INPUT_MAX_BYTES) return null;
|
|
21
|
+
const imageId = `ccdx_img_${randomUUID()}`;
|
|
22
|
+
const retained = cache.set(`${providerScope(config)}:${imageId}`, JSON.stringify({
|
|
23
|
+
image: `data:${image.mimeType};base64,${image.data}`,
|
|
24
|
+
size: image.size,
|
|
25
|
+
}));
|
|
26
|
+
return retained ? imageId : null;
|
|
27
|
+
},
|
|
28
|
+
resolve(config, imageId) {
|
|
29
|
+
if (typeof imageId !== "string" || !IMAGE_ID.test(imageId)) {
|
|
30
|
+
throw new Error("Provide the exact image_id returned by CCDX for the image to edit.");
|
|
31
|
+
}
|
|
32
|
+
const value = cache.get(`${providerScope(config)}:${imageId}`);
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
throw new Error("This image reference is unavailable: the adapter restarted, the image was evicted, or the provider changed. No edit was submitted.");
|
|
35
|
+
}
|
|
36
|
+
return JSON.parse(value);
|
|
37
|
+
},
|
|
38
|
+
clear() { cache.clear(); },
|
|
39
|
+
stats() { return cache.stats(); },
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/image-skill.mjs
CHANGED
|
@@ -108,28 +108,32 @@ export function updateImageSkill({
|
|
|
108
108
|
const endpoint = `${adapterBaseUrl(host, adapterPort)}/mcp/image`;
|
|
109
109
|
const skillBody = `---
|
|
110
110
|
name: ccdx-image
|
|
111
|
-
description: Generate
|
|
111
|
+
description: Generate images from text and edit previously generated CCDX images using the user's configured provider. Use for drawing a new image or modifying a CCDX result identified in the conversation. Excludes arbitrary image uploads, masks, transparent output, and code-native SVG or diagrams.
|
|
112
112
|
---
|
|
113
113
|
|
|
114
114
|
# CCDX image generation
|
|
115
115
|
|
|
116
|
-
The user enabled this provider through ccdx enable-image.
|
|
116
|
+
The user enabled this provider through ccdx enable-image. Use the configured CCDX provider directly for text-to-image requests and supported edits of its previous results.
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
For a new image, use the ccdx_image server's generate_image tool. Supply a complete prompt and one size: 1024x1024 (square), 1536x1024 (landscape), or 1024x1536 (portrait). Display its returned image and retain the returned image_id in the conversation for later edits. No Python, SDK installation, extra API key, or provider discovery is needed.
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
For an edit, use edit_image with the image_id of the specific CCDX result the user wants to change and a prompt describing the changes and what to preserve. Omit size to keep the source dimensions unless the user requests another size. Select the ID from this conversation; never assume a global last image or substitute a different result. Each edit returns a new image and image_id and preserves the source. IDs belong to the running CCDX process and can expire after cache eviction or restart. If the source ID is missing or expired, explain that the original image is unavailable for this edit. The provider must support edit_image; an unsupported edit is not permission to generate a replacement.
|
|
121
|
+
|
|
122
|
+
If the desired tool is not directly callable and the host provides tool search, make one targeted search for ccdx_image generate_image or ccdx_image edit_image as appropriate and use that tool if it becomes callable. If it is still unavailable, run the bundled Node helper once. To generate:
|
|
121
123
|
|
|
122
124
|
\`\`\`sh
|
|
123
125
|
${quote(nodePath)} ${quote(helperPath)} --prompt 'A complete image prompt' --size 1024x1024
|
|
124
126
|
\`\`\`
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
To edit, add --image-id with the exact source ID returned earlier and supply the editing prompt. Omit --size to preserve the source dimensions. This sends one edit_image call to the same service.
|
|
129
|
+
|
|
130
|
+
Quote the actual prompt safely for the shell. The helper uses the same local CCDX service and prints only the saved absolute image path to stdout; stderr reports CCDX image_id for subsequent edits. An optional --out argument selects a new output file; existing files are never overwritten. Inspect and display the saved image with the available image viewer or an absolute-path Markdown image, and retain its new ID.
|
|
127
131
|
|
|
128
132
|
The helper needs loopback HTTP access. If the task reports restricted network access, use the execution tool's normal permission-request mechanism for this exact command before running it; localhost is also restricted. Keep the host's approval policy unchanged.
|
|
129
133
|
|
|
130
|
-
Only use the helper when the MCP tool is absent, not after
|
|
134
|
+
Only use the helper when the MCP tool is absent, not after generation or editing fails or times out: the first request may already have consumed a generation or edit. A helper error explicitly saying generation or editing has not started means no image request was submitted; resolve its stated local connection or permission issue before trying again. Otherwise report the failure once without switching providers, installing dependencies, or repeating the image request.
|
|
131
135
|
|
|
132
|
-
|
|
136
|
+
For arbitrary uploaded images, reference-image workflows, masks, transparent output, or explicitly requested other providers, keep the relevant native tools and skills; do not substitute a fresh CCDX generation.
|
|
133
137
|
`;
|
|
134
138
|
const skillContent = skillBody.replace("# CCDX image generation", `<!-- ccdx:image-skill:${digest(skillBody)} -->\n# CCDX image generation`);
|
|
135
139
|
const helperBody = `${fs.readFileSync(new URL("./image-tool-client.mjs", import.meta.url), "utf8")}\ntry {\n await runImageToolClient({ endpoint: ${JSON.stringify(endpoint)} });\n} catch (error) {\n console.error(String(error?.message || "Image generation failed").replace(/[\\u0000-\\u001f\\u007f-\\u009f]/g, " ").slice(0, 1000));\n process.exitCode = 1;\n}\n`;
|
|
@@ -7,22 +7,25 @@ const IMAGE_SIZES = new Set(["1024x1024", "1536x1024", "1024x1536"]);
|
|
|
7
7
|
const IMAGE_EXTENSIONS = new Map([["image/png", ".png"], ["image/jpeg", ".jpg"], ["image/webp", ".webp"]]);
|
|
8
8
|
const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
|
|
9
9
|
const MAX_RESPONSE_BYTES = 48 * 1024 * 1024;
|
|
10
|
-
const
|
|
10
|
+
const IMAGE_ID = /^ccdx_img_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
const USAGE = "Usage: generate.mjs --prompt <text> [--image-id <previous-CCDX-image-id>] [--size 1024x1024|1536x1024|1024x1536] [--out <new-file>]";
|
|
11
12
|
|
|
12
13
|
export function parseImageToolArgs(args = []) {
|
|
13
14
|
if (args.length === 1 && ["--help", "-h"].includes(args[0])) return { help: true };
|
|
14
15
|
const values = {};
|
|
15
16
|
for (let index = 0; index < args.length; index += 2) {
|
|
16
17
|
const option = args[index];
|
|
17
|
-
if (!["--prompt", "--size", "--out"].includes(option) || option in values) throw new Error(USAGE);
|
|
18
|
+
if (!["--prompt", "--image-id", "--size", "--out"].includes(option) || option in values) throw new Error(USAGE);
|
|
18
19
|
if (typeof args[index + 1] !== "string" || !args[index + 1].trim()) throw new Error(`Missing value for ${option}`);
|
|
19
20
|
values[option] = args[index + 1];
|
|
20
21
|
}
|
|
21
22
|
const prompt = String(values["--prompt"] || "").trim();
|
|
22
23
|
if (!prompt || prompt.length > 16_000) throw new Error("Image prompt must contain 1 to 16000 characters");
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
25
|
-
|
|
24
|
+
const imageId = values["--image-id"]?.trim();
|
|
25
|
+
if (imageId !== undefined && !IMAGE_ID.test(imageId)) throw new Error("Invalid CCDX image ID");
|
|
26
|
+
const size = values["--size"] ? values["--size"].trim().toLowerCase() : (imageId ? undefined : "1024x1024");
|
|
27
|
+
if (size !== undefined && !IMAGE_SIZES.has(size)) throw new Error("Unsupported image size");
|
|
28
|
+
return { prompt, size, out: values["--out"], imageId };
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
function localImageEndpoint(value) {
|
|
@@ -79,6 +82,7 @@ export async function runImageToolClient({
|
|
|
79
82
|
cwd = process.cwd(),
|
|
80
83
|
fetchImpl = fetch,
|
|
81
84
|
output = process.stdout,
|
|
85
|
+
metadataOutput = process.stderr,
|
|
82
86
|
signal = AbortSignal.timeout(210_000),
|
|
83
87
|
} = {}) {
|
|
84
88
|
const options = parseImageToolArgs(args);
|
|
@@ -114,11 +118,14 @@ export async function runImageToolClient({
|
|
|
114
118
|
const detail = ["EPERM", "EACCES"].includes(code)
|
|
115
119
|
? "Local network access was denied. Request normal execution permission for this helper command."
|
|
116
120
|
: cleanError(error.message);
|
|
117
|
-
throw Object.assign(new Error(`Image generation has not started: ${detail}`), { code: "CCDX_IMAGE_NOT_STARTED" });
|
|
121
|
+
throw Object.assign(new Error(`Image ${options.imageId ? "editing" : "generation"} has not started: ${detail}`), { code: "CCDX_IMAGE_NOT_STARTED" });
|
|
118
122
|
}
|
|
123
|
+
const argumentsValue = { prompt: options.prompt };
|
|
124
|
+
if (options.size !== undefined) argumentsValue.size = options.size;
|
|
125
|
+
if (options.imageId) argumentsValue.image_id = options.imageId;
|
|
119
126
|
const result = await readRpcResponse(await post({
|
|
120
127
|
jsonrpc: "2.0", id: 2, method: "tools/call",
|
|
121
|
-
params: { name: "
|
|
128
|
+
params: { name: options.imageId ? "edit_image" : "generate_image", arguments: argumentsValue },
|
|
122
129
|
}), 2);
|
|
123
130
|
if (result.isError) {
|
|
124
131
|
throw new Error(cleanError(result.content?.find((part) => part.type === "text")?.text));
|
|
@@ -137,5 +144,7 @@ export async function runImageToolClient({
|
|
|
137
144
|
const filePath = requestedOutput || path.join(directory, `image-${randomUUID()}${extension}`);
|
|
138
145
|
fs.writeFileSync(filePath, bytes, { flag: "wx", mode: 0o600 });
|
|
139
146
|
output.write(`${filePath}\n`);
|
|
147
|
+
const imageId = result._meta?.["ccdx/image_id"];
|
|
148
|
+
if (typeof imageId === "string" && IMAGE_ID.test(imageId)) metadataOutput.write(`CCDX image_id: ${imageId}\n`);
|
|
140
149
|
return filePath;
|
|
141
150
|
}
|
|
@@ -144,6 +144,42 @@ export function createResponsesHandler(options) {
|
|
|
144
144
|
activeDeadline = now() + timeoutMs;
|
|
145
145
|
abort.setTimeout(timeoutMs, activeTimeoutReason);
|
|
146
146
|
};
|
|
147
|
+
const startStream = () => {
|
|
148
|
+
assertPrepareActive();
|
|
149
|
+
// Headers completed this phase. Stream liveness is now governed by its
|
|
150
|
+
// idle timer, not the original handshake deadline.
|
|
151
|
+
activeDeadline = Number.POSITIVE_INFINITY;
|
|
152
|
+
};
|
|
153
|
+
const acquireRetryPreparation = async (reqContext) => {
|
|
154
|
+
assertPrepareActive();
|
|
155
|
+
activeDeadline = now() + upstreamTimeoutMs;
|
|
156
|
+
activeTimeoutReason = "responses_prepare_timeout";
|
|
157
|
+
abort.setTimeout(upstreamTimeoutMs, activeTimeoutReason);
|
|
158
|
+
let releaseRetryRequest = () => {};
|
|
159
|
+
let retrySnapshot = null;
|
|
160
|
+
const release = () => {
|
|
161
|
+
retrySnapshot?.release();
|
|
162
|
+
releaseRetryRequest();
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
releaseRetryRequest = await measureRequestStageAsync("admission", () => acquireRequest(req, { signal: abort.signal }));
|
|
166
|
+
assertPrepareActive();
|
|
167
|
+
if (reqContext.historyParentId) {
|
|
168
|
+
retrySnapshot = measureRequestStage("history", () => acquireResponseHistorySnapshot(reqContext.historyParentId, {
|
|
169
|
+
assertActive: assertPrepareActive,
|
|
170
|
+
signal: abort.signal,
|
|
171
|
+
}));
|
|
172
|
+
if (retrySnapshot.bytes > 0) {
|
|
173
|
+
await measureRequestStageAsync("admission", () => releaseRetryRequest.reserveResponseHistory?.(retrySnapshot.bytes, { signal: abort.signal }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
assertPrepareActive();
|
|
177
|
+
return { historySnapshot: retrySnapshot, release };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
release();
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
147
183
|
const applyAdaptiveTimeout = (error) => {
|
|
148
184
|
if (!["responses_prepare_timeout", "stream_handshake_timeout", "upstream_timeout"].includes(abort.reason)) {
|
|
149
185
|
return error;
|
|
@@ -251,10 +287,12 @@ export function createResponsesHandler(options) {
|
|
|
251
287
|
applyCopilotResponsesRequestPolicies(prepared.body);
|
|
252
288
|
if (routePlan.protocol === "openai-responses") {
|
|
253
289
|
const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
290
|
+
acquireRetryPreparation,
|
|
254
291
|
assertPrepareActive,
|
|
255
292
|
signal: abort.signal,
|
|
256
293
|
abort,
|
|
257
294
|
onUpstreamStart: startUpstreamTimeout,
|
|
295
|
+
onStreamStart: startStream,
|
|
258
296
|
releaseRequest: releaseUpstreamPayload,
|
|
259
297
|
responseFailures,
|
|
260
298
|
streamIdleTimeoutMs,
|