gpteam 0.1.18 → 0.1.19

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 CHANGED
@@ -19,12 +19,15 @@ The Image MCP config uses cc-switch-style per-client env blocks. Codex writes `[
19
19
  The Image MCP exposes both a synchronous compatibility tool and a local async job flow:
20
20
 
21
21
  - `create_image_job`: recommended for normal use. It starts a local background image job and returns `job_id` quickly, which avoids losing the whole generation when a VPN, proxy, or client has a 60-second idle timeout.
22
- - `get_image_job_status`: checks whether the local job is queued, running, succeeded, failed, or cancelled.
22
+ - `get_image_job_status`: checks whether the local job is queued, running, succeeded, failed, canceled, or expired.
23
23
  - `cancel_image_job`: cancels a queued/running local job.
24
- - `download_image_result`: returns the completed file metadata and image content.
24
+ - `download_image_result`: returns the completed file metadata and image content. Use `metadata_only`, `include_image`, and `include_revised_prompt` to control large result payloads.
25
+ - `get_capabilities`: returns supported sizes, formats, quality values, async support, cancellation semantics, queue limits, and image-to-image support.
25
26
  - `generate_image`: waits for completion and writes the image file immediately. This is kept for synchronous compatibility.
26
27
 
27
- Image MCP results are returned as stable JSON text and MCP `structuredContent`. Successful results include file path, model, size, format, quality, byte size, SHA-256, MIME type, image dimensions, duration, retry count, `job_id`, and `trace_id`. The image bytes are returned as MCP image content, not embedded in structured JSON. File writes create missing directories, avoid overwriting existing files by adding `-v2`, `-v3`, etc., and validate PNG/JPEG/WebP before returning success. `return_revised_prompt` controls whether the upstream revised prompt is included in the result.
28
+ Image MCP results are returned as stable JSON text and MCP `structuredContent`. Successful results include final file path, model, action, size, format, quality, byte size, SHA-256, MIME type, image dimensions, duration, retry count, `job_id`, `trace_id`, and optional `idempotency_key`. Error results use stable `error.code`, `error.message`, `error.retryable`, `error.stage`, `error.upstream_status`, and `error.trace_id` fields while keeping compatibility fields such as `category` and `http_status`.
29
+
30
+ The MCP supports normal text-to-image generation and image-to-image/edit inputs. Pass `images` as data URLs, HTTPS URLs, or local file paths. Pass `mask` the same way for masked edits, and use `input_fidelity` when needed. File writes create missing directories, avoid overwriting existing files by adding `-v2`, `-v3`, etc., and validate PNG/JPEG/WebP before returning success. `overwrite: true` is available for explicit replacement. `return_revised_prompt` controls whether the upstream revised prompt is included in the result.
28
31
 
29
32
  Claude Code is written to `~/.claude/settings.json` under the `env` section, using the GPTeam `/anthropic` base URL. OpenClaw writes `models.providers.gpteam` and also selects `gpteam/<model>` under `agents.defaults.model`, so the chosen model is active without an extra manual step.
30
33
 
package/lib/cli.js CHANGED
@@ -72,7 +72,7 @@ export async function runCli(argv = []) {
72
72
  console.log(`入口:${formatNodeLabel(selectedNode)}`);
73
73
  console.log(`地址:${selectedNode.baseUrl}`);
74
74
  if (['codex', 'opencode', 'claude-code'].includes(client.id)) {
75
- printHint(theme, '已写入 GPTeam Image MCP。客户端对话里需要生图时优先调用 create_image_job,再用 get_image_job_status 和 download_image_result 取结果,MCP 使用专用环境变量读取 API key。');
75
+ printHint(theme, '已写入 GPTeam Image MCP。客户端对话里需要生图或图生图时优先调用 create_image_job,再用 get_image_job_status 和 download_image_result 取结果,get_capabilities 可查看支持能力,MCP 使用专用环境变量读取 API key。');
76
76
  }
77
77
  } finally {
78
78
  rl.close();
package/lib/config.js CHANGED
@@ -13,6 +13,7 @@ const IMAGE_MCP_ENABLED_TOOLS = [
13
13
  'get_image_job_status',
14
14
  'download_image_result',
15
15
  'cancel_image_job',
16
+ 'get_capabilities',
16
17
  'generate_image'
17
18
  ];
18
19
 
package/lib/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PACKAGE_NAME = 'gpteam';
2
- export const PACKAGE_VERSION = '0.1.18';
2
+ export const PACKAGE_VERSION = '0.1.19';
3
3
 
4
4
  export function getHelpText() {
5
5
  return [
@@ -22,6 +22,6 @@ export function getHelpText() {
22
22
  ' --help 显示帮助',
23
23
  ' --version 显示版本',
24
24
  '',
25
- '说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图工具从 MCP env 读取 key,长时间生图优先用 create_image_job 异步任务。'
25
+ '说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图和图生图工具从 MCP env 读取 key,长时间任务优先用 create_image_job 异步任务,get_capabilities 可查看支持能力。'
26
26
  ].join('\n');
27
27
  }
@@ -6,8 +6,10 @@ export class ImageMCPError extends Error {
6
6
  this.name = 'ImageMCPError';
7
7
  this.code = String(options.code || 'image_mcp_error');
8
8
  this.category = String(options.category || 'unknown');
9
+ this.stage = String(options.stage || stageFromCategory(this.category));
9
10
  this.retryable = Boolean(options.retryable);
10
11
  this.http_status = Number.isFinite(options.http_status) ? options.http_status : undefined;
12
+ this.upstream_status = Number.isFinite(options.upstream_status) ? options.upstream_status : this.http_status;
11
13
  this.details = options.details || undefined;
12
14
  }
13
15
  }
@@ -23,6 +25,7 @@ export async function imageErrorFromHTTPResponse(response, apiKey) {
23
25
  return new ImageMCPError(message, {
24
26
  code: upstreamCode || 'rate_limit_exceeded',
25
27
  category: 'upstream_rate_limit',
28
+ stage: 'upstream',
26
29
  http_status: status,
27
30
  retryable: true
28
31
  });
@@ -31,6 +34,7 @@ export async function imageErrorFromHTTPResponse(response, apiKey) {
31
34
  return new ImageMCPError(message, {
32
35
  code: upstreamCode || 'upstream_server_error',
33
36
  category: status === 408 ? 'timeout' : 'upstream_server',
37
+ stage: 'upstream',
34
38
  http_status: status,
35
39
  retryable: true
36
40
  });
@@ -39,6 +43,7 @@ export async function imageErrorFromHTTPResponse(response, apiKey) {
39
43
  return new ImageMCPError(message, {
40
44
  code: upstreamCode || 'content_safety_rejected',
41
45
  category: 'content_safety',
46
+ stage: 'upstream',
42
47
  http_status: status,
43
48
  retryable: false
44
49
  });
@@ -46,6 +51,7 @@ export async function imageErrorFromHTTPResponse(response, apiKey) {
46
51
  return new ImageMCPError(message, {
47
52
  code: upstreamCode || (status === 401 || status === 403 ? 'authentication_failed' : 'invalid_request_error'),
48
53
  category: status === 401 || status === 403 ? 'authentication' : 'parameter',
54
+ stage: status === 401 || status === 403 ? 'upstream' : 'validate',
49
55
  http_status: status,
50
56
  retryable: false
51
57
  });
@@ -55,7 +61,8 @@ export function imageErrorFromFetch(error, options = {}) {
55
61
  if (options.cancelled) {
56
62
  return new ImageMCPError('图片生成任务已取消。', {
57
63
  code: 'job_cancelled',
58
- category: 'cancelled',
64
+ category: 'canceled',
65
+ stage: 'cancel',
59
66
  retryable: false
60
67
  });
61
68
  }
@@ -64,25 +71,33 @@ export function imageErrorFromFetch(error, options = {}) {
64
71
  return new ImageMCPError(message, {
65
72
  code: timeoutLike ? 'network_timeout' : 'network_error',
66
73
  category: timeoutLike ? 'timeout' : 'network',
74
+ stage: 'network',
67
75
  retryable: true
68
76
  });
69
77
  }
70
78
 
71
- export function serializeImageError(error) {
79
+ export function serializeImageError(error, meta = {}) {
72
80
  if (error instanceof ImageMCPError) {
73
81
  return {
74
82
  code: error.code,
75
- category: error.category,
83
+ message: error.message,
76
84
  retryable: error.retryable,
85
+ stage: error.stage,
86
+ upstream_status: error.upstream_status,
87
+ trace_id: meta.trace_id || '',
88
+ category: error.category,
77
89
  http_status: error.http_status,
78
- message: error.message
90
+ details: error.details
79
91
  };
80
92
  }
81
93
  return {
82
94
  code: 'image_mcp_error',
83
- category: 'unknown',
95
+ message: error && error.message ? String(error.message) : String(error || 'unknown error'),
84
96
  retryable: false,
85
- message: error && error.message ? String(error.message) : String(error || 'unknown error')
97
+ stage: 'unknown',
98
+ upstream_status: undefined,
99
+ trace_id: meta.trace_id || '',
100
+ category: 'unknown'
86
101
  };
87
102
  }
88
103
 
@@ -114,3 +129,33 @@ function isContentSafetyError(code, message) {
114
129
  const text = `${code || ''} ${message || ''}`.toLowerCase();
115
130
  return /content|safety|policy|moderation/.test(text);
116
131
  }
132
+
133
+ function stageFromCategory(category) {
134
+ switch (String(category || '').toLowerCase()) {
135
+ case 'parameter':
136
+ return 'validate';
137
+ case 'configuration':
138
+ case 'environment':
139
+ return 'configuration';
140
+ case 'file_system':
141
+ case 'response_invalid':
142
+ return 'local';
143
+ case 'authentication':
144
+ case 'content_safety':
145
+ case 'upstream_rate_limit':
146
+ case 'upstream_server':
147
+ return 'upstream';
148
+ case 'timeout':
149
+ case 'network':
150
+ return 'network';
151
+ case 'canceled':
152
+ case 'cancelled':
153
+ return 'cancel';
154
+ case 'not_found':
155
+ return 'lookup';
156
+ case 'queue':
157
+ return 'queue';
158
+ default:
159
+ return 'unknown';
160
+ }
161
+ }
@@ -8,11 +8,15 @@ export const DEFAULT_IMAGE_FORMAT = 'png';
8
8
 
9
9
  export function writeImageOutput(input = {}, options = {}) {
10
10
  const bytes = decodeBase64Image(input.b64);
11
- const image = inspectImage(bytes);
11
+ const image = inspectImageBytes(bytes);
12
12
  const format = image.format || normalizeImageFormat(input.format || input.output_format || DEFAULT_IMAGE_FORMAT);
13
- const outputPath = writeImageFileNonOverwriting(input.output_path, format, bytes, options);
13
+ const outputPath = writeImageFile(input.output_path, format, bytes, {
14
+ ...options,
15
+ overwrite: Boolean(input.overwrite)
16
+ });
14
17
  return {
15
18
  file: outputPath,
19
+ final_file: outputPath,
16
20
  path: outputPath,
17
21
  bytes: bytes.length,
18
22
  sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
@@ -25,6 +29,23 @@ export function writeImageOutput(input = {}, options = {}) {
25
29
  };
26
30
  }
27
31
 
32
+ export function localImageToDataURL(rawPath, options = {}) {
33
+ const filePath = path.resolve(expandHome(String(rawPath || ''), options.home || os.homedir()));
34
+ const bytes = fs.readFileSync(filePath);
35
+ const image = inspectImageBytes(bytes);
36
+ return `data:${image.mime_type};base64,${bytes.toString('base64')}`;
37
+ }
38
+
39
+ function writeImageFile(rawOutputPath, format, bytes, options) {
40
+ if (options.overwrite) {
41
+ const outputPath = resolveOutputPath(rawOutputPath, format, options);
42
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
43
+ atomicReplaceFile(outputPath, bytes);
44
+ return outputPath;
45
+ }
46
+ return writeImageFileNonOverwriting(rawOutputPath, format, bytes, options);
47
+ }
48
+
28
49
  function writeImageFileNonOverwriting(rawOutputPath, format, bytes, options) {
29
50
  let lastConflict;
30
51
  for (let attempt = 0; attempt < 10000; attempt += 1) {
@@ -41,6 +62,7 @@ function writeImageFileNonOverwriting(rawOutputPath, format, bytes, options) {
41
62
  throw lastConflict || new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
42
63
  code: 'output_path_conflict',
43
64
  category: 'file_system',
65
+ stage: 'local',
44
66
  retryable: false
45
67
  });
46
68
  }
@@ -58,6 +80,7 @@ function decodeBase64Image(value) {
58
80
  throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
59
81
  code: 'image_data_missing',
60
82
  category: 'response_invalid',
83
+ stage: 'local',
61
84
  retryable: false
62
85
  });
63
86
  }
@@ -66,13 +89,14 @@ function decodeBase64Image(value) {
66
89
  throw new ImageMCPError('GPTeam 图片接口返回的图片数据为空。', {
67
90
  code: 'image_data_empty',
68
91
  category: 'response_invalid',
92
+ stage: 'local',
69
93
  retryable: false
70
94
  });
71
95
  }
72
96
  return bytes;
73
97
  }
74
98
 
75
- function inspectImage(bytes) {
99
+ export function inspectImageBytes(bytes) {
76
100
  const png = inspectPNG(bytes);
77
101
  if (png) return png;
78
102
  const jpeg = inspectJPEG(bytes);
@@ -82,6 +106,7 @@ function inspectImage(bytes) {
82
106
  throw new ImageMCPError('写入前图片校验失败:无法识别 PNG/JPEG/WebP。', {
83
107
  code: 'image_mime_invalid',
84
108
  category: 'response_invalid',
109
+ stage: 'local',
85
110
  retryable: false
86
111
  });
87
112
  }
@@ -152,6 +177,7 @@ function resolveAvailableOutputPath(rawOutputPath, format, options) {
152
177
  throw new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
153
178
  code: 'output_path_conflict',
154
179
  category: 'file_system',
180
+ stage: 'local',
155
181
  retryable: false
156
182
  });
157
183
  }
@@ -162,11 +188,11 @@ function resolveOutputPath(rawOutputPath, format, options) {
162
188
  const name = `gpteam-image-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-')}.${format}`;
163
189
  if (rawOutputPath) {
164
190
  const expanded = expandHome(String(rawOutputPath), home);
165
- if (isDirectoryLike(expanded)) return path.join(expanded, name);
166
- return withImageExtension(expanded, format);
191
+ if (isDirectoryLike(expanded)) return path.resolve(expanded, name);
192
+ return path.resolve(withImageExtension(expanded, format));
167
193
  }
168
194
  const outputDir = expandHome(firstNonEmpty(env.GPTEAM_IMAGE_OUTPUT_DIR, defaultImageOutputDir(home)), home);
169
- return path.join(outputDir, name);
195
+ return path.resolve(outputDir, name);
170
196
  }
171
197
 
172
198
  function atomicWriteFile(outputPath, bytes) {
@@ -201,6 +227,39 @@ function atomicWriteFile(outputPath, bytes) {
201
227
  throw new ImageMCPError(conflict ? '输出文件已存在,正在重新选择不覆盖路径。' : `图片文件写入失败:${error.message}`, {
202
228
  code: conflict ? 'output_path_conflict' : 'file_write_failed',
203
229
  category: 'file_system',
230
+ stage: 'local',
231
+ retryable: false
232
+ });
233
+ }
234
+ }
235
+
236
+ function atomicReplaceFile(outputPath, bytes) {
237
+ const tempPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`);
238
+ let fd;
239
+ try {
240
+ fd = fs.openSync(tempPath, 'wx', 0o600);
241
+ fs.writeFileSync(fd, bytes);
242
+ fs.fsyncSync(fd);
243
+ fs.closeSync(fd);
244
+ fd = undefined;
245
+ fs.renameSync(tempPath, outputPath);
246
+ } catch (error) {
247
+ if (fd !== undefined) {
248
+ try {
249
+ fs.closeSync(fd);
250
+ } catch {
251
+ // 忽略关闭临时文件失败,下面会清理临时路径。
252
+ }
253
+ }
254
+ try {
255
+ fs.rmSync(tempPath, { force: true });
256
+ } catch {
257
+ // 忽略清理失败,返回主要写入错误。
258
+ }
259
+ throw new ImageMCPError(`图片文件写入失败:${error.message}`, {
260
+ code: 'file_write_failed',
261
+ category: 'file_system',
262
+ stage: 'local',
204
263
  retryable: false
205
264
  });
206
265
  }
@@ -8,7 +8,7 @@ import {
8
8
  redactSecret,
9
9
  serializeImageError
10
10
  } from './errors.js';
11
- import { DEFAULT_IMAGE_FORMAT, normalizeImageFormat, writeImageOutput } from './files.js';
11
+ import { DEFAULT_IMAGE_FORMAT, localImageToDataURL, normalizeImageFormat, writeImageOutput } from './files.js';
12
12
 
13
13
  export const DEFAULT_BASE_URL = 'https://api.gpteamservices.com/v1';
14
14
  export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
@@ -17,11 +17,15 @@ export { DEFAULT_IMAGE_FORMAT };
17
17
  const defaultMaxAttempts = 3;
18
18
  const defaultRetryDelayMs = 800;
19
19
  const defaultRequestTimeoutMs = 5 * 60 * 1000;
20
+ const defaultMaxConcurrentJobs = 2;
21
+ const defaultMaxQueuedJobs = 20;
22
+ const defaultJobTTLMS = 30 * 60 * 1000;
23
+ const terminalStatuses = new Set(['succeeded', 'failed', 'canceled', 'expired']);
20
24
 
21
25
  const defaultJobStore = createImageJobStore();
22
26
 
23
- export function buildImageGenerationPayload(input = {}) {
24
- return {
27
+ export function buildImageGenerationPayload(input = {}, options = {}) {
28
+ const payload = {
25
29
  model: String(input.model || DEFAULT_IMAGE_MODEL),
26
30
  prompt: String(input.prompt || '').trim(),
27
31
  response_format: 'b64_json',
@@ -29,6 +33,15 @@ export function buildImageGenerationPayload(input = {}) {
29
33
  quality: String(input.quality || 'high'),
30
34
  output_format: normalizeImageFormat(input.format || input.output_format || DEFAULT_IMAGE_FORMAT)
31
35
  };
36
+ const imageOptions = { ...options, home: options.home };
37
+ const images = normalizeInputImages(input.images || input.image, imageOptions);
38
+ if (images.length > 0) payload.images = images.map((imageURL) => ({ image_url: imageURL }));
39
+ const mask = normalizeImageReference(input.mask, imageOptions);
40
+ if (mask) payload.mask = { image_url: mask };
41
+ copyOptionalImageToolOption(payload, input, 'background');
42
+ copyOptionalImageToolOption(payload, input, 'moderation');
43
+ copyOptionalImageToolOption(payload, input, 'input_fidelity');
44
+ return payload;
32
45
  }
33
46
 
34
47
  export function loadGPTeamCredentials(options = {}) {
@@ -42,6 +55,7 @@ export function loadGPTeamCredentials(options = {}) {
42
55
  throw new ImageMCPError('没有找到 GPTeam API key。请在 MCP 配置 env 中设置 GPTEAM_API_KEY,或先运行 npx gpteam 完成本地配置。', {
43
56
  code: 'api_key_missing',
44
57
  category: 'configuration',
58
+ stage: 'configuration',
45
59
  retryable: false
46
60
  });
47
61
  }
@@ -79,16 +93,18 @@ export async function generateImage(input = {}, options = {}) {
79
93
  throw new ImageMCPError('prompt 不能为空', {
80
94
  code: 'prompt_required',
81
95
  category: 'parameter',
96
+ stage: 'validate',
82
97
  retryable: false
83
98
  });
84
99
  }
85
100
  const credentials = loadGPTeamCredentials(options);
86
- const payload = buildImageGenerationPayload(input);
101
+ const payload = buildImageGenerationPayload(input, options);
87
102
  const fetchImpl = options.fetch || globalThis.fetch;
88
103
  if (typeof fetchImpl !== 'function') {
89
104
  throw new ImageMCPError('当前 Node.js 运行时不支持 fetch,请升级到 Node.js 18.18 或更高版本。', {
90
105
  code: 'fetch_unavailable',
91
106
  category: 'environment',
107
+ stage: 'configuration',
92
108
  retryable: false
93
109
  });
94
110
  }
@@ -99,6 +115,7 @@ export async function generateImage(input = {}, options = {}) {
99
115
  const file = writeImageOutput({
100
116
  b64: result.b64,
101
117
  output_path: input.output_path,
118
+ overwrite: input.overwrite,
102
119
  format
103
120
  }, options);
104
121
  const includeRevisedPrompt = resolveRevisedPromptFlag(input);
@@ -111,37 +128,65 @@ export async function generateImage(input = {}, options = {}) {
111
128
  b64: result.b64,
112
129
  revisedPrompt: includeRevisedPrompt ? result.revisedPrompt : '',
113
130
  retryCount: result.retryCount,
114
- durationMs
131
+ durationMs,
132
+ idempotencyKey: normalizeIdempotencyKey(input.idempotency_key)
115
133
  });
116
134
  }
117
135
 
118
136
  export function createImageJobStore(options = {}) {
119
137
  return {
120
138
  jobs: new Map(),
139
+ queue: [],
140
+ runningCount: 0,
141
+ idempotency: new Map(),
121
142
  now: typeof options.now === 'function' ? options.now : Date.now,
122
- ttlMs: Number(options.ttlMs || 30 * 60 * 1000)
143
+ ttlMs: Number(options.ttlMs || defaultJobTTLMS),
144
+ maxConcurrent: Number.isFinite(options.maxConcurrent) ? options.maxConcurrent : defaultMaxConcurrentJobs,
145
+ maxQueue: Number.isFinite(options.maxQueue) ? options.maxQueue : defaultMaxQueuedJobs
123
146
  };
124
147
  }
125
148
 
126
149
  export function createImageJob(input = {}, options = {}) {
127
150
  const store = options.store || defaultJobStore;
151
+ configureJobStore(store, options);
128
152
  cleanupImageJobs(store);
153
+ const idempotencyKey = normalizeIdempotencyKey(input.idempotency_key);
154
+ if (idempotencyKey) {
155
+ const existingJobID = store.idempotency.get(idempotencyKey);
156
+ const existingJob = existingJobID ? store.jobs.get(existingJobID) : null;
157
+ if (existingJob) return publicJobStatus(existingJob);
158
+ store.idempotency.delete(idempotencyKey);
159
+ }
160
+ if ((queuedJobCount(store) + store.runningCount) >= (store.maxConcurrent + store.maxQueue)) {
161
+ return resultFromError(new ImageMCPError('图片任务队列已满,请稍后重试。', {
162
+ code: 'queue_full',
163
+ category: 'queue',
164
+ stage: 'queue',
165
+ retryable: true
166
+ }));
167
+ }
129
168
  const jobID = makeID('img');
130
169
  const traceID = makeID('tr');
131
170
  const controller = new AbortController();
132
171
  const job = {
133
172
  job_id: jobID,
134
173
  trace_id: traceID,
174
+ idempotency_key: idempotencyKey,
135
175
  status: 'queued',
136
176
  ok: true,
137
177
  created_at: new Date(store.now()).toISOString(),
138
178
  updated_at: new Date(store.now()).toISOString(),
179
+ expires_at: new Date(store.now() + store.ttlMs).toISOString(),
139
180
  controller,
181
+ input: { ...input, idempotency_key: idempotencyKey },
182
+ options,
140
183
  result: null,
141
184
  error: null
142
185
  };
143
186
  store.jobs.set(jobID, job);
144
- queueMicrotask(() => runImageJob(store, job, input, options));
187
+ if (idempotencyKey) store.idempotency.set(idempotencyKey, jobID);
188
+ store.queue.push(jobID);
189
+ queueMicrotask(() => scheduleImageJobs(store));
145
190
  return publicJobStatus(job);
146
191
  }
147
192
 
@@ -157,18 +202,26 @@ export function cancelImageJob(input = {}, options = {}) {
157
202
  const store = options.store || defaultJobStore;
158
203
  const job = store.jobs.get(String(input.job_id || ''));
159
204
  if (!job) return missingJobResult(input.job_id);
160
- if (job.status === 'succeeded' || job.status === 'failed') return publicJobStatus(job);
205
+ if (isTerminalStatus(job.status)) return publicJobStatus(job);
161
206
  job.controller.abort();
162
- job.status = 'cancelled';
207
+ job.status = 'canceled';
163
208
  job.ok = false;
164
209
  job.updated_at = new Date(store.now()).toISOString();
165
210
  job.error = {
166
211
  code: 'job_cancelled',
167
- category: 'cancelled',
212
+ message: '图片生成任务已取消。',
168
213
  retryable: false,
169
- message: '图片生成任务已取消。'
214
+ stage: 'cancel',
215
+ upstream_status: undefined,
216
+ trace_id: job.trace_id,
217
+ category: 'canceled'
218
+ };
219
+ return {
220
+ ...publicJobStatus(job),
221
+ ok: true,
222
+ cancellation_mode: 'best_effort',
223
+ cancellation_note: '已中止本地请求。若上游已经开始生成,无法保证上游也同步取消。'
170
224
  };
171
- return { ...publicJobStatus(job), ok: true };
172
225
  }
173
226
 
174
227
  export function downloadImageResult(input = {}, options = {}) {
@@ -176,12 +229,23 @@ export function downloadImageResult(input = {}, options = {}) {
176
229
  const job = store.jobs.get(String(input.job_id || ''));
177
230
  if (!job) return missingJobResult(input.job_id);
178
231
  if (job.status !== 'succeeded') return publicJobStatus(job);
179
- return { ...job.result, status: 'succeeded' };
232
+ return shapeDownloadResult(job.result, input);
180
233
  }
181
234
 
182
235
  export function structuredToolResult(result) {
183
236
  if (!result || typeof result !== 'object') {
184
- return { ok: false, error: { code: 'empty_result', category: 'unknown', retryable: false, message: 'empty result' } };
237
+ return {
238
+ ok: false,
239
+ error: {
240
+ code: 'empty_result',
241
+ message: 'empty result',
242
+ retryable: false,
243
+ stage: 'unknown',
244
+ upstream_status: undefined,
245
+ trace_id: '',
246
+ category: 'unknown'
247
+ }
248
+ };
185
249
  }
186
250
  const clone = { ...result };
187
251
  delete clone.b64;
@@ -206,7 +270,32 @@ export function resultFromError(error, meta = {}) {
206
270
  job_id: meta.job_id || '',
207
271
  trace_id: meta.trace_id || '',
208
272
  status: 'failed',
209
- error: serializeImageError(error)
273
+ error: serializeImageError(error, meta)
274
+ };
275
+ }
276
+
277
+ export function getCapabilities(options = {}) {
278
+ const env = options.env || process.env;
279
+ return {
280
+ ok: true,
281
+ model: DEFAULT_IMAGE_MODEL,
282
+ default_model: DEFAULT_IMAGE_MODEL,
283
+ supports_async: true,
284
+ supports_cancel: true,
285
+ cancel_semantics: 'best_effort',
286
+ supports_idempotency_key: true,
287
+ supports_image_to_image: true,
288
+ supports_mask: true,
289
+ sizes: ['1024x1024', '1536x1024', '1024x1536', 'auto'],
290
+ formats: ['png', 'jpeg', 'webp'],
291
+ quality: ['low', 'medium', 'high', 'auto'],
292
+ max_prompt_length: 32000,
293
+ statuses: ['queued', 'running', 'succeeded', 'failed', 'canceled', 'expired'],
294
+ default_output_format: DEFAULT_IMAGE_FORMAT,
295
+ default_request_timeout_ms: resolveBoundedInt(1, env.GPTEAM_IMAGE_REQUEST_TIMEOUT_MS, defaultRequestTimeoutMs),
296
+ default_max_attempts: resolveBoundedInt(1, env.GPTEAM_IMAGE_MAX_ATTEMPTS, defaultMaxAttempts),
297
+ max_concurrent_jobs: resolveBoundedInt(1, env.GPTEAM_IMAGE_MAX_CONCURRENT, defaultMaxConcurrentJobs),
298
+ max_queued_jobs: resolveBoundedInt(0, env.GPTEAM_IMAGE_MAX_QUEUE, defaultMaxQueuedJobs)
210
299
  };
211
300
  }
212
301
 
@@ -230,8 +319,9 @@ async function fetchImageWithRetry(fetchImpl, credentials, payload, options) {
230
319
 
231
320
  async function fetchImageOnce(fetchImpl, credentials, payload, options) {
232
321
  const requestSignal = createRequestSignal(options);
322
+ const endpoint = imageEndpointFromPayload(payload);
233
323
  try {
234
- const response = await fetchImpl(`${credentials.baseUrl}/images/generations`, {
324
+ const response = await fetchImpl(`${credentials.baseUrl}${endpoint}`, {
235
325
  method: 'POST',
236
326
  headers: {
237
327
  Authorization: `Bearer ${credentials.apiKey}`,
@@ -246,9 +336,10 @@ async function fetchImageOnce(fetchImpl, credentials, payload, options) {
246
336
  const b64 = first && typeof first.b64_json === 'string' ? first.b64_json : '';
247
337
  if (!b64) {
248
338
  throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
249
- code: 'image_data_missing',
250
- category: 'response_invalid',
251
- retryable: false
339
+ code: 'image_data_missing',
340
+ category: 'response_invalid',
341
+ stage: 'local',
342
+ retryable: false
252
343
  });
253
344
  }
254
345
  return {
@@ -272,8 +363,10 @@ function buildSuccessResult(input) {
272
363
  ok: true,
273
364
  status: 'succeeded',
274
365
  file: input.file.file,
366
+ final_file: input.file.final_file,
275
367
  path: input.file.file,
276
368
  model: input.payload.model,
369
+ action: imageActionFromPayload(input.payload),
277
370
  size: input.payload.size,
278
371
  format: input.file.format,
279
372
  output_format: input.file.format,
@@ -288,6 +381,7 @@ function buildSuccessResult(input) {
288
381
  retry_count: input.retryCount,
289
382
  job_id: input.jobID,
290
383
  trace_id: input.traceID,
384
+ idempotency_key: input.idempotencyKey || undefined,
291
385
  b64: input.b64
292
386
  };
293
387
  if (input.revisedPrompt) {
@@ -297,38 +391,58 @@ function buildSuccessResult(input) {
297
391
  return result;
298
392
  }
299
393
 
300
- async function runImageJob(store, job, input, options) {
301
- if (job.status === 'cancelled') return;
394
+ function scheduleImageJobs(store) {
395
+ cleanupImageJobs(store);
396
+ while (store.runningCount < store.maxConcurrent && store.queue.length > 0) {
397
+ const jobID = store.queue.shift();
398
+ const job = store.jobs.get(jobID);
399
+ if (!job || job.status !== 'queued') continue;
400
+ store.runningCount += 1;
401
+ queueMicrotask(() => runImageJob(store, job));
402
+ }
403
+ }
404
+
405
+ async function runImageJob(store, job) {
406
+ if (job.status === 'canceled') {
407
+ store.runningCount = Math.max(0, store.runningCount - 1);
408
+ scheduleImageJobs(store);
409
+ return;
410
+ }
302
411
  job.status = 'running';
303
412
  job.updated_at = new Date(store.now()).toISOString();
304
413
  try {
305
- const result = await generateImage({ ...input, job_id: job.job_id, trace_id: job.trace_id }, {
306
- ...options,
414
+ const result = await generateImage({ ...job.input, job_id: job.job_id, trace_id: job.trace_id }, {
415
+ ...job.options,
307
416
  signal: job.controller.signal
308
417
  });
309
- if (job.status === 'cancelled') return;
418
+ if (job.status === 'canceled') return;
310
419
  job.status = 'succeeded';
311
420
  job.ok = true;
312
421
  job.result = result;
313
422
  } catch (error) {
314
- if (job.status !== 'cancelled') {
423
+ if (job.status !== 'canceled') {
315
424
  job.status = 'failed';
316
425
  job.ok = false;
317
- job.error = serializeImageError(error);
426
+ job.error = serializeImageError(error, { trace_id: job.trace_id });
318
427
  }
319
428
  } finally {
320
429
  job.updated_at = new Date(store.now()).toISOString();
430
+ store.runningCount = Math.max(0, store.runningCount - 1);
431
+ scheduleImageJobs(store);
321
432
  }
322
433
  }
323
434
 
324
435
  function publicJobStatus(job) {
325
436
  const base = {
326
- ok: job.status !== 'failed' && job.status !== 'cancelled',
437
+ ok: job.status !== 'failed' && job.status !== 'canceled' && job.status !== 'expired',
327
438
  job_id: job.job_id,
328
439
  trace_id: job.trace_id,
440
+ idempotency_key: job.idempotency_key || undefined,
329
441
  status: job.status,
442
+ legacy_status: job.status === 'canceled' ? 'cancelled' : undefined,
330
443
  created_at: job.created_at,
331
- updated_at: job.updated_at
444
+ updated_at: job.updated_at,
445
+ expires_at: job.expires_at
332
446
  };
333
447
  if (job.result) return { ...base, ...structuredToolResult(job.result) };
334
448
  if (job.error) return { ...base, error: job.error };
@@ -342,9 +456,12 @@ function missingJobResult(jobID) {
342
456
  status: 'not_found',
343
457
  error: {
344
458
  code: 'job_not_found',
345
- category: 'not_found',
459
+ message: '没有找到这个图片任务。',
346
460
  retryable: false,
347
- message: '没有找到这个图片任务。'
461
+ stage: 'lookup',
462
+ upstream_status: undefined,
463
+ trace_id: '',
464
+ category: 'not_found'
348
465
  }
349
466
  };
350
467
  }
@@ -352,10 +469,116 @@ function missingJobResult(jobID) {
352
469
  function cleanupImageJobs(store) {
353
470
  const threshold = store.now() - store.ttlMs;
354
471
  for (const [jobID, job] of store.jobs.entries()) {
472
+ const created = Date.parse(job.created_at || '');
473
+ if (job.status === 'queued' && Number.isFinite(created) && created < threshold) {
474
+ job.status = 'expired';
475
+ job.ok = false;
476
+ job.updated_at = new Date(store.now()).toISOString();
477
+ job.error = {
478
+ code: 'job_expired',
479
+ message: '图片任务已过期,请重新创建任务。',
480
+ retryable: false,
481
+ stage: 'queue',
482
+ upstream_status: undefined,
483
+ trace_id: job.trace_id,
484
+ category: 'queue'
485
+ };
486
+ }
355
487
  if (job.status === 'queued' || job.status === 'running') continue;
356
488
  const updated = Date.parse(job.updated_at || job.created_at || '');
357
- if (Number.isFinite(updated) && updated < threshold) store.jobs.delete(jobID);
489
+ if (Number.isFinite(updated) && updated < threshold) removeImageJob(store, jobID, job);
358
490
  }
491
+ store.queue = store.queue.filter((jobID) => {
492
+ const job = store.jobs.get(jobID);
493
+ return job && job.status === 'queued';
494
+ });
495
+ }
496
+
497
+ function configureJobStore(store, options = {}) {
498
+ const env = options.env || process.env;
499
+ store.maxConcurrent = resolveBoundedInt(1, options.maxConcurrent, env.GPTEAM_IMAGE_MAX_CONCURRENT, store.maxConcurrent || defaultMaxConcurrentJobs);
500
+ store.maxQueue = resolveBoundedInt(0, options.maxQueue, env.GPTEAM_IMAGE_MAX_QUEUE, store.maxQueue || defaultMaxQueuedJobs);
501
+ }
502
+
503
+ function queuedJobCount(store) {
504
+ return store.queue.filter((jobID) => {
505
+ const job = store.jobs.get(jobID);
506
+ return job && job.status === 'queued';
507
+ }).length;
508
+ }
509
+
510
+ function removeImageJob(store, jobID, job) {
511
+ store.jobs.delete(jobID);
512
+ if (job && job.idempotency_key && store.idempotency.get(job.idempotency_key) === jobID) {
513
+ store.idempotency.delete(job.idempotency_key);
514
+ }
515
+ }
516
+
517
+ function isTerminalStatus(status) {
518
+ return terminalStatuses.has(String(status || ''));
519
+ }
520
+
521
+ function normalizeIdempotencyKey(value) {
522
+ return String(value || '').trim().slice(0, 200);
523
+ }
524
+
525
+ function shapeDownloadResult(result, input = {}) {
526
+ const includeImage = !input.metadata_only && input.include_image !== false;
527
+ const includeRevisedPrompt = input.include_revised_prompt !== false;
528
+ const output = {
529
+ ...result,
530
+ status: 'succeeded'
531
+ };
532
+ if (!includeImage) {
533
+ delete output.b64;
534
+ delete output.mimeType;
535
+ }
536
+ if (!includeRevisedPrompt) {
537
+ delete output.revised_prompt;
538
+ delete output.revisedPrompt;
539
+ }
540
+ return output;
541
+ }
542
+
543
+ function imageEndpointFromPayload(payload) {
544
+ return imageActionFromPayload(payload) === 'edit' ? '/images/edits' : '/images/generations';
545
+ }
546
+
547
+ function imageActionFromPayload(payload) {
548
+ return Array.isArray(payload.images) && payload.images.length > 0 ? 'edit' : 'generate';
549
+ }
550
+
551
+ function normalizeInputImages(value, options = {}) {
552
+ const rawImages = Array.isArray(value) ? value : (value ? [value] : []);
553
+ return rawImages.map((item) => normalizeImageReference(item, options)).filter(Boolean);
554
+ }
555
+
556
+ function normalizeImageReference(value, options = {}) {
557
+ if (!value) return '';
558
+ if (typeof value === 'object') {
559
+ return normalizeImageReference(value.image_url || value.url || value.path || value.file, options);
560
+ }
561
+ const text = String(value || '').trim();
562
+ if (!text) return '';
563
+ if (/^data:image\/[a-z0-9.+-]+;base64,/i.test(text)) return text;
564
+ if (/^https?:\/\//i.test(text)) return text;
565
+ try {
566
+ return localImageToDataURL(text, options);
567
+ } catch (error) {
568
+ throw new ImageMCPError(`读取输入图片失败:${error.message}`, {
569
+ code: 'input_image_read_failed',
570
+ category: 'file_system',
571
+ stage: 'local',
572
+ retryable: false
573
+ });
574
+ }
575
+ }
576
+
577
+ function copyOptionalImageToolOption(payload, input, key) {
578
+ if (!Object.prototype.hasOwnProperty.call(input, key)) return;
579
+ const value = input[key];
580
+ if (value === undefined || value === null || value === '') return;
581
+ payload[key] = value;
359
582
  }
360
583
 
361
584
  function createRequestSignal(options = {}) {
@@ -7,6 +7,7 @@ import {
7
7
  createImageJob,
8
8
  downloadImageResult,
9
9
  generateImage,
10
+ getCapabilities,
10
11
  getImageJobStatus,
11
12
  resultFromError,
12
13
  structuredToolResult,
@@ -38,6 +39,29 @@ const imageInputProperties = {
38
39
  type: 'string',
39
40
  description: 'Optional output file path or directory.'
40
41
  },
42
+ overwrite: {
43
+ type: 'boolean',
44
+ description: 'Overwrite output_path when it already exists. Defaults to false.',
45
+ default: false
46
+ },
47
+ idempotency_key: {
48
+ type: 'string',
49
+ description: 'Optional idempotency key. Reusing it returns the same local job in this MCP process.'
50
+ },
51
+ images: {
52
+ type: 'array',
53
+ items: { type: 'string' },
54
+ description: 'Optional input images for image-to-image or edit. Values may be data URLs, HTTPS URLs, or local file paths.'
55
+ },
56
+ mask: {
57
+ type: 'string',
58
+ description: 'Optional mask image for image edit. Value may be a data URL, HTTPS URL, or local file path.'
59
+ },
60
+ input_fidelity: {
61
+ type: 'string',
62
+ description: 'Optional image edit fidelity.',
63
+ enum: ['low', 'high']
64
+ },
41
65
  return_revised_prompt: {
42
66
  type: 'boolean',
43
67
  description: 'Return upstream revised prompt when available.',
@@ -69,7 +93,16 @@ const tools = [
69
93
  {
70
94
  name: 'download_image_result',
71
95
  description: 'Return the saved local file metadata and image content for a completed image job.',
72
- inputSchema: jobIDSchema()
96
+ inputSchema: downloadSchema()
97
+ },
98
+ {
99
+ name: 'get_capabilities',
100
+ description: 'Return GPTeam Image MCP capabilities, supported sizes, formats, quality levels, async support, and queue limits.',
101
+ inputSchema: {
102
+ type: 'object',
103
+ properties: {},
104
+ additionalProperties: false
105
+ }
73
106
  },
74
107
  {
75
108
  name: 'generate_image',
@@ -134,6 +167,8 @@ export async function callImageTool(toolName, args = {}, deps = {}) {
134
167
  return cancelImageJob(args || {}, deps);
135
168
  case 'download_image_result':
136
169
  return downloadImageResult(args || {}, deps);
170
+ case 'get_capabilities':
171
+ return getCapabilities(deps);
137
172
  case 'generate_image':
138
173
  return await generateImage(args || {}, deps);
139
174
  default:
@@ -145,6 +180,26 @@ export async function callImageTool(toolName, args = {}, deps = {}) {
145
180
  }
146
181
  }
147
182
 
183
+ function downloadSchema() {
184
+ const schema = jobIDSchema();
185
+ schema.properties.metadata_only = {
186
+ type: 'boolean',
187
+ description: 'Return only metadata without MCP image content.',
188
+ default: false
189
+ };
190
+ schema.properties.include_image = {
191
+ type: 'boolean',
192
+ description: 'Include image content when the job succeeded.',
193
+ default: true
194
+ };
195
+ schema.properties.include_revised_prompt = {
196
+ type: 'boolean',
197
+ description: 'Include revised prompt when available.',
198
+ default: true
199
+ };
200
+ return schema;
201
+ }
202
+
148
203
  function jobIDSchema() {
149
204
  return {
150
205
  type: 'object',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpteam",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {