codex-copilot-dx 0.7.7 → 0.7.8

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.
@@ -33,8 +33,29 @@ const RETRY_POLICIES = Object.freeze([
33
33
  }),
34
34
  ]);
35
35
 
36
+ export function selectCopilotResponseRetry(
37
+ reqContext,
38
+ statusCode,
39
+ errorText,
40
+ usedPolicies = new Set(),
41
+ ) {
42
+ for (const policy of RETRY_POLICIES) {
43
+ if (usedPolicies.has(policy.id) || !policy.matches(statusCode, errorText)) continue;
44
+ const candidate = policy.apply(reqContext);
45
+ if (!candidate) continue;
46
+ usedPolicies.add(policy.id);
47
+ return {
48
+ reqContext: candidate,
49
+ policyId: policy.id,
50
+ warning: policy.warning,
51
+ usedPolicies,
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+
36
57
  export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
37
- const usedPolicies = new Set();
58
+ const usedPolicies = options.usedRetryPolicies || new Set();
38
59
  let payloadPrepared = false;
39
60
  for (let attempt = 0; attempt <= RETRY_POLICIES.length; attempt += 1) {
40
61
  options.assertPrepareActive?.();
@@ -49,7 +70,7 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
49
70
  payloadPrepared = true;
50
71
  if (resp.ok) {
51
72
  reqContext = finalizeEncryptedHistoryRebase(reqContext);
52
- return { resp, reqContext };
73
+ return { resp, reqContext, usedRetryPolicies: usedPolicies };
53
74
  }
54
75
 
55
76
  const errorText = await readBoundedResponseText(resp, {
@@ -57,24 +78,13 @@ export async function openCopilotResponse(reqContext, upstream = copilotResponse
57
78
  label: "Copilot Responses error body",
58
79
  });
59
80
  options.assertPrepareActive?.();
60
- let retryPolicy = null;
61
- let retryContext = null;
62
- for (const policy of RETRY_POLICIES) {
63
- if (usedPolicies.has(policy.id) || !policy.matches(resp.status, errorText)) continue;
64
- const candidate = policy.apply(reqContext);
65
- if (candidate) {
66
- retryPolicy = policy;
67
- retryContext = candidate;
68
- break;
69
- }
70
- }
71
- if (retryPolicy && retryContext) {
72
- usedPolicies.add(retryPolicy.id);
73
- reqContext = retryContext;
74
- console.warn(status("warn", retryPolicy.warning));
81
+ const retry = selectCopilotResponseRetry(reqContext, resp.status, errorText, usedPolicies);
82
+ if (retry) {
83
+ reqContext = retry.reqContext;
84
+ console.warn(status("warn", retry.warning));
75
85
  continue;
76
86
  }
77
- return { resp, reqContext, errorText };
87
+ return { resp, reqContext, errorText, usedRetryPolicies: usedPolicies };
78
88
  }
79
89
  throw httpError("Responses compatibility retry limit exceeded", 502);
80
90
  }
@@ -0,0 +1,169 @@
1
+ import { isLoopbackAddress } from "./observability.mjs";
2
+ import { generateImage } from "./image-provider.mjs";
3
+ import { readImageProviderConfig } from "./image-provider-config.mjs";
4
+
5
+ const MCP_PROTOCOL_VERSION = "2025-06-18";
6
+ const MCP_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26", "2024-11-05"]);
7
+ const MCP_MAX_BODY_BYTES = 256 * 1024;
8
+ const IMAGE_TOOL_NAME = "generate_image";
9
+
10
+ function sameHostSocket(socket) {
11
+ const remote = String(socket?.remoteAddress || "").replace(/^::ffff:/, "");
12
+ const local = String(socket?.localAddress || "").replace(/^::ffff:/, "");
13
+ return remote && local && remote === local;
14
+ }
15
+
16
+ function writeJson(res, statusCode, body) {
17
+ if (res.destroyed || res.writableEnded) return;
18
+ res.writeHead(statusCode, {
19
+ "Content-Type": "application/json",
20
+ "Cache-Control": "no-store",
21
+ });
22
+ res.end(body === undefined ? undefined : JSON.stringify(body));
23
+ }
24
+
25
+ function jsonRpcError(id, code, message) {
26
+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
27
+ }
28
+
29
+ async function readBody(req) {
30
+ const chunks = [];
31
+ let total = 0;
32
+ for await (const chunk of req) {
33
+ total += chunk.length;
34
+ if (total > MCP_MAX_BODY_BYTES) throw Object.assign(new Error("MCP request body is too large"), { statusCode: 413 });
35
+ chunks.push(chunk);
36
+ }
37
+ try { return JSON.parse(Buffer.concat(chunks, total).toString("utf8")); }
38
+ catch { throw Object.assign(new Error("Invalid JSON"), { parseError: true }); }
39
+ }
40
+
41
+ function imageTool() {
42
+ return {
43
+ name: IMAGE_TOOL_NAME,
44
+ title: "Generate image",
45
+ description: "Generate a new image from a detailed text prompt. Use this when the user asks to create, draw, render, or generate an image. This tool does not edit an existing image.",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ prompt: { type: "string", description: "A detailed, standalone description of the image to generate." },
50
+ size: {
51
+ type: "string",
52
+ enum: ["1024x1024", "1536x1024", "1024x1536"],
53
+ default: "1024x1024",
54
+ description: "Output dimensions: square, landscape, or portrait.",
55
+ },
56
+ },
57
+ required: ["prompt"],
58
+ additionalProperties: false,
59
+ },
60
+ annotations: {
61
+ readOnlyHint: false,
62
+ destructiveHint: false,
63
+ idempotentHint: false,
64
+ openWorldHint: true,
65
+ },
66
+ };
67
+ }
68
+
69
+ function safeToolError(error, apiKey) {
70
+ if (typeof error?.message === "string" && error.message) {
71
+ let message = error.message.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
72
+ if (apiKey) message = message.split(apiKey).join("[redacted]");
73
+ return message.slice(0, 1000);
74
+ }
75
+ return "Image generation failed";
76
+ }
77
+
78
+ export function createImageMcpHandler({
79
+ configLoader = () => readImageProviderConfig(),
80
+ generateImageFn = generateImage,
81
+ maxConcurrent = 2,
82
+ } = {}) {
83
+ let active = 0;
84
+ return async function imageMcpHandler(req, res) {
85
+ if (!isLoopbackAddress(req.socket?.remoteAddress) && !sameHostSocket(req.socket)) {
86
+ writeJson(res, 403, jsonRpcError(null, -32001, "Image MCP is available only on this device"));
87
+ return;
88
+ }
89
+ if (req.method !== "POST") {
90
+ res.setHeader?.("Allow", "POST");
91
+ writeJson(res, 405, jsonRpcError(null, -32600, "Only POST is supported"));
92
+ return;
93
+ }
94
+
95
+ let request;
96
+ try { request = await readBody(req); }
97
+ catch (error) {
98
+ writeJson(res, error.statusCode || 400, jsonRpcError(null, error.parseError ? -32700 : -32600, error.message));
99
+ return;
100
+ }
101
+ if (!request || typeof request !== "object" || Array.isArray(request) || request.jsonrpc !== "2.0" || typeof request.method !== "string") {
102
+ writeJson(res, 400, jsonRpcError(request?.id, -32600, "Invalid JSON-RPC request"));
103
+ return;
104
+ }
105
+ if (request.id === undefined) {
106
+ writeJson(res, 202);
107
+ return;
108
+ }
109
+ const reply = (result) => writeJson(res, 200, { jsonrpc: "2.0", id: request.id, result });
110
+ if (request.method === "initialize") {
111
+ const requestedVersion = String(request.params?.protocolVersion || "");
112
+ reply({
113
+ protocolVersion: MCP_PROTOCOL_VERSIONS.has(requestedVersion) ? requestedVersion : MCP_PROTOCOL_VERSION,
114
+ capabilities: { tools: { listChanged: false } },
115
+ serverInfo: { name: "ccdx-image", version: "1" },
116
+ instructions: "Use generate_image only when the user asks to create a new image.",
117
+ });
118
+ return;
119
+ }
120
+ if (request.method === "ping") {
121
+ reply({});
122
+ return;
123
+ }
124
+ if (request.method === "tools/list") {
125
+ const config = configLoader();
126
+ reply({ tools: config ? [imageTool()] : [] });
127
+ return;
128
+ }
129
+ if (request.method !== "tools/call") {
130
+ writeJson(res, 200, jsonRpcError(request.id, -32601, "Method not found"));
131
+ return;
132
+ }
133
+ if (request.params?.name !== IMAGE_TOOL_NAME) {
134
+ writeJson(res, 200, jsonRpcError(request.id, -32602, "Unknown image tool"));
135
+ return;
136
+ }
137
+ const config = configLoader();
138
+ if (!config) {
139
+ reply({ content: [{ type: "text", text: "Image generation is disabled. Run ccdx enable-image first." }], isError: true });
140
+ return;
141
+ }
142
+ if (active >= maxConcurrent) {
143
+ reply({ content: [{ type: "text", text: "Image generation is busy. Try again after the current request finishes." }], isError: true });
144
+ return;
145
+ }
146
+ active += 1;
147
+ const abort = new AbortController();
148
+ const cancel = () => abort.abort(new Error("Image request was cancelled"));
149
+ req.once?.("aborted", cancel);
150
+ res.once?.("close", cancel);
151
+ try {
152
+ const image = await generateImageFn(config, request.params?.arguments || {}, { signal: abort.signal });
153
+ reply({
154
+ content: [
155
+ { type: "image", data: image.data, mimeType: image.mimeType },
156
+ { type: "text", text: `Generated ${image.width || ""}${image.width && image.height ? "×" : ""}${image.height || image.size} image with ${image.model}.` },
157
+ ],
158
+ });
159
+ } catch (error) {
160
+ if (!res.destroyed && !res.writableEnded) {
161
+ reply({ content: [{ type: "text", text: safeToolError(error, config.api_key) }], isError: true });
162
+ }
163
+ } finally {
164
+ active -= 1;
165
+ req.off?.("aborted", cancel);
166
+ res.off?.("close", cancel);
167
+ }
168
+ };
169
+ }
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { atomicWriteFileSync } from "./atomic-file.mjs";
5
+
6
+ export const IMAGE_PROVIDER_PROTOCOLS = new Set(["openai-images", "qwen-messages"]);
7
+ export const IMAGE_PROVIDER_TIMEOUT_MS = 15_000;
8
+
9
+ export function imageProviderConfigPath({ env = process.env, home = os.homedir() } = {}) {
10
+ const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim();
11
+ const configRoot = xdgConfigHome || path.join(home, ".config");
12
+ return path.join(configRoot, "codex-copilot-dx", "image-provider.json");
13
+ }
14
+
15
+ function invalidConfig(filePath, reason) {
16
+ return new Error(`Invalid ccdx image provider at ${filePath}: ${reason}`);
17
+ }
18
+
19
+ export function normalizeImageEndpoint(value) {
20
+ let url;
21
+ try {
22
+ url = new URL(String(value || "").trim());
23
+ } catch {
24
+ throw new Error("Image API endpoint must be a valid HTTPS URL");
25
+ }
26
+ if (url.protocol !== "https:"
27
+ || !url.hostname
28
+ || url.username
29
+ || url.password
30
+ || url.search
31
+ || url.hash) {
32
+ throw new Error("Image API endpoint must be an HTTPS URL without credentials, query, or fragment");
33
+ }
34
+ const pathname = url.pathname.replace(/\/+$/, "");
35
+ if (!pathname.endsWith("/images/generations")) {
36
+ throw new Error("Image API endpoint path must end with /images/generations");
37
+ }
38
+ return `${url.origin}${pathname}`;
39
+ }
40
+
41
+ export function imageModelsEndpoint(endpoint) {
42
+ const normalized = normalizeImageEndpoint(endpoint);
43
+ return normalized.replace(/\/images\/generations$/, "/models");
44
+ }
45
+
46
+ function checkedApiKey(value) {
47
+ const key = String(value || "").trim();
48
+ if (!key) throw new Error("Image API key is required");
49
+ if (key.length > 8192 || /[\u0000-\u001f\u007f-\u009f]/.test(key)) throw new Error("Image API key is invalid");
50
+ return key;
51
+ }
52
+
53
+ export function validateImageProviderConfig(value, { filePath = "image-provider.json" } = {}) {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
55
+ throw invalidConfig(filePath, "expected a JSON object");
56
+ }
57
+ let endpoint;
58
+ let apiKey;
59
+ try {
60
+ endpoint = normalizeImageEndpoint(value.endpoint);
61
+ apiKey = checkedApiKey(value.api_key);
62
+ } catch (error) {
63
+ throw invalidConfig(filePath, error.message);
64
+ }
65
+ const model = String(value.model || "").trim();
66
+ if (!model || model.length > 256 || /[\u0000-\u001f\u007f-\u009f]/.test(model)) {
67
+ throw invalidConfig(filePath, "model must be a non-empty string");
68
+ }
69
+ const protocol = String(value.protocol || "").trim();
70
+ if (!IMAGE_PROVIDER_PROTOCOLS.has(protocol)) {
71
+ throw invalidConfig(filePath, `protocol must be one of: ${[...IMAGE_PROVIDER_PROTOCOLS].join(", ")}`);
72
+ }
73
+ return { enabled: true, endpoint, api_key: apiKey, model, protocol };
74
+ }
75
+
76
+ export function readImageProviderConfig({ env = process.env, home = os.homedir(), strict = false } = {}) {
77
+ const filePath = imageProviderConfigPath({ env, home });
78
+ try {
79
+ return validateImageProviderConfig(JSON.parse(fs.readFileSync(filePath, "utf8")), { filePath });
80
+ } catch (error) {
81
+ if (error?.code === "ENOENT") return null;
82
+ if (strict) {
83
+ if (String(error?.message || "").startsWith("Invalid ccdx image provider")) throw error;
84
+ throw invalidConfig(filePath, error?.message || String(error));
85
+ }
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function writeImageProviderConfig(config, { env = process.env, home = os.homedir() } = {}) {
91
+ const filePath = imageProviderConfigPath({ env, home });
92
+ const normalized = validateImageProviderConfig(config, { filePath });
93
+ atomicWriteFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, {
94
+ mode: 0o600,
95
+ preserveMode: false,
96
+ });
97
+ return { filePath, config: normalized };
98
+ }
99
+
100
+ async function limitedText(response, maxBytes = 1024 * 1024) {
101
+ const declared = Number(response.headers?.get?.("content-length"));
102
+ if (Number.isFinite(declared) && declared > maxBytes) throw new Error("Image API response was too large");
103
+ if (!response.body?.getReader) {
104
+ const text = await response.text();
105
+ if (Buffer.byteLength(text) > maxBytes) throw new Error("Image API response was too large");
106
+ return text;
107
+ }
108
+ const reader = response.body.getReader();
109
+ const chunks = [];
110
+ let total = 0;
111
+ try {
112
+ while (true) {
113
+ const { done, value } = await reader.read();
114
+ if (done) break;
115
+ total += value.byteLength;
116
+ if (total > maxBytes) {
117
+ await reader.cancel().catch(() => {});
118
+ throw new Error("Image API response was too large");
119
+ }
120
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
121
+ }
122
+ } finally {
123
+ reader.releaseLock();
124
+ }
125
+ return Buffer.concat(chunks, total).toString("utf8");
126
+ }
127
+
128
+ function errorMessage(value) {
129
+ if (!value || typeof value !== "object") return "";
130
+ return String(value.message || value.error?.message || value.error || value.reason || "");
131
+ }
132
+
133
+ function imageModelIds(body) {
134
+ if (!body || !Array.isArray(body.data)) return [];
135
+ const candidates = body.data
136
+ .map((model) => ({
137
+ id: String(model?.id || "").trim(),
138
+ imageCapable: /image|dall[-_.]?e/i.test(String(model?.id || ""))
139
+ || [model?.capabilities, model?.supported_generation_methods, model?.output_modalities]
140
+ .flatMap((value) => Array.isArray(value) ? value : [])
141
+ .some((value) => /image/i.test(String(value))),
142
+ }))
143
+ .filter(({ id }) => id
144
+ && id.length <= 256
145
+ && !/[\u0000-\u001f\u007f-\u009f]/.test(id));
146
+ const imageIds = candidates.filter(({ imageCapable }) => imageCapable).map(({ id }) => id);
147
+ return [...new Set(imageIds.length ? imageIds : candidates.length === 1 ? [candidates[0].id] : [])];
148
+ }
149
+
150
+ function detectProtocol(statusCode, body) {
151
+ if (statusCode < 400) return null;
152
+ const text = `${errorMessage(body)} ${JSON.stringify(body)}`.toLowerCase();
153
+ if (text.includes("input.messages")) return "qwen-messages";
154
+ if (/(?:field|required|missing|provide)[^\n]{0,80}\bprompt\b|\bprompt\b[^\n]{0,80}(?:required|missing)/i.test(text)) {
155
+ return "openai-images";
156
+ }
157
+ return null;
158
+ }
159
+
160
+ async function fetchJson(url, init, { fetchImpl, timeoutMs }) {
161
+ const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
162
+ const text = await limitedText(response);
163
+ let body;
164
+ try { body = JSON.parse(text); } catch { throw new Error("Image API returned invalid JSON"); }
165
+ return { response, body };
166
+ }
167
+
168
+ export async function inspectImageProvider({
169
+ endpoint,
170
+ apiKey,
171
+ fetchImpl = fetch,
172
+ timeoutMs = IMAGE_PROVIDER_TIMEOUT_MS,
173
+ } = {}) {
174
+ const normalizedEndpoint = normalizeImageEndpoint(endpoint);
175
+ const normalizedKey = checkedApiKey(apiKey);
176
+ const headers = { Authorization: `Bearer ${normalizedKey}`, Accept: "application/json" };
177
+ const models = await fetchJson(imageModelsEndpoint(normalizedEndpoint), { headers }, { fetchImpl, timeoutMs });
178
+ if (!models.response.ok) throw new Error(`Image API model lookup failed with HTTP ${models.response.status}`);
179
+ const modelIds = imageModelIds(models.body);
180
+ if (!modelIds.length) throw new Error("Image API model lookup returned no image models");
181
+
182
+ const probe = await fetchJson(normalizedEndpoint, {
183
+ method: "POST",
184
+ headers: { ...headers, "Content-Type": "application/json" },
185
+ body: JSON.stringify({ model: modelIds[0] }),
186
+ }, { fetchImpl, timeoutMs });
187
+ const protocol = detectProtocol(probe.response.status, probe.body);
188
+ if (!protocol) throw new Error("Image API protocol is unsupported or could not be detected safely");
189
+ return { endpoint: normalizedEndpoint, modelIds, protocol };
190
+ }