gpteam 0.1.16 → 0.1.18

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
@@ -16,6 +16,16 @@ Client config writing follows the same safety pattern as cc-switch: keep Codex t
16
16
 
17
17
  The Image MCP config uses cc-switch-style per-client env blocks. Codex writes `[mcp_servers.gpteam_image.env]`, OpenCode writes `mcp.gpteam_image.environment`, and Claude Code writes `mcpServers.gpteam_image.env` in `~/.claude.json`. The MCP receives `GPTEAM_API_KEY` and `GPTEAM_BASE_URL` from that MCP config, so it does not depend on Codex `auth.json` or inherited `OPENAI_API_KEY`.
18
18
 
19
+ The Image MCP exposes both a synchronous compatibility tool and a local async job flow:
20
+
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.
23
+ - `cancel_image_job`: cancels a queued/running local job.
24
+ - `download_image_result`: returns the completed file metadata and image content.
25
+ - `generate_image`: waits for completion and writes the image file immediately. This is kept for synchronous compatibility.
26
+
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
+
19
29
  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.
20
30
 
21
31
  Supported clients:
package/lib/cli.js CHANGED
@@ -71,8 +71,8 @@ export async function runCli(argv = []) {
71
71
  for (const filePath of written) console.log(`- ${filePath}`);
72
72
  console.log(`入口:${formatNodeLabel(selectedNode)}`);
73
73
  console.log(`地址:${selectedNode.baseUrl}`);
74
- if (client.id === 'codex') {
75
- printHint(theme, '已写入 GPTeam Image MCP。Codex 对话里需要生图时可调用 generate_image,MCP 使用专用环境变量读取 API key。');
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。');
76
76
  }
77
77
  } finally {
78
78
  rl.close();
@@ -89,7 +89,7 @@ export function resolveSpawnInvocation(command, args, platform = process.platfor
89
89
  if (platform === 'win32' && command === 'npm') {
90
90
  return {
91
91
  command: 'cmd.exe',
92
- args: ['/d', '/s', '/c', windowsCommandLine('npm.cmd', args)]
92
+ args: ['/d', '/c', windowsCommandLine('npm.cmd', args)]
93
93
  };
94
94
  }
95
95
  return { command, args };
@@ -123,7 +123,9 @@ function windowsCommandLine(command, args) {
123
123
  }
124
124
 
125
125
  function windowsQuoteArg(value) {
126
- return `"${String(value).replace(/"/g, '\\"')}"`;
126
+ const text = String(value);
127
+ if (!/[\s&()^|<>"]/.test(text)) return text;
128
+ return `"${text.replace(/"/g, '\\"')}"`;
127
129
  }
128
130
 
129
131
  function shellQuote(value) {
package/lib/config.js CHANGED
@@ -8,6 +8,13 @@ const PROVIDER_ID = 'gpteam';
8
8
  const IMAGE_MCP_ID = 'gpteam_image';
9
9
  const IMAGE_MCP_PACKAGE = 'gpteam';
10
10
  const IMAGE_MCP_BIN = 'gpteam-image-mcp';
11
+ const IMAGE_MCP_ENABLED_TOOLS = [
12
+ 'create_image_job',
13
+ 'get_image_job_status',
14
+ 'download_image_result',
15
+ 'cancel_image_job',
16
+ 'generate_image'
17
+ ];
11
18
 
12
19
  export const CLIENTS = [
13
20
  { id: 'codex', label: 'Codex' },
@@ -56,7 +63,7 @@ export function writeCodexConfig(settings) {
56
63
  `args = [${mcpCommand.args.map((arg) => tomlString(arg)).join(', ')}]`,
57
64
  'startup_timeout_sec = 20',
58
65
  'tool_timeout_sec = 300',
59
- 'enabled_tools = ["generate_image"]',
66
+ `enabled_tools = [${IMAGE_MCP_ENABLED_TOOLS.map((name) => tomlString(name)).join(', ')}]`,
60
67
  'default_tools_approval_mode = "prompt"',
61
68
  '',
62
69
  `[mcp_servers.${IMAGE_MCP_ID}.env]`,
package/lib/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PACKAGE_NAME = 'gpteam';
2
- export const PACKAGE_VERSION = '0.1.16';
2
+ export const PACKAGE_VERSION = '0.1.18';
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'
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 异步任务。'
26
26
  ].join('\n');
27
27
  }
@@ -0,0 +1,116 @@
1
+ import { formatNetworkError } from '../errors.js';
2
+
3
+ export class ImageMCPError extends Error {
4
+ constructor(message, options = {}) {
5
+ super(String(message || 'GPTeam image MCP error'));
6
+ this.name = 'ImageMCPError';
7
+ this.code = String(options.code || 'image_mcp_error');
8
+ this.category = String(options.category || 'unknown');
9
+ this.retryable = Boolean(options.retryable);
10
+ this.http_status = Number.isFinite(options.http_status) ? options.http_status : undefined;
11
+ this.details = options.details || undefined;
12
+ }
13
+ }
14
+
15
+ export async function imageErrorFromHTTPResponse(response, apiKey) {
16
+ const status = Number(response && response.status) || 0;
17
+ const rawText = typeof response.text === 'function' ? await response.text() : '';
18
+ const detail = parseUpstreamError(rawText);
19
+ const upstreamCode = String(detail.code || detail.type || '').trim();
20
+ const bodyMessage = detail.message || rawText || '';
21
+ const message = redactSecret(bodyMessage ? `HTTP ${status}: ${bodyMessage}` : `HTTP ${status}`, apiKey);
22
+ if (status === 429) {
23
+ return new ImageMCPError(message, {
24
+ code: upstreamCode || 'rate_limit_exceeded',
25
+ category: 'upstream_rate_limit',
26
+ http_status: status,
27
+ retryable: true
28
+ });
29
+ }
30
+ if (status >= 500 || status === 408) {
31
+ return new ImageMCPError(message, {
32
+ code: upstreamCode || 'upstream_server_error',
33
+ category: status === 408 ? 'timeout' : 'upstream_server',
34
+ http_status: status,
35
+ retryable: true
36
+ });
37
+ }
38
+ if (isContentSafetyError(upstreamCode, message)) {
39
+ return new ImageMCPError(message, {
40
+ code: upstreamCode || 'content_safety_rejected',
41
+ category: 'content_safety',
42
+ http_status: status,
43
+ retryable: false
44
+ });
45
+ }
46
+ return new ImageMCPError(message, {
47
+ code: upstreamCode || (status === 401 || status === 403 ? 'authentication_failed' : 'invalid_request_error'),
48
+ category: status === 401 || status === 403 ? 'authentication' : 'parameter',
49
+ http_status: status,
50
+ retryable: false
51
+ });
52
+ }
53
+
54
+ export function imageErrorFromFetch(error, options = {}) {
55
+ if (options.cancelled) {
56
+ return new ImageMCPError('图片生成任务已取消。', {
57
+ code: 'job_cancelled',
58
+ category: 'cancelled',
59
+ retryable: false
60
+ });
61
+ }
62
+ const message = redactSecret(formatNetworkError(error), options.apiKey);
63
+ const timeoutLike = options.timedOut || /timeout|timedout|etimedout/i.test(message);
64
+ return new ImageMCPError(message, {
65
+ code: timeoutLike ? 'network_timeout' : 'network_error',
66
+ category: timeoutLike ? 'timeout' : 'network',
67
+ retryable: true
68
+ });
69
+ }
70
+
71
+ export function serializeImageError(error) {
72
+ if (error instanceof ImageMCPError) {
73
+ return {
74
+ code: error.code,
75
+ category: error.category,
76
+ retryable: error.retryable,
77
+ http_status: error.http_status,
78
+ message: error.message
79
+ };
80
+ }
81
+ return {
82
+ code: 'image_mcp_error',
83
+ category: 'unknown',
84
+ retryable: false,
85
+ message: error && error.message ? String(error.message) : String(error || 'unknown error')
86
+ };
87
+ }
88
+
89
+ export function redactSecret(text, secret) {
90
+ let result = String(text || '');
91
+ if (secret) result = result.split(secret).join('[redacted]');
92
+ result = result.replace(/sk-[A-Za-z0-9_-]{6,}/g, 'sk-[redacted]');
93
+ return result;
94
+ }
95
+
96
+ function parseUpstreamError(text) {
97
+ try {
98
+ const parsed = JSON.parse(String(text || ''));
99
+ const error = parsed && typeof parsed === 'object' ? parsed.error : null;
100
+ if (error && typeof error === 'object') {
101
+ return {
102
+ code: error.code,
103
+ type: error.type,
104
+ message: error.message
105
+ };
106
+ }
107
+ } catch {
108
+ return {};
109
+ }
110
+ return {};
111
+ }
112
+
113
+ function isContentSafetyError(code, message) {
114
+ const text = `${code || ''} ${message || ''}`.toLowerCase();
115
+ return /content|safety|policy|moderation/.test(text);
116
+ }
@@ -0,0 +1,244 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { ImageMCPError } from './errors.js';
6
+
7
+ export const DEFAULT_IMAGE_FORMAT = 'png';
8
+
9
+ export function writeImageOutput(input = {}, options = {}) {
10
+ const bytes = decodeBase64Image(input.b64);
11
+ const image = inspectImage(bytes);
12
+ const format = image.format || normalizeImageFormat(input.format || input.output_format || DEFAULT_IMAGE_FORMAT);
13
+ const outputPath = writeImageFileNonOverwriting(input.output_path, format, bytes, options);
14
+ return {
15
+ file: outputPath,
16
+ path: outputPath,
17
+ bytes: bytes.length,
18
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
19
+ mime_type: image.mime_type,
20
+ mimeType: image.mime_type,
21
+ width: image.width,
22
+ height: image.height,
23
+ format,
24
+ output_format: format
25
+ };
26
+ }
27
+
28
+ function writeImageFileNonOverwriting(rawOutputPath, format, bytes, options) {
29
+ let lastConflict;
30
+ for (let attempt = 0; attempt < 10000; attempt += 1) {
31
+ const outputPath = resolveAvailableOutputPath(rawOutputPath, format, options);
32
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
33
+ try {
34
+ atomicWriteFile(outputPath, bytes);
35
+ return outputPath;
36
+ } catch (error) {
37
+ if (!isOutputPathConflict(error)) throw error;
38
+ lastConflict = error;
39
+ }
40
+ }
41
+ throw lastConflict || new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
42
+ code: 'output_path_conflict',
43
+ category: 'file_system',
44
+ retryable: false
45
+ });
46
+ }
47
+
48
+ export function normalizeImageFormat(value) {
49
+ const normalized = String(value || DEFAULT_IMAGE_FORMAT).trim().toLowerCase();
50
+ if (normalized === 'jpg') return 'jpeg';
51
+ if (['png', 'jpeg', 'webp'].includes(normalized)) return normalized;
52
+ return DEFAULT_IMAGE_FORMAT;
53
+ }
54
+
55
+ function decodeBase64Image(value) {
56
+ const text = String(value || '').trim();
57
+ if (!text) {
58
+ throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
59
+ code: 'image_data_missing',
60
+ category: 'response_invalid',
61
+ retryable: false
62
+ });
63
+ }
64
+ const bytes = Buffer.from(text, 'base64');
65
+ if (bytes.length === 0) {
66
+ throw new ImageMCPError('GPTeam 图片接口返回的图片数据为空。', {
67
+ code: 'image_data_empty',
68
+ category: 'response_invalid',
69
+ retryable: false
70
+ });
71
+ }
72
+ return bytes;
73
+ }
74
+
75
+ function inspectImage(bytes) {
76
+ const png = inspectPNG(bytes);
77
+ if (png) return png;
78
+ const jpeg = inspectJPEG(bytes);
79
+ if (jpeg) return jpeg;
80
+ const webp = inspectWebP(bytes);
81
+ if (webp) return webp;
82
+ throw new ImageMCPError('写入前图片校验失败:无法识别 PNG/JPEG/WebP。', {
83
+ code: 'image_mime_invalid',
84
+ category: 'response_invalid',
85
+ retryable: false
86
+ });
87
+ }
88
+
89
+ function inspectPNG(bytes) {
90
+ if (bytes.length < 24) return null;
91
+ const signature = '89504e470d0a1a0a';
92
+ if (bytes.subarray(0, 8).toString('hex') !== signature) return null;
93
+ return {
94
+ format: 'png',
95
+ mime_type: 'image/png',
96
+ width: bytes.readUInt32BE(16),
97
+ height: bytes.readUInt32BE(20)
98
+ };
99
+ }
100
+
101
+ function inspectJPEG(bytes) {
102
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
103
+ let offset = 2;
104
+ while (offset + 9 < bytes.length) {
105
+ if (bytes[offset] !== 0xff) {
106
+ offset += 1;
107
+ continue;
108
+ }
109
+ const marker = bytes[offset + 1];
110
+ const length = bytes.readUInt16BE(offset + 2);
111
+ if (length < 2) break;
112
+ if ([0xc0, 0xc1, 0xc2, 0xc3].includes(marker)) {
113
+ return {
114
+ format: 'jpeg',
115
+ mime_type: 'image/jpeg',
116
+ height: bytes.readUInt16BE(offset + 5),
117
+ width: bytes.readUInt16BE(offset + 7)
118
+ };
119
+ }
120
+ offset += 2 + length;
121
+ }
122
+ return { format: 'jpeg', mime_type: 'image/jpeg', width: undefined, height: undefined };
123
+ }
124
+
125
+ function inspectWebP(bytes) {
126
+ if (bytes.length < 16) return null;
127
+ if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF') return null;
128
+ if (bytes.subarray(8, 12).toString('ascii') !== 'WEBP') return null;
129
+ if (bytes.subarray(12, 16).toString('ascii') === 'VP8X' && bytes.length >= 30) {
130
+ return {
131
+ format: 'webp',
132
+ mime_type: 'image/webp',
133
+ width: 1 + readUInt24LE(bytes, 24),
134
+ height: 1 + readUInt24LE(bytes, 27)
135
+ };
136
+ }
137
+ return { format: 'webp', mime_type: 'image/webp', width: undefined, height: undefined };
138
+ }
139
+
140
+ function readUInt24LE(bytes, offset) {
141
+ return bytes[offset] + (bytes[offset + 1] << 8) + (bytes[offset + 2] << 16);
142
+ }
143
+
144
+ function resolveAvailableOutputPath(rawOutputPath, format, options) {
145
+ const resolved = resolveOutputPath(rawOutputPath, format, options);
146
+ if (!fs.existsSync(resolved)) return resolved;
147
+ const parsed = path.parse(resolved);
148
+ for (let version = 2; version < 10000; version += 1) {
149
+ const candidate = path.join(parsed.dir, `${parsed.name}-v${version}${parsed.ext}`);
150
+ if (!fs.existsSync(candidate)) return candidate;
151
+ }
152
+ throw new ImageMCPError('无法生成不覆盖现有文件的输出路径。', {
153
+ code: 'output_path_conflict',
154
+ category: 'file_system',
155
+ retryable: false
156
+ });
157
+ }
158
+
159
+ function resolveOutputPath(rawOutputPath, format, options) {
160
+ const env = options.env || process.env;
161
+ const home = options.home || os.homedir();
162
+ const name = `gpteam-image-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-')}.${format}`;
163
+ if (rawOutputPath) {
164
+ const expanded = expandHome(String(rawOutputPath), home);
165
+ if (isDirectoryLike(expanded)) return path.join(expanded, name);
166
+ return withImageExtension(expanded, format);
167
+ }
168
+ const outputDir = expandHome(firstNonEmpty(env.GPTEAM_IMAGE_OUTPUT_DIR, defaultImageOutputDir(home)), home);
169
+ return path.join(outputDir, name);
170
+ }
171
+
172
+ function atomicWriteFile(outputPath, bytes) {
173
+ const tempPath = path.join(path.dirname(outputPath), `.${path.basename(outputPath)}.${process.pid}.${Date.now()}.${crypto.randomBytes(6).toString('hex')}.tmp`);
174
+ let fd;
175
+ try {
176
+ fd = fs.openSync(tempPath, 'wx', 0o600);
177
+ fs.writeFileSync(fd, bytes);
178
+ fs.fsyncSync(fd);
179
+ fs.closeSync(fd);
180
+ fd = undefined;
181
+ fs.linkSync(tempPath, outputPath);
182
+ try {
183
+ fs.rmSync(tempPath, { force: true });
184
+ } catch {
185
+ // 目标文件已通过独占链接落盘,临时文件清理失败不影响返回结果。
186
+ }
187
+ } catch (error) {
188
+ if (fd !== undefined) {
189
+ try {
190
+ fs.closeSync(fd);
191
+ } catch {
192
+ // 忽略关闭临时文件失败,下面会清理临时路径。
193
+ }
194
+ }
195
+ try {
196
+ fs.rmSync(tempPath, { force: true });
197
+ } catch {
198
+ // 忽略清理失败,返回主要写入错误。
199
+ }
200
+ const conflict = error && error.code === 'EEXIST';
201
+ throw new ImageMCPError(conflict ? '输出文件已存在,正在重新选择不覆盖路径。' : `图片文件写入失败:${error.message}`, {
202
+ code: conflict ? 'output_path_conflict' : 'file_write_failed',
203
+ category: 'file_system',
204
+ retryable: false
205
+ });
206
+ }
207
+ }
208
+
209
+ function isOutputPathConflict(error) {
210
+ return error instanceof ImageMCPError && error.code === 'output_path_conflict';
211
+ }
212
+
213
+ function defaultImageOutputDir(home) {
214
+ const desktop = path.join(home, 'Desktop');
215
+ return fs.existsSync(desktop) ? desktop : process.cwd();
216
+ }
217
+
218
+ function isDirectoryLike(value) {
219
+ return /[\\/]$/.test(value) || (fs.existsSync(value) && fs.statSync(value).isDirectory());
220
+ }
221
+
222
+ function firstNonEmpty(...values) {
223
+ for (const value of values) {
224
+ const text = String(value || '').trim();
225
+ if (text) return text;
226
+ }
227
+ return '';
228
+ }
229
+
230
+ function expandHome(value, home) {
231
+ const text = String(value || '');
232
+ if (text === '~') return home;
233
+ if (text.startsWith(`~${path.sep}`)) return path.join(home, text.slice(2));
234
+ if (text.startsWith('~/')) return path.join(home, text.slice(2));
235
+ return text;
236
+ }
237
+
238
+ function withImageExtension(filePath, format) {
239
+ const parsed = path.parse(filePath);
240
+ const ext = parsed.ext.toLowerCase();
241
+ const normalizedExt = ext === '.jpg' ? '.jpeg' : ext;
242
+ if (normalizedExt === `.${format}`) return filePath;
243
+ return path.join(parsed.dir, `${parsed.name}.${format}`);
244
+ }
@@ -1,10 +1,24 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import {
5
+ ImageMCPError,
6
+ imageErrorFromFetch,
7
+ imageErrorFromHTTPResponse,
8
+ redactSecret,
9
+ serializeImageError
10
+ } from './errors.js';
11
+ import { DEFAULT_IMAGE_FORMAT, normalizeImageFormat, writeImageOutput } from './files.js';
4
12
 
5
13
  export const DEFAULT_BASE_URL = 'https://api.gpteamservices.com/v1';
6
14
  export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
7
- export const DEFAULT_IMAGE_FORMAT = 'png';
15
+ export { DEFAULT_IMAGE_FORMAT };
16
+
17
+ const defaultMaxAttempts = 3;
18
+ const defaultRetryDelayMs = 800;
19
+ const defaultRequestTimeoutMs = 5 * 60 * 1000;
20
+
21
+ const defaultJobStore = createImageJobStore();
8
22
 
9
23
  export function buildImageGenerationPayload(input = {}) {
10
24
  return {
@@ -25,7 +39,11 @@ export function loadGPTeamCredentials(options = {}) {
25
39
  const configuredBaseUrl = parseGPTeamBaseUrl(configText);
26
40
  const apiKey = firstNonEmpty(env.GPTEAM_API_KEY);
27
41
  if (!apiKey) {
28
- throw new Error('没有找到 GPTeam API key。请在 MCP 配置 env 中设置 GPTEAM_API_KEY,或先运行 npx gpteam 完成本地配置。');
42
+ throw new ImageMCPError('没有找到 GPTeam API key。请在 MCP 配置 env 中设置 GPTEAM_API_KEY,或先运行 npx gpteam 完成本地配置。', {
43
+ code: 'api_key_missing',
44
+ category: 'configuration',
45
+ retryable: false
46
+ });
29
47
  }
30
48
  const baseUrl = normalizeBaseUrl(firstNonEmpty(env.GPTEAM_BASE_URL, configuredBaseUrl, DEFAULT_BASE_URL));
31
49
  return { apiKey, baseUrl, codexHome };
@@ -55,107 +73,331 @@ export function normalizeBaseUrl(value) {
55
73
  }
56
74
 
57
75
  export async function generateImage(input = {}, options = {}) {
76
+ const startedAt = now(options);
58
77
  const prompt = String(input.prompt || '').trim();
59
- if (!prompt) throw new Error('prompt 不能为空');
78
+ if (!prompt) {
79
+ throw new ImageMCPError('prompt 不能为空', {
80
+ code: 'prompt_required',
81
+ category: 'parameter',
82
+ retryable: false
83
+ });
84
+ }
60
85
  const credentials = loadGPTeamCredentials(options);
61
86
  const payload = buildImageGenerationPayload(input);
62
87
  const fetchImpl = options.fetch || globalThis.fetch;
63
88
  if (typeof fetchImpl !== 'function') {
64
- throw new Error('当前 Node.js 运行时不支持 fetch,请升级到 Node.js 18.18 或更高版本。');
89
+ throw new ImageMCPError('当前 Node.js 运行时不支持 fetch,请升级到 Node.js 18.18 或更高版本。', {
90
+ code: 'fetch_unavailable',
91
+ category: 'environment',
92
+ retryable: false
93
+ });
65
94
  }
66
- const response = await fetchImpl(`${credentials.baseUrl}/images/generations`, {
67
- method: 'POST',
68
- headers: {
69
- Authorization: `Bearer ${credentials.apiKey}`,
70
- 'Content-Type': 'application/json'
71
- },
72
- body: JSON.stringify(payload)
95
+ const jobID = String(input.job_id || options.jobID || makeID('img'));
96
+ const traceID = String(input.trace_id || options.traceID || makeID('tr'));
97
+ const result = await fetchImageWithRetry(fetchImpl, credentials, payload, options);
98
+ const format = normalizeImageFormat(payload.output_format);
99
+ const file = writeImageOutput({
100
+ b64: result.b64,
101
+ output_path: input.output_path,
102
+ format
103
+ }, options);
104
+ const includeRevisedPrompt = resolveRevisedPromptFlag(input);
105
+ const durationMs = now(options) - startedAt;
106
+ return buildSuccessResult({
107
+ jobID,
108
+ traceID,
109
+ payload,
110
+ file,
111
+ b64: result.b64,
112
+ revisedPrompt: includeRevisedPrompt ? result.revisedPrompt : '',
113
+ retryCount: result.retryCount,
114
+ durationMs
73
115
  });
74
- if (!response.ok) {
75
- const detail = typeof response.text === 'function' ? await response.text() : '';
76
- throw new Error(`GPTeam /v1/images/generations 返回 HTTP ${response.status}${detail ? `:${redactSecret(detail, credentials.apiKey)}` : ''}`);
116
+ }
117
+
118
+ export function createImageJobStore(options = {}) {
119
+ return {
120
+ jobs: new Map(),
121
+ now: typeof options.now === 'function' ? options.now : Date.now,
122
+ ttlMs: Number(options.ttlMs || 30 * 60 * 1000)
123
+ };
124
+ }
125
+
126
+ export function createImageJob(input = {}, options = {}) {
127
+ const store = options.store || defaultJobStore;
128
+ cleanupImageJobs(store);
129
+ const jobID = makeID('img');
130
+ const traceID = makeID('tr');
131
+ const controller = new AbortController();
132
+ const job = {
133
+ job_id: jobID,
134
+ trace_id: traceID,
135
+ status: 'queued',
136
+ ok: true,
137
+ created_at: new Date(store.now()).toISOString(),
138
+ updated_at: new Date(store.now()).toISOString(),
139
+ controller,
140
+ result: null,
141
+ error: null
142
+ };
143
+ store.jobs.set(jobID, job);
144
+ queueMicrotask(() => runImageJob(store, job, input, options));
145
+ return publicJobStatus(job);
146
+ }
147
+
148
+ export function getImageJobStatus(input = {}, options = {}) {
149
+ const store = options.store || defaultJobStore;
150
+ cleanupImageJobs(store);
151
+ const job = store.jobs.get(String(input.job_id || ''));
152
+ if (!job) return missingJobResult(input.job_id);
153
+ return publicJobStatus(job);
154
+ }
155
+
156
+ export function cancelImageJob(input = {}, options = {}) {
157
+ const store = options.store || defaultJobStore;
158
+ const job = store.jobs.get(String(input.job_id || ''));
159
+ if (!job) return missingJobResult(input.job_id);
160
+ if (job.status === 'succeeded' || job.status === 'failed') return publicJobStatus(job);
161
+ job.controller.abort();
162
+ job.status = 'cancelled';
163
+ job.ok = false;
164
+ job.updated_at = new Date(store.now()).toISOString();
165
+ job.error = {
166
+ code: 'job_cancelled',
167
+ category: 'cancelled',
168
+ retryable: false,
169
+ message: '图片生成任务已取消。'
170
+ };
171
+ return { ...publicJobStatus(job), ok: true };
172
+ }
173
+
174
+ export function downloadImageResult(input = {}, options = {}) {
175
+ const store = options.store || defaultJobStore;
176
+ const job = store.jobs.get(String(input.job_id || ''));
177
+ if (!job) return missingJobResult(input.job_id);
178
+ if (job.status !== 'succeeded') return publicJobStatus(job);
179
+ return { ...job.result, status: 'succeeded' };
180
+ }
181
+
182
+ export function structuredToolResult(result) {
183
+ if (!result || typeof result !== 'object') {
184
+ return { ok: false, error: { code: 'empty_result', category: 'unknown', retryable: false, message: 'empty result' } };
77
185
  }
78
- const data = await response.json();
79
- const first = Array.isArray(data && data.data) ? data.data[0] : null;
80
- const b64 = first && typeof first.b64_json === 'string' ? first.b64_json : '';
81
- if (!b64) {
82
- throw new Error('GPTeam 图片接口没有返回 b64_json 图片数据。');
186
+ const clone = { ...result };
187
+ delete clone.b64;
188
+ delete clone.mimeType;
189
+ delete clone.revisedPrompt;
190
+ delete clone.path;
191
+ return clone;
192
+ }
193
+
194
+ export function toolResultContent(result) {
195
+ const structured = structuredToolResult(result);
196
+ const content = [{ type: 'text', text: JSON.stringify(structured, null, 2) }];
197
+ if (result && result.ok && result.b64 && result.mimeType) {
198
+ content.push({ type: 'image', data: result.b64, mimeType: result.mimeType });
83
199
  }
84
- const format = normalizeImageFormat(payload.output_format);
85
- const outputPath = resolveOutputPath(input.output_path, format, options);
86
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
87
- fs.writeFileSync(outputPath, Buffer.from(b64, 'base64'));
200
+ return content;
201
+ }
202
+
203
+ export function resultFromError(error, meta = {}) {
88
204
  return {
89
- path: outputPath,
90
- mimeType: mimeTypeForFormat(format),
91
- b64,
92
- model: payload.model,
93
- size: payload.size,
94
- quality: payload.quality,
95
- format,
96
- revisedPrompt: first && typeof first.revised_prompt === 'string' ? first.revised_prompt : ''
205
+ ok: false,
206
+ job_id: meta.job_id || '',
207
+ trace_id: meta.trace_id || '',
208
+ status: 'failed',
209
+ error: serializeImageError(error)
97
210
  };
98
211
  }
99
212
 
100
- export function toolResultContent(result) {
101
- const text = [
102
- 'GPTeam Image 2 生成完成。',
103
- `文件:${result.path}`,
104
- `模型:${result.model}`,
105
- `规格:${result.size} / ${result.quality} / ${result.format}`
106
- ];
107
- if (result.revisedPrompt) text.push(`修订提示词:${result.revisedPrompt}`);
108
- return [
109
- { type: 'text', text: text.join('\n') },
110
- { type: 'image', data: result.b64, mimeType: result.mimeType }
111
- ];
213
+ async function fetchImageWithRetry(fetchImpl, credentials, payload, options) {
214
+ const maxAttempts = resolveBoundedInt(1, options.maxAttempts, options.env && options.env.GPTEAM_IMAGE_MAX_ATTEMPTS, defaultMaxAttempts);
215
+ const retryDelayMs = resolveBoundedInt(0, options.retryDelayMs, options.env && options.env.GPTEAM_IMAGE_RETRY_DELAY_MS, defaultRetryDelayMs);
216
+ let lastError;
217
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
218
+ try {
219
+ const response = await fetchImageOnce(fetchImpl, credentials, payload, options);
220
+ return { ...response, retryCount: attempt - 1 };
221
+ } catch (error) {
222
+ const classified = error instanceof ImageMCPError ? error : imageErrorFromFetch(error, { apiKey: credentials.apiKey });
223
+ lastError = classified;
224
+ if (!classified.retryable || attempt >= maxAttempts) throw classified;
225
+ await delay(retryDelayMs * Math.max(1, attempt), options);
226
+ }
227
+ }
228
+ throw lastError;
112
229
  }
113
230
 
114
- function resolveCodexHome(env, home) {
115
- return expandHome(firstNonEmpty(env.GPTEAM_CODEX_HOME, env.CODEX_HOME, path.join(home, '.codex')), home);
231
+ async function fetchImageOnce(fetchImpl, credentials, payload, options) {
232
+ const requestSignal = createRequestSignal(options);
233
+ try {
234
+ const response = await fetchImpl(`${credentials.baseUrl}/images/generations`, {
235
+ method: 'POST',
236
+ headers: {
237
+ Authorization: `Bearer ${credentials.apiKey}`,
238
+ 'Content-Type': 'application/json'
239
+ },
240
+ body: JSON.stringify(payload),
241
+ signal: requestSignal.signal
242
+ });
243
+ if (!response.ok) throw await imageErrorFromHTTPResponse(response, credentials.apiKey);
244
+ const data = await response.json();
245
+ const first = Array.isArray(data && data.data) ? data.data[0] : null;
246
+ const b64 = first && typeof first.b64_json === 'string' ? first.b64_json : '';
247
+ if (!b64) {
248
+ throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
249
+ code: 'image_data_missing',
250
+ category: 'response_invalid',
251
+ retryable: false
252
+ });
253
+ }
254
+ return {
255
+ b64,
256
+ revisedPrompt: first && typeof first.revised_prompt === 'string' ? first.revised_prompt : ''
257
+ };
258
+ } catch (error) {
259
+ if (error instanceof ImageMCPError) throw error;
260
+ throw imageErrorFromFetch(error, {
261
+ apiKey: credentials.apiKey,
262
+ timedOut: requestSignal.timedOut(),
263
+ cancelled: Boolean(options.signal && options.signal.aborted)
264
+ });
265
+ } finally {
266
+ requestSignal.clear();
267
+ }
116
268
  }
117
269
 
118
- function safeRead(filePath, readFile) {
270
+ function buildSuccessResult(input) {
271
+ const result = {
272
+ ok: true,
273
+ status: 'succeeded',
274
+ file: input.file.file,
275
+ path: input.file.file,
276
+ model: input.payload.model,
277
+ size: input.payload.size,
278
+ format: input.file.format,
279
+ output_format: input.file.format,
280
+ quality: input.payload.quality,
281
+ mime_type: input.file.mime_type,
282
+ mimeType: input.file.mime_type,
283
+ bytes: input.file.bytes,
284
+ sha256: input.file.sha256,
285
+ width: input.file.width,
286
+ height: input.file.height,
287
+ duration_ms: input.durationMs,
288
+ retry_count: input.retryCount,
289
+ job_id: input.jobID,
290
+ trace_id: input.traceID,
291
+ b64: input.b64
292
+ };
293
+ if (input.revisedPrompt) {
294
+ result.revised_prompt = input.revisedPrompt;
295
+ result.revisedPrompt = input.revisedPrompt;
296
+ }
297
+ return result;
298
+ }
299
+
300
+ async function runImageJob(store, job, input, options) {
301
+ if (job.status === 'cancelled') return;
302
+ job.status = 'running';
303
+ job.updated_at = new Date(store.now()).toISOString();
119
304
  try {
120
- return readFile(filePath);
121
- } catch {
122
- return '';
305
+ const result = await generateImage({ ...input, job_id: job.job_id, trace_id: job.trace_id }, {
306
+ ...options,
307
+ signal: job.controller.signal
308
+ });
309
+ if (job.status === 'cancelled') return;
310
+ job.status = 'succeeded';
311
+ job.ok = true;
312
+ job.result = result;
313
+ } catch (error) {
314
+ if (job.status !== 'cancelled') {
315
+ job.status = 'failed';
316
+ job.ok = false;
317
+ job.error = serializeImageError(error);
318
+ }
319
+ } finally {
320
+ job.updated_at = new Date(store.now()).toISOString();
123
321
  }
124
322
  }
125
323
 
126
- function resolveOutputPath(rawOutputPath, format, options) {
127
- const env = options.env || process.env;
128
- const home = options.home || os.homedir();
129
- const name = `gpteam-image-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-')}.${format}`;
130
- if (rawOutputPath) {
131
- const expanded = expandHome(String(rawOutputPath), home);
132
- if (isDirectoryLike(expanded)) return path.join(expanded, name);
133
- return expanded;
324
+ function publicJobStatus(job) {
325
+ const base = {
326
+ ok: job.status !== 'failed' && job.status !== 'cancelled',
327
+ job_id: job.job_id,
328
+ trace_id: job.trace_id,
329
+ status: job.status,
330
+ created_at: job.created_at,
331
+ updated_at: job.updated_at
332
+ };
333
+ if (job.result) return { ...base, ...structuredToolResult(job.result) };
334
+ if (job.error) return { ...base, error: job.error };
335
+ return base;
336
+ }
337
+
338
+ function missingJobResult(jobID) {
339
+ return {
340
+ ok: false,
341
+ job_id: String(jobID || ''),
342
+ status: 'not_found',
343
+ error: {
344
+ code: 'job_not_found',
345
+ category: 'not_found',
346
+ retryable: false,
347
+ message: '没有找到这个图片任务。'
348
+ }
349
+ };
350
+ }
351
+
352
+ function cleanupImageJobs(store) {
353
+ const threshold = store.now() - store.ttlMs;
354
+ for (const [jobID, job] of store.jobs.entries()) {
355
+ if (job.status === 'queued' || job.status === 'running') continue;
356
+ const updated = Date.parse(job.updated_at || job.created_at || '');
357
+ if (Number.isFinite(updated) && updated < threshold) store.jobs.delete(jobID);
134
358
  }
135
- const outputDir = expandHome(firstNonEmpty(env.GPTEAM_IMAGE_OUTPUT_DIR, defaultImageOutputDir(home)), home);
136
- return path.join(outputDir, name);
137
359
  }
138
360
 
139
- function defaultImageOutputDir(home) {
140
- const desktop = path.join(home, 'Desktop');
141
- return fs.existsSync(desktop) ? desktop : process.cwd();
361
+ function createRequestSignal(options = {}) {
362
+ const timeoutMs = resolveBoundedInt(1, options.requestTimeoutMs, options.env && options.env.GPTEAM_IMAGE_REQUEST_TIMEOUT_MS, defaultRequestTimeoutMs);
363
+ const controller = new AbortController();
364
+ let timedOut = false;
365
+ const timer = setTimeout(() => {
366
+ timedOut = true;
367
+ controller.abort();
368
+ }, timeoutMs);
369
+ if (typeof timer.unref === 'function') timer.unref();
370
+ const parent = options.signal;
371
+ if (parent) {
372
+ if (parent.aborted) controller.abort();
373
+ else parent.addEventListener('abort', () => {
374
+ clearTimeout(timer);
375
+ controller.abort();
376
+ }, { once: true });
377
+ }
378
+ return {
379
+ signal: controller.signal,
380
+ timedOut: () => timedOut,
381
+ clear: () => clearTimeout(timer)
382
+ };
142
383
  }
143
384
 
144
- function isDirectoryLike(value) {
145
- return /[\\/]$/.test(value) || (fs.existsSync(value) && fs.statSync(value).isDirectory());
385
+ function resolveRevisedPromptFlag(input) {
386
+ if (Object.prototype.hasOwnProperty.call(input, 'include_revised_prompt')) return Boolean(input.include_revised_prompt);
387
+ if (Object.prototype.hasOwnProperty.call(input, 'return_revised_prompt')) return Boolean(input.return_revised_prompt);
388
+ return true;
146
389
  }
147
390
 
148
- function normalizeImageFormat(value) {
149
- const normalized = String(value || DEFAULT_IMAGE_FORMAT).trim().toLowerCase();
150
- if (normalized === 'jpg') return 'jpeg';
151
- if (['png', 'jpeg', 'webp'].includes(normalized)) return normalized;
152
- return DEFAULT_IMAGE_FORMAT;
391
+ function resolveCodexHome(env, home) {
392
+ return expandHome(firstNonEmpty(env.GPTEAM_CODEX_HOME, env.CODEX_HOME, path.join(home, '.codex')), home);
153
393
  }
154
394
 
155
- function mimeTypeForFormat(format) {
156
- if (format === 'jpeg') return 'image/jpeg';
157
- if (format === 'webp') return 'image/webp';
158
- return 'image/png';
395
+ function safeRead(filePath, readFile) {
396
+ try {
397
+ return readFile(filePath);
398
+ } catch {
399
+ return '';
400
+ }
159
401
  }
160
402
 
161
403
  function firstNonEmpty(...values) {
@@ -178,9 +420,29 @@ function unescapeTomlString(value) {
178
420
  return String(value || '').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
179
421
  }
180
422
 
181
- function redactSecret(text, secret) {
182
- let result = String(text || '');
183
- if (secret) result = result.split(secret).join('[redacted]');
184
- result = result.replace(/sk-[A-Za-z0-9_-]{6,}/g, 'sk-[redacted]');
185
- return result;
423
+ function makeID(prefix) {
424
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
425
+ }
426
+
427
+ function now(options = {}) {
428
+ return typeof options.now === 'function' ? options.now() : Date.now();
429
+ }
430
+
431
+ function resolveBoundedInt(min, ...values) {
432
+ for (const value of values) {
433
+ if (value === undefined || value === null || value === '') continue;
434
+ const parsed = Number.parseInt(String(value), 10);
435
+ if (Number.isFinite(parsed) && parsed >= min) return parsed;
436
+ }
437
+ return min;
438
+ }
439
+
440
+ async function delay(ms, options = {}) {
441
+ if (ms <= 0) return;
442
+ if (typeof options.sleep === 'function') return options.sleep(ms);
443
+ await new Promise((resolve) => setTimeout(resolve, ms));
444
+ }
445
+
446
+ export function redactImageSecret(text, secret) {
447
+ return redactSecret(text, secret);
186
448
  }
@@ -1,45 +1,88 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
3
+ import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
4
4
  import fs from 'node:fs';
5
- import { generateImage, toolResultContent } from './image.js';
5
+ import {
6
+ cancelImageJob,
7
+ createImageJob,
8
+ downloadImageResult,
9
+ generateImage,
10
+ getImageJobStatus,
11
+ resultFromError,
12
+ structuredToolResult,
13
+ toolResultContent
14
+ } from './image.js';
6
15
 
7
- const GENERATE_IMAGE_TOOL = {
8
- name: 'generate_image',
9
- description: 'Generate an image through GPTeam Image 2 and save it to a local file.',
10
- inputSchema: {
11
- type: 'object',
12
- properties: {
13
- prompt: {
14
- type: 'string',
15
- description: 'Image prompt.'
16
- },
17
- size: {
18
- type: 'string',
19
- description: 'Image size, for example 1024x1024.',
20
- default: '1024x1024'
21
- },
22
- quality: {
23
- type: 'string',
24
- description: 'Image quality.',
25
- default: 'high'
26
- },
27
- format: {
28
- type: 'string',
29
- description: 'Output image format.',
30
- enum: ['png', 'jpeg', 'webp'],
31
- default: 'png'
32
- },
33
- output_path: {
34
- type: 'string',
35
- description: 'Optional output file path or directory.'
36
- }
37
- },
38
- required: ['prompt'],
39
- additionalProperties: false
16
+ const imageInputProperties = {
17
+ prompt: {
18
+ type: 'string',
19
+ description: 'Image prompt.'
20
+ },
21
+ size: {
22
+ type: 'string',
23
+ description: 'Image size, for example 1024x1024.',
24
+ default: '1024x1024'
25
+ },
26
+ quality: {
27
+ type: 'string',
28
+ description: 'Image quality.',
29
+ default: 'high'
30
+ },
31
+ format: {
32
+ type: 'string',
33
+ description: 'Output image format.',
34
+ enum: ['png', 'jpeg', 'webp'],
35
+ default: 'png'
36
+ },
37
+ output_path: {
38
+ type: 'string',
39
+ description: 'Optional output file path or directory.'
40
+ },
41
+ return_revised_prompt: {
42
+ type: 'boolean',
43
+ description: 'Return upstream revised prompt when available.',
44
+ default: true
40
45
  }
41
46
  };
42
47
 
48
+ const tools = [
49
+ {
50
+ name: 'create_image_job',
51
+ description: 'Recommended for normal use. Create a local background GPTeam Image 2 job and return immediately with a job_id.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: imageInputProperties,
55
+ required: ['prompt'],
56
+ additionalProperties: false
57
+ }
58
+ },
59
+ {
60
+ name: 'get_image_job_status',
61
+ description: 'Get the status of a local GPTeam Image 2 job.',
62
+ inputSchema: jobIDSchema()
63
+ },
64
+ {
65
+ name: 'cancel_image_job',
66
+ description: 'Cancel a local GPTeam Image 2 job when it is still queued or running.',
67
+ inputSchema: jobIDSchema()
68
+ },
69
+ {
70
+ name: 'download_image_result',
71
+ description: 'Return the saved local file metadata and image content for a completed image job.',
72
+ inputSchema: jobIDSchema()
73
+ },
74
+ {
75
+ name: 'generate_image',
76
+ description: 'Compatibility tool. Generate an image through GPTeam Image 2, wait for completion, and save it locally.',
77
+ inputSchema: {
78
+ type: 'object',
79
+ properties: imageInputProperties,
80
+ required: ['prompt'],
81
+ additionalProperties: false
82
+ }
83
+ }
84
+ ];
85
+
43
86
  export function createServer(deps = {}) {
44
87
  const server = new Server({
45
88
  name: 'gpteam-image-mcp',
@@ -50,18 +93,14 @@ export function createServer(deps = {}) {
50
93
  }
51
94
  });
52
95
 
53
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
54
- tools: [GENERATE_IMAGE_TOOL]
55
- }));
96
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
56
97
 
57
98
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
58
- const toolName = request.params && request.params.name;
59
- if (toolName !== GENERATE_IMAGE_TOOL.name) {
60
- throw new Error(`未知工具:${toolName}`);
61
- }
62
- const result = await generateImage(request.params.arguments || {}, deps);
99
+ const result = await callImageTool(request.params && request.params.name, request.params && request.params.arguments, deps);
63
100
  return {
64
- content: toolResultContent(result)
101
+ content: toolResultContent(result),
102
+ structuredContent: structuredToolResult(result),
103
+ isError: result && result.ok === false
65
104
  };
66
105
  });
67
106
 
@@ -83,3 +122,39 @@ export function resolvePackageVersion(deps = {}) {
83
122
  return '0.0.0';
84
123
  }
85
124
  }
125
+
126
+ export async function callImageTool(toolName, args = {}, deps = {}) {
127
+ try {
128
+ switch (toolName) {
129
+ case 'create_image_job':
130
+ return createImageJob(args || {}, deps);
131
+ case 'get_image_job_status':
132
+ return getImageJobStatus(args || {}, deps);
133
+ case 'cancel_image_job':
134
+ return cancelImageJob(args || {}, deps);
135
+ case 'download_image_result':
136
+ return downloadImageResult(args || {}, deps);
137
+ case 'generate_image':
138
+ return await generateImage(args || {}, deps);
139
+ default:
140
+ throw new McpError(ErrorCode.InvalidParams, `未知工具:${toolName}`);
141
+ }
142
+ } catch (error) {
143
+ if (error instanceof McpError) throw error;
144
+ return resultFromError(error);
145
+ }
146
+ }
147
+
148
+ function jobIDSchema() {
149
+ return {
150
+ type: 'object',
151
+ properties: {
152
+ job_id: {
153
+ type: 'string',
154
+ description: 'Image job id returned by create_image_job.'
155
+ }
156
+ },
157
+ required: ['job_id'],
158
+ additionalProperties: false
159
+ };
160
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpteam",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {