gpteam 0.1.28 → 0.1.30

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
@@ -1,20 +1,20 @@
1
- # GPTeam CLI
1
+ # GPTEAM CLI
2
2
 
3
- Interactive GPTeam API client configurator.
3
+ Interactive GPTEAM API client configurator.
4
4
 
5
5
  ```bash
6
6
  npx gpteam
7
7
  ```
8
8
 
9
- The CLI asks for an API key, validates it with `/v1/models`, checks whether the selected client command is installed, detects available models, benchmarks all production ingress endpoints with real API requests, then backs up old files and writes the selected client configuration. The next steps are blocked until the key validation succeeds. Client detection runs before model selection and benchmarking, so a missing local client does not waste API probes.
9
+ The CLI asks for an API key, reads the key-scoped GPTEAM capability summary, shows only the available clients and models for that key's group, benchmarks production ingress endpoints with real API requests, then backs up old files and writes the selected client configuration. Legacy servers fall back to `/v1/models` for validation only.
10
10
 
11
- When a selected client is missing, the interactive CLI shows the exact install command and asks before running it. `--install-client` skips that confirmation for scripted setup. Codex, Claude Code, OpenCode, and OpenClaw use npm-based install commands; OpenCode uses the current `opencode-ai` package. Windows is blocked for OpenClaw because GPTeam's OpenClaw config writer currently supports macOS and Linux only.
11
+ When a selected client is missing, the interactive CLI shows the exact install command and asks before running it. `--install-client` skips that confirmation for scripted setup. Codex, Claude Code, OpenCode, and OpenClaw use npm-based install commands; OpenCode uses the current `opencode-ai` package. Windows is blocked for OpenClaw because GPTEAM's OpenClaw config writer currently supports macOS and Linux only.
12
12
 
13
13
  Recommendation order is deterministic: success rate first, then an experience score. The score weighs first SSE event time, total completion time, p90 completion tail latency, and health-check time, so a node with one very slow probe is not recommended just because its median result looks good. The result table also marks the fastest full completion separately from the balanced recommendation, because deep or long-output model runs may care more about full completion time than first-token responsiveness. Model prompts use "configurable context" wording because the value is written to the client-side Codex `model_context_window`; it must not be confused with a public marketing total-window label.
14
14
 
15
- Production endpoints are shown as the main, Japan, and Hong Kong ingress domains. They are all GPTeam production API entry points backed by the current new backend stack; the CLI no longer describes them as split-routing or secondary-worker nodes. The current node ids are `main`, `jp`, and `hk`. Older scripted values `jp-direct`, `jp-split`, and `hk-split` remain accepted as compatibility aliases.
15
+ Production endpoints are `main` and `jp`. Older scripted values `jp-direct` and `jp-split` remain compatibility aliases. The retired Hong Kong ingress is not included in the package, generated configs, help text, or release smoke tests.
16
16
 
17
- Client config writing follows the same safety pattern as cc-switch: keep Codex top-level fields separate from provider tables, preserve unrelated sections such as MCP servers, merge OpenCode/OpenClaw providers additively, and stop before writing when an existing JSON/JSON5 config cannot be parsed. GPTeam keeps three proxy-specific extensions on top of that baseline: Codex enables WebSocket capability for the GPTeam provider, Codex/OpenCode/Claude Code write the `gpteam_image` MCP server so Image 2 can be called from chat, and Claude Code writes the reasoning-effort header through `ANTHROPIC_CUSTOM_HEADERS`.
17
+ Client config writing follows the same safety pattern as cc-switch: keep Codex top-level fields separate from provider tables, preserve unrelated sections such as MCP servers, merge OpenCode/OpenClaw providers additively, and stop before writing when an existing JSON/JSON5 config cannot be parsed. GPTEAM keeps three proxy-specific extensions on top of that baseline: Codex enables WebSocket capability for the GPTEAM provider, Codex/OpenCode/Claude Code write the `gpteam_image` MCP server so Image 2 can be called from chat, and Claude Code writes the reasoning-effort header through `ANTHROPIC_CUSTOM_HEADERS`.
18
18
 
19
19
  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`.
20
20
 
@@ -29,9 +29,9 @@ The Image MCP exposes an async-first local job flow plus a legacy compatibility
29
29
 
30
30
  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`.
31
31
 
32
- 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. For easier tool calling, `image`, `image_path`, `image_paths`, `input_image`, and `input_images` are accepted as aliases. Pass `mask` or `mask_path` the same way for masked edits. `input_fidelity` is accepted for compatibility but is not forwarded to the current GPTeam Image 2 bridge because upstream Codex image edits reject it. The MCP requests GPTeam image endpoints with `stream=true` and reads the final image event, so long image jobs get early stream bytes instead of sitting idle until the final JSON body. 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.
32
+ 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. For easier tool calling, `image`, `image_path`, `image_paths`, `input_image`, and `input_images` are accepted as aliases. Pass `mask` or `mask_path` the same way for masked edits. `input_fidelity` is accepted for compatibility but is not forwarded to the current GPTEAM Image 2 bridge because upstream Codex image edits reject it. The MCP requests GPTEAM image endpoints with `stream=true` and reads the final image event, so long image jobs get early stream bytes instead of sitting idle until the final JSON body. 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.
33
33
 
34
- 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.
34
+ 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.
35
35
 
36
36
  Supported clients:
37
37
 
File without changes
package/lib/bench.js CHANGED
@@ -2,6 +2,7 @@ import dns from 'node:dns';
2
2
  import https from 'node:https';
3
3
  import { performance } from 'node:perf_hooks';
4
4
  import { formatNetworkError } from './errors.js';
5
+ import { nodeAPIBaseUrl } from './nodes.js';
5
6
  import { inspectSSEBody } from './sse.js';
6
7
 
7
8
  const MISSING_LATENCY_MS = 999999;
@@ -29,7 +30,7 @@ export async function benchmarkNodes(nodes, options) {
29
30
 
30
31
  export async function benchmarkNode(node, options) {
31
32
  const health = await measureHealth(node.healthUrl);
32
- const stream = await measureStream(node.baseUrl, options);
33
+ const stream = await measureStream(nodeAPIBaseUrl(node), options);
33
34
  return {
34
35
  ok: health.ok && stream.ok,
35
36
  health,
@@ -120,6 +121,7 @@ function measureStream(baseUrl, options) {
120
121
  const payload = JSON.stringify({
121
122
  model: options.model,
122
123
  stream: true,
124
+ instructions: 'You are a helpful coding assistant.',
123
125
  input: options.prompt || '请只回复一句话:节点测速完成。',
124
126
  max_output_tokens: options.maxOutputTokens || 648,
125
127
  reasoning: options.effort ? { effort: options.effort } : undefined,
package/lib/cli.js CHANGED
@@ -4,8 +4,8 @@ import { benchmarkNodes, formatMs } from './bench.js';
4
4
  import { ensureClientInstalled, formatInstallCommand } from './client-install.js';
5
5
  import { CLIENTS, writeClientConfig } from './config.js';
6
6
  import { getHelpText, PACKAGE_NAME, PACKAGE_VERSION } from './help.js';
7
- import { modelByID, validateApiKey } from './models.js';
8
- import { INGRESS_NODES, nodeMatchesID } from './nodes.js';
7
+ import { DEFAULT_OPENAI_MODEL_ID, validateApiKey } from './models.js';
8
+ import { INGRESS_NODES, nodeMatchesID, nodesFromCapabilities } from './nodes.js';
9
9
  import { createTheme, stripAnsi } from './terminal.js';
10
10
 
11
11
  export async function runCli(argv = []) {
@@ -25,27 +25,31 @@ export async function runCli(argv = []) {
25
25
  printBanner(theme);
26
26
 
27
27
  const apiKey = args.key || args.apiKey || await askRequired(rl, '请输入 API key:');
28
- printStep(theme, 1, 5, '校验 API key', '请求 /v1/models,校验通过后才继续。');
28
+ printStep(theme, 1, 5, '校验 API key', '读取 Key 分组能力,校验通过后才继续。');
29
29
  const validation = await validateApiKey(INGRESS_NODES, apiKey);
30
- printStatus(theme, '通过', `API key 可用,校验入口:${validation.node.label}`);
31
-
32
- printStep(theme, 2, 5, '选择客户端和模型');
33
- const client = await choose(rl, '请选择客户端类型', CLIENTS, args.client, theme);
30
+ const ingressNodes = nodesFromCapabilities(validation);
31
+ printStatus(theme, '通过', formatValidationSummary(validation));
32
+
33
+ printStep(theme, 2, 5, '选择客户端');
34
+ const platform = validation.group?.platform || '';
35
+ const allowedClients = Array.isArray(validation.clients) && validation.clients.length
36
+ ? validation.clients
37
+ : clientIDsForPlatform(platform);
38
+ const clientChoices = filterClientsForCapabilities(CLIENTS, allowedClients);
39
+ const client = await choose(rl, '请选择客户端类型', clientChoices, args.client, theme);
34
40
  await ensureSelectedClientInstalled(client.id, args, rl, theme);
35
41
  const models = validation.models;
36
- const model = await chooseModel(rl, models, args.model, theme);
37
- const contextLength = await askContextLength(rl, model, args.context);
38
- const effort = await choose(rl, '请选择思考深度', model.efforts.map((id) => ({ id, label: id })), args.effort, theme);
39
- const maxOutputTokens = Number(args.maxOutputTokens || 648);
42
+ const model = selectDefaultModel(models, validation.defaultModel || '', platform);
43
+ const contextLength = Number(model.contextLength || 400000);
44
+ const maxOutputTokens = Number(model.maxOutputTokens || 648);
40
45
 
41
46
  printStep(theme, 3, 5, '真实请求测速', 'GET /api/health + POST /v1/responses stream=true');
42
47
  printHint(theme, '入口之间并行测速,单入口多轮顺序执行,避免同时打出过多真实请求。');
43
48
  printHint(theme, '综合推荐规则:成功率优先,其次按首包、完成、尾延迟和健康检查计算体验分,分数越低越好;完成最快会单独标出。');
44
- printHint(theme, `模型:${model.id},测速输出上限:${maxOutputTokens}`);
45
- const results = await benchmarkNodes(INGRESS_NODES, {
49
+ printHint(theme, `测速模型:${model.id}`);
50
+ const results = await benchmarkNodes(ingressNodes, {
46
51
  apiKey,
47
52
  model: model.id,
48
- effort: effort.id,
49
53
  maxOutputTokens,
50
54
  rounds: Number(args.rounds || 3)
51
55
  });
@@ -60,10 +64,12 @@ export async function runCli(argv = []) {
60
64
  const written = writeClientConfig(client.id, {
61
65
  apiKey,
62
66
  model: model.id,
63
- effort: effort.id,
64
67
  contextLength,
65
68
  maxOutputTokens: model.maxOutputTokens,
66
- node: selectedNode
69
+ models,
70
+ platform,
71
+ node: selectedNode,
72
+ imageMCP: validation.imageMCP
67
73
  });
68
74
 
69
75
  console.log('');
@@ -71,8 +77,8 @@ export async function runCli(argv = []) {
71
77
  for (const filePath of written) console.log(`- ${filePath}`);
72
78
  console.log(`入口:${formatNodeLabel(selectedNode)}`);
73
79
  console.log(`地址:${selectedNode.baseUrl}`);
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 取结果,get_capabilities 可查看支持能力,MCP 使用专用环境变量读取 API key。');
80
+ if (['codex', 'opencode', 'claude-code'].includes(client.id) && validation.imageMCP?.enabled === true) {
81
+ printHint(theme, '已写入 GPTEAM Image MCP。客户端对话里需要生图或图生图时优先调用 create_image_job,再用 get_image_job_status 和 download_image_result 取结果,get_capabilities 可查看支持能力,MCP 使用专用环境变量读取 API key。');
76
82
  }
77
83
  } finally {
78
84
  rl.close();
@@ -143,23 +149,26 @@ export function resultMarker(item, recommended, fastestTotal) {
143
149
  return labels.length ? labels.join('/') : '-';
144
150
  }
145
151
 
146
- async function chooseModel(rl, models, preferred, theme) {
147
- const selected = preferred ? modelByID(models, preferred) : null;
148
- if (selected) return selected;
149
- printSubTitle(theme, '可用模型');
150
- models.forEach((model, index) => {
151
- console.log(` ${theme.info(String(index + 1).padStart(2, ' '))} ${formatModelLabel(model)}`);
152
- });
153
- const answer = await askRequired(rl, '请选择模型序号:');
154
- const index = Math.max(1, Math.min(models.length, Number(answer) || 1)) - 1;
155
- return models[index];
152
+ export function selectDefaultModel(models, preferred, platform) {
153
+ const available = Array.isArray(models) ? models.filter((model) => model && model.id) : [];
154
+ if (!available.length) {
155
+ throw new Error('没有可配置模型,已停止写入客户端配置');
156
+ }
157
+ const current = normalizePlatform(platform);
158
+ const preferredIDs = defaultModelCandidates(current, preferred);
159
+ for (const id of preferredIDs) {
160
+ const match = available.find((model) => model.id === id);
161
+ if (match) return match;
162
+ }
163
+ return available[0];
156
164
  }
157
165
 
158
- async function askContextLength(rl, model, preferred) {
159
- const max = Number(model.contextLength || 400000);
160
- if (preferred) return clamp(Number(preferred), 1, max);
161
- const answer = await rl.question(`请输入可配置上下文长度(最大 ${max},输出上限 ${model.maxOutputTokens},默认 ${max},回车即选择默认):`);
162
- return clamp(Number(answer || max), 1, max);
166
+ function defaultModelCandidates(platform, preferred) {
167
+ const ids = [];
168
+ if (platform === 'gemini') ids.push('gemini-3.1-pro-preview');
169
+ if (platform === 'openai') ids.push(DEFAULT_OPENAI_MODEL_ID);
170
+ ids.push(preferred);
171
+ return [...new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))];
163
172
  }
164
173
 
165
174
  export async function chooseNode(rl, results, preferred, recommendedID, theme = createTheme()) {
@@ -212,15 +221,42 @@ function formatScore(value) {
212
221
  return Number.isFinite(value) ? String(Math.round(value)) : '-';
213
222
  }
214
223
 
215
- function clamp(value, min, max) {
216
- if (!Number.isFinite(value)) return max;
217
- return Math.max(min, Math.min(max, Math.floor(value)));
224
+ export function formatModelLabel(model) {
225
+ return String(model?.id || '');
218
226
  }
219
227
 
220
- export function formatModelLabel(model) {
221
- const context = Number(model.contextLength || 0);
222
- const outputTokens = Number(model.maxOutputTokens || 0);
223
- return `${model.id}(可配置上下文 ${context},输出上限 ${outputTokens})`;
228
+ export function filterClientsForCapabilities(clients, allowedIDs) {
229
+ if (!Array.isArray(allowedIDs) || !allowedIDs.length) return clients;
230
+ const allowed = new Set(allowedIDs.flatMap((id) => {
231
+ const value = String(id);
232
+ return value === 'codex' ? ['codex', 'codex-ws'] : [value];
233
+ }));
234
+ const filtered = clients.filter((client) => allowed.has(client.id));
235
+ return filtered.length ? filtered : clients;
236
+ }
237
+
238
+ export function clientIDsForPlatform(platform) {
239
+ const current = normalizePlatform(platform);
240
+ if (current === 'anthropic') return ['claude-code', 'opencode'];
241
+ if (current === 'gemini') return ['gemini', 'opencode'];
242
+ if (current === 'ark' || current === 'openai-compatible') return ['opencode'];
243
+ return ['codex', 'codex-ws', 'claude-code', 'opencode'];
244
+ }
245
+
246
+ function normalizePlatform(value) {
247
+ const key = String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '');
248
+ if (!key) return 'openai';
249
+ if (key.includes('anthropic') || key.includes('claude')) return 'anthropic';
250
+ if (key.includes('gemini') || key.includes('google')) return 'gemini';
251
+ if (key.includes('ark') || key.includes('volc') || key.includes('doubao') || key.includes('openaicompatible')) return 'ark';
252
+ return 'openai';
253
+ }
254
+
255
+ function formatValidationSummary(validation) {
256
+ const group = validation.group && validation.group.name
257
+ ? `,分组:${validation.group.name}`
258
+ : '';
259
+ return `API key 可用,校验入口:${validation.node.label}${group}`;
224
260
  }
225
261
 
226
262
  export function formatNodeLabel(node) {
@@ -234,7 +270,7 @@ export function assertNodeConfig(node) {
234
270
  }
235
271
 
236
272
  function printBanner(theme) {
237
- console.log(theme.brand('GPTeam API 配置助手'));
273
+ console.log(theme.brand('GPTEAM API 配置助手'));
238
274
  console.log(theme.muted('真实请求测速,自动写入客户端配置,旧配置会先备份。'));
239
275
  }
240
276
 
@@ -8,6 +8,13 @@ export const CLIENT_INSTALL_SPECS = {
8
8
  installCommand: ['npm', ['i', '-g', '@openai/codex@latest']],
9
9
  versionHint: 'codex --version'
10
10
  },
11
+ 'codex-ws': {
12
+ id: 'codex-ws',
13
+ label: 'Codex (WebSocket)',
14
+ command: 'codex',
15
+ installCommand: ['npm', ['i', '-g', '@openai/codex@latest']],
16
+ versionHint: 'codex --version'
17
+ },
11
18
  opencode: {
12
19
  id: 'opencode',
13
20
  label: 'OpenCode',
@@ -22,6 +29,13 @@ export const CLIENT_INSTALL_SPECS = {
22
29
  installCommand: ['npm', ['i', '-g', '@anthropic-ai/claude-code@latest']],
23
30
  versionHint: 'claude --version'
24
31
  },
32
+ gemini: {
33
+ id: 'gemini',
34
+ label: 'Gemini CLI',
35
+ command: 'gemini',
36
+ installCommand: ['npm', ['i', '-g', '@google/gemini-cli@latest']],
37
+ versionHint: 'gemini --version'
38
+ },
25
39
  openclaw: {
26
40
  id: 'openclaw',
27
41
  label: 'OpenClaw',
package/lib/config.js CHANGED
@@ -2,7 +2,8 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import JSON5 from 'json5';
5
- import { endpointRoot } from './nodes.js';
5
+ import { DEFAULT_OPENAI_SMALL_MODEL_ID } from './models.js';
6
+ import { endpointAPIBase, endpointRoot } from './nodes.js';
6
7
 
7
8
  const PROVIDER_ID = 'gpteam';
8
9
  const IMAGE_MCP_ID = 'gpteam_image';
@@ -18,15 +19,19 @@ const IMAGE_MCP_ENABLED_TOOLS = [
18
19
 
19
20
  export const CLIENTS = [
20
21
  { id: 'codex', label: 'Codex' },
22
+ { id: 'codex-ws', label: 'Codex (WebSocket)' },
21
23
  { id: 'opencode', label: 'OpenCode' },
22
24
  { id: 'claude-code', label: 'Claude Code' },
25
+ { id: 'gemini', label: 'Gemini CLI' },
23
26
  { id: 'openclaw', label: 'OpenClaw(macOS / Linux)' }
24
27
  ];
25
28
 
26
29
  export function writeClientConfig(clientID, settings) {
27
30
  if (clientID === 'codex') return writeCodexConfig(settings);
31
+ if (clientID === 'codex-ws') return writeCodexConfig({ ...settings, websocket: true });
28
32
  if (clientID === 'opencode') return writeOpenCodeConfig(settings);
29
33
  if (clientID === 'claude-code') return writeClaudeCodeConfig(settings);
34
+ if (clientID === 'gemini') return writeGeminiCLIConfig(settings);
30
35
  if (clientID === 'openclaw') return writeOpenClawConfig(settings);
31
36
  throw new Error(`未知客户端:${clientID}`);
32
37
  }
@@ -44,8 +49,6 @@ export function writeCodexConfig(settings) {
44
49
  const managedRoot = [
45
50
  `model = ${tomlString(settings.model)}`,
46
51
  `model_provider = "gpteam"`,
47
- `model_context_window = ${Number(settings.contextLength)}`,
48
- `model_reasoning_effort = ${tomlString(settings.effort)}`,
49
52
  'disable_response_storage = true'
50
53
  ];
51
54
  const managedProvider = [
@@ -56,7 +59,11 @@ export function writeCodexConfig(settings) {
56
59
  'requires_openai_auth = true',
57
60
  'supports_websockets = true'
58
61
  ];
62
+ const websocketFeature = settings.websocket === true
63
+ ? ['[features]', 'responses_websockets_v2 = true']
64
+ : [];
59
65
  const mcpCommand = codexImageMCPCommand();
66
+ const mcpEnv = imageMCPEnv(settings);
60
67
  const managedImageMCP = [
61
68
  `[mcp_servers.${IMAGE_MCP_ID}]`,
62
69
  `command = ${tomlString(mcpCommand.command)}`,
@@ -67,15 +74,17 @@ export function writeCodexConfig(settings) {
67
74
  'default_tools_approval_mode = "prompt"',
68
75
  '',
69
76
  `[mcp_servers.${IMAGE_MCP_ID}.env]`,
70
- `GPTEAM_API_KEY = ${tomlString(settings.apiKey)}`,
71
- `GPTEAM_BASE_URL = ${tomlString(settings.node.baseUrl)}`,
72
- `GPTEAM_CODEX_HOME = ${tomlString(dir)}`
77
+ `GPTEAM_API_KEY = ${tomlString(mcpEnv.GPTEAM_API_KEY)}`,
78
+ `GPTEAM_BASE_URL = ${tomlString(mcpEnv.GPTEAM_BASE_URL)}`,
79
+ `GPTEAM_CODEX_HOME = ${tomlString(dir)}`,
80
+ ...imageMCPConcurrencyEnvLines(mcpEnv)
73
81
  ];
74
82
  const next = joinTomlSections([
75
83
  managedRoot.join('\n'),
76
84
  rootLines.join('\n'),
77
85
  rest.join('\n'),
78
- managedImageMCP.join('\n'),
86
+ websocketFeature.join('\n'),
87
+ imageMCPEnabled(settings) ? managedImageMCP.join('\n') : '',
79
88
  managedProvider.join('\n')
80
89
  ]);
81
90
  assertNoDuplicateTomlKeys(next);
@@ -91,33 +100,30 @@ export function writeOpenCodeConfig(settings) {
91
100
  backupIfExists(filePath);
92
101
  const config = readJSON(filePath, { $schema: 'https://opencode.ai/config.json' });
93
102
  config.provider = config.provider && typeof config.provider === 'object' ? config.provider : {};
103
+ const providerProfile = openCodeProviderProfile(settings);
94
104
  config.provider[PROVIDER_ID] = {
95
- npm: '@ai-sdk/openai',
96
- name: 'GPTeam',
105
+ npm: providerProfile.npm,
106
+ name: 'GPTEAM',
97
107
  options: {
98
108
  apiKey: settings.apiKey,
99
- baseURL: settings.node.baseUrl,
109
+ baseURL: providerProfile.baseURL,
100
110
  setCacheKey: true
101
111
  },
102
- models: {
103
- [settings.model]: {
104
- name: settings.model,
105
- limit: {
106
- context: Number(settings.contextLength),
107
- output: Number(settings.maxOutputTokens)
108
- },
109
- variants: {
110
- [settings.effort]: {
111
- reasoningEffort: settings.effort,
112
- reasoningSummary: 'auto',
113
- textVerbosity: 'medium'
114
- }
115
- }
116
- }
117
- }
112
+ models: openCodeModels(settings)
118
113
  };
114
+ const modelEntries = normalizeSettingsModels(settings);
115
+ const defaultModel = resolveOpenCodeDefaultModel(settings, modelEntries);
116
+ if (defaultModel) {
117
+ const smallModel = resolveOpenCodeSmallModel(settings, modelEntries, defaultModel);
118
+ config.model = `${PROVIDER_ID}/${defaultModel}`;
119
+ config.small_model = `${PROVIDER_ID}/${smallModel}`;
120
+ }
119
121
  config.mcp = config.mcp && typeof config.mcp === 'object' ? config.mcp : {};
120
- config.mcp[IMAGE_MCP_ID] = openCodeImageMCPConfig(settings);
122
+ if (imageMCPEnabled(settings)) {
123
+ config.mcp[IMAGE_MCP_ID] = openCodeImageMCPConfig(settings);
124
+ } else {
125
+ delete config.mcp[IMAGE_MCP_ID];
126
+ }
121
127
  writeJSON(filePath, config);
122
128
  return [filePath];
123
129
  }
@@ -137,21 +143,39 @@ export function writeClaudeCodeConfig(settings) {
137
143
  ANTHROPIC_MODEL: settings.model,
138
144
  ANTHROPIC_DEFAULT_HAIKU_MODEL: settings.model,
139
145
  ANTHROPIC_DEFAULT_SONNET_MODEL: settings.model,
140
- ANTHROPIC_DEFAULT_OPUS_MODEL: settings.model,
141
- ANTHROPIC_CUSTOM_HEADERS: `X-Codex-Reasoning-Effort: ${settings.effort}`
146
+ ANTHROPIC_DEFAULT_OPUS_MODEL: settings.model
142
147
  });
148
+ delete config.env.ANTHROPIC_CUSTOM_HEADERS;
143
149
  writeJSON(filePath, config);
144
150
  const mcpConfig = readJSON(mcpPath, {});
145
151
  mcpConfig.mcpServers = mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object'
146
152
  ? mcpConfig.mcpServers
147
153
  : {};
148
- mcpConfig.mcpServers[IMAGE_MCP_ID] = imageMCPServer(settings);
154
+ if (imageMCPEnabled(settings)) {
155
+ mcpConfig.mcpServers[IMAGE_MCP_ID] = imageMCPServer(settings);
156
+ } else {
157
+ delete mcpConfig.mcpServers[IMAGE_MCP_ID];
158
+ }
149
159
  writeJSON(mcpPath, mcpConfig);
150
160
  return [filePath, mcpPath];
151
161
  }
152
162
 
153
163
  export const writeClaudeCodeEnv = writeClaudeCodeConfig;
154
164
 
165
+ export function writeGeminiCLIConfig(settings) {
166
+ const dir = path.join(homeDir(), '.gpteam');
167
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
168
+ const filePath = path.join(dir, 'gemini-cli.env');
169
+ backupIfExists(filePath);
170
+ const lines = [
171
+ `export GOOGLE_GEMINI_BASE_URL=${shellString(geminiBaseUrl(settings.node.baseUrl))}`,
172
+ `export GEMINI_API_KEY=${shellString(settings.apiKey)}`,
173
+ `export GEMINI_MODEL=${shellString(settings.model)}`
174
+ ];
175
+ writeTextAtomic(filePath, `${lines.join('\n')}\n`, 0o600);
176
+ return [filePath];
177
+ }
178
+
155
179
  export function writeOpenClawConfig(settings) {
156
180
  if (process.platform === 'win32') {
157
181
  throw new Error('OpenClaw 自动配置当前只支持 macOS / Linux');
@@ -215,6 +239,185 @@ function claudeBaseUrl(baseUrl) {
215
239
  return `${root}/anthropic`;
216
240
  }
217
241
 
242
+ function geminiBaseUrl(baseUrl) {
243
+ const root = endpointRoot(baseUrl);
244
+ if (/\/v1beta\/?$/.test(root)) return root.replace(/\/$/, '');
245
+ return `${root}/v1beta`;
246
+ }
247
+
248
+ function openCodeProviderProfile(settings) {
249
+ const platform = normalizePlatform(settings?.platform || settings?.group?.platform);
250
+ const baseUrl = settings?.node?.baseUrl || '';
251
+ if (platform === 'anthropic') {
252
+ return { npm: '@ai-sdk/anthropic', baseURL: claudeBaseUrl(baseUrl) };
253
+ }
254
+ if (platform === 'gemini') {
255
+ return { npm: '@ai-sdk/google', baseURL: geminiBaseUrl(baseUrl) };
256
+ }
257
+ if (platform === 'ark' || platform === 'openai-compatible') {
258
+ return { npm: '@ai-sdk/openai-compatible', baseURL: endpointAPIBase(baseUrl) };
259
+ }
260
+ return { npm: '@ai-sdk/openai', baseURL: endpointAPIBase(baseUrl) };
261
+ }
262
+
263
+ function resolveOpenCodeDefaultModel(settings, entries = normalizeSettingsModels(settings)) {
264
+ return String(settings.model || entries[0]?.id || '').trim();
265
+ }
266
+
267
+ function resolveOpenCodeSmallModel(settings, entries, defaultModel) {
268
+ const platform = normalizePlatform(settings?.platform || settings?.group?.platform);
269
+ if (platform === 'openai') {
270
+ const smallModel = findOpenCodeModelID(entries, DEFAULT_OPENAI_SMALL_MODEL_ID);
271
+ if (smallModel) return smallModel;
272
+ }
273
+ return defaultModel;
274
+ }
275
+
276
+ function findOpenCodeModelID(entries, id) {
277
+ const target = String(id || '').trim();
278
+ if (!target) return '';
279
+ const match = (entries || []).find((model) => String(model?.id || model?.name || '').trim() === target);
280
+ return match ? target : '';
281
+ }
282
+
283
+ function openCodeModels(settings) {
284
+ const entries = normalizeSettingsModels(settings);
285
+ const out = {};
286
+ for (const model of entries) {
287
+ const id = String(model.id || model.name || '').trim();
288
+ if (!id || out[id]) continue;
289
+ const limit = {};
290
+ const context = Number(model.contextLength || model.context_length || model.inputTokenLimit || settings.contextLength);
291
+ const output = Number(model.maxOutputTokens || model.max_completion_tokens || model.outputTokenLimit || settings.maxOutputTokens);
292
+ if (Number.isFinite(context) && context > 0) limit.context = Math.floor(context);
293
+ if (Number.isFinite(output) && output > 0) limit.output = Math.floor(output);
294
+ const item = {
295
+ id,
296
+ name: String(model.displayName || model.display_name || id),
297
+ tool_call: true,
298
+ modalities: { input: ['text'], output: ['text'] },
299
+ ...(Object.keys(limit).length >= 2 ? { limit } : {})
300
+ };
301
+ if (openCodeModelSupportsReasoning(model, settings)) {
302
+ item.reasoning = true;
303
+ const options = openCodeModelOptions(model, settings);
304
+ if (Object.keys(options).length > 0) item.options = options;
305
+ const variants = openCodeModelVariants(model, settings);
306
+ if (Object.keys(variants).length > 0) item.variants = variants;
307
+ }
308
+ out[id] = item;
309
+ }
310
+ return out;
311
+ }
312
+
313
+ function openCodeModelOptions(model, settings) {
314
+ const platform = normalizePlatform(settings?.platform || settings?.group?.platform);
315
+ if (platform === 'anthropic') {
316
+ return { thinking: { type: 'enabled', budgetTokens: openCodeThinkingBudget(model) } };
317
+ }
318
+ if (platform === 'openai') {
319
+ return { reasoningEffort: 'high' };
320
+ }
321
+ if (platform === 'gemini') {
322
+ return openCodeGeminiThinkingOptions(model, 'high');
323
+ }
324
+ return {};
325
+ }
326
+
327
+ function openCodeModelVariants(model, settings) {
328
+ const platform = normalizePlatform(settings?.platform || settings?.group?.platform);
329
+ if (platform === 'openai') return openCodeOpenAIVariants(model);
330
+ if (platform === 'anthropic') return openCodeAnthropicVariants(model);
331
+ if (platform === 'gemini') return openCodeGeminiVariants(model);
332
+ return {};
333
+ }
334
+
335
+ function openCodeOpenAIVariants(model) {
336
+ return Object.fromEntries(openCodeThinkingLevels(model, ['low', 'medium', 'high', 'xhigh'])
337
+ .map((level) => [level, { reasoningEffort: level }]));
338
+ }
339
+
340
+ function openCodeAnthropicVariants(model) {
341
+ const budgets = { low: 8000, medium: 16000, high: 32000, xhigh: 64000 };
342
+ return Object.fromEntries(openCodeThinkingLevels(model, ['low', 'medium', 'high', 'xhigh'])
343
+ .map((level) => [level, { thinking: { type: 'enabled', budgetTokens: budgets[level] || 32000 } }]));
344
+ }
345
+
346
+ function openCodeGeminiVariants(model) {
347
+ return Object.fromEntries(openCodeThinkingLevels(model, ['low', 'medium', 'high'])
348
+ .map((level) => [level, openCodeGeminiThinkingOptions(model, level)]));
349
+ }
350
+
351
+ function openCodeThinkingLevels(model, fallback) {
352
+ const levels = Array.isArray(model?.thinking?.levels) ? model.thinking.levels : fallback;
353
+ const out = [];
354
+ for (const level of levels) {
355
+ const normalized = String(level || '').trim().toLowerCase();
356
+ if (normalized && !out.includes(normalized)) out.push(normalized);
357
+ }
358
+ return out;
359
+ }
360
+
361
+ function openCodeGeminiThinkingOptions(model, level) {
362
+ const id = String(model?.id || model?.name || '').toLowerCase();
363
+ if (/gemini-3/.test(id)) return { thinkingConfig: { thinkingLevel: level } };
364
+ const budgets = { low: 1024, medium: 8192, high: 24576 };
365
+ return { thinkingConfig: { thinkingBudget: budgets[level] || 24576 } };
366
+ }
367
+
368
+ function openCodeThinkingBudget(model) {
369
+ const values = [
370
+ model?.thinking?.budgetTokens,
371
+ model?.thinking?.budget_tokens,
372
+ model?.thinkingBudgetTokens
373
+ ];
374
+ for (const value of values) {
375
+ const parsed = Number(value);
376
+ if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
377
+ }
378
+ return 32000;
379
+ }
380
+
381
+ function openCodeModelSupportsReasoning(model, settings) {
382
+ if (model && model.thinking && typeof model.thinking === 'object') return true;
383
+ const id = String(model?.id || model?.name || '').toLowerCase();
384
+ const platform = normalizePlatform(settings?.platform || settings?.group?.platform);
385
+ if (/thinking|reasoning/.test(id)) return true;
386
+ if (platform === 'openai' && /^(gpt-|codex-)/.test(id)) return true;
387
+ if (platform === 'anthropic' && /^claude-/.test(id)) return true;
388
+ if (platform === 'gemini' && /^gemini-(3|2\.5)/.test(id)) return true;
389
+ return false;
390
+ }
391
+
392
+ function normalizeSettingsModels(settings) {
393
+ const models = Array.isArray(settings?.models) ? settings.models : [];
394
+ const entries = models.length ? models : [{
395
+ id: settings.model,
396
+ contextLength: settings.contextLength,
397
+ maxOutputTokens: settings.maxOutputTokens
398
+ }];
399
+ return entries.filter((model) => model && String(model.id || model.name || '').trim());
400
+ }
401
+
402
+ function normalizePlatform(value) {
403
+ const key = String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '');
404
+ if (!key) return 'openai';
405
+ if (key.includes('anthropic') || key.includes('claude')) return 'anthropic';
406
+ if (key.includes('gemini') || key.includes('google')) return 'gemini';
407
+ if (
408
+ key.includes('ark') ||
409
+ key.includes('volc') ||
410
+ key.includes('doubao') ||
411
+ key.includes('bytedance') ||
412
+ key.includes('openaicompatible')
413
+ ) return 'ark';
414
+ return 'openai';
415
+ }
416
+
417
+ function shellString(value) {
418
+ return `"${String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
419
+ }
420
+
218
421
  function codexImageMCPCommand() {
219
422
  if (process.platform === 'win32') {
220
423
  return {
@@ -238,10 +441,34 @@ function imageMCPServer(settings) {
238
441
  }
239
442
 
240
443
  function imageMCPEnv(settings) {
241
- return {
444
+ const env = {
242
445
  GPTEAM_API_KEY: String(settings.apiKey || ''),
243
446
  GPTEAM_BASE_URL: String(settings.node && settings.node.baseUrl ? settings.node.baseUrl : '')
244
447
  };
448
+ const maxConcurrent = positiveIntString(settings?.imageMCP?.max_concurrent_jobs);
449
+ if (maxConcurrent) env.GPTEAM_IMAGE_MAX_CONCURRENT = maxConcurrent;
450
+ const maxQueue = positiveIntString(settings?.imageMCP?.max_queued_jobs, true);
451
+ if (maxQueue) env.GPTEAM_IMAGE_MAX_QUEUE = maxQueue;
452
+ return env;
453
+ }
454
+
455
+ function imageMCPConcurrencyEnvLines(env) {
456
+ const lines = [];
457
+ if (env.GPTEAM_IMAGE_MAX_CONCURRENT) {
458
+ lines.push(`GPTEAM_IMAGE_MAX_CONCURRENT = ${tomlString(env.GPTEAM_IMAGE_MAX_CONCURRENT)}`);
459
+ }
460
+ if (env.GPTEAM_IMAGE_MAX_QUEUE) {
461
+ lines.push(`GPTEAM_IMAGE_MAX_QUEUE = ${tomlString(env.GPTEAM_IMAGE_MAX_QUEUE)}`);
462
+ }
463
+ return lines;
464
+ }
465
+
466
+ function positiveIntString(value, allowZero = false) {
467
+ const parsed = Number(value);
468
+ if (!Number.isFinite(parsed)) return '';
469
+ const rounded = Math.floor(parsed);
470
+ if (allowZero ? rounded < 0 : rounded <= 0) return '';
471
+ return String(rounded);
245
472
  }
246
473
 
247
474
  function openCodeImageMCPConfig(settings) {
@@ -254,6 +481,10 @@ function openCodeImageMCPConfig(settings) {
254
481
  };
255
482
  }
256
483
 
484
+ function imageMCPEnabled(settings) {
485
+ return settings?.imageMCP?.enabled === true;
486
+ }
487
+
257
488
  function stripCodexManagedConfig(raw) {
258
489
  const rootLines = [];
259
490
  const rest = [];
package/lib/help.js CHANGED
@@ -1,9 +1,9 @@
1
1
  export const PACKAGE_NAME = 'gpteam';
2
- export const PACKAGE_VERSION = '0.1.28';
2
+ export const PACKAGE_VERSION = '0.1.30';
3
3
 
4
4
  export function getHelpText() {
5
5
  return [
6
- 'GPTeam API 配置助手',
6
+ 'GPTEAM API 配置助手',
7
7
  '',
8
8
  '用法:',
9
9
  ' npx gpteam',
@@ -11,17 +11,13 @@ export function getHelpText() {
11
11
  '',
12
12
  '常用参数:',
13
13
  ' --api-key <key> 预填 API key',
14
- ' --client <id> codex / opencode / claude-code / openclaw',
15
- ' --model <id> 预选模型,例如 gpt-5.5',
16
- ' --context <tokens> 预设可配置上下文长度',
17
- ' --effort <level> 按模型支持项预选,常见为 none / low / medium / high / xhigh',
18
- ' --node <id> main / jp / hk(兼容旧参数 jp-direct / jp-split / hk-split)',
14
+ ' --client <id> codex / codex-ws / claude-code / opencode / gemini',
15
+ ' --node <id> main / jp(兼容旧参数 jp-direct / jp-split)',
19
16
  ' --rounds <n> 每个入口测速轮数,默认 3',
20
- ' --max-output-tokens <n> 测速输出上限,默认 648',
21
17
  ' --install-client 客户端命令缺失时直接安装,不再二次确认',
22
18
  ' --help 显示帮助',
23
19
  ' --version 显示版本',
24
20
  '',
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 可查看支持能力。'
21
+ '说明:输入 key 后自动识别分组能力,只展示可用客户端,自动选择模型测速并写入配置;旧配置会先备份。Codex、OpenCode、Claude Code 会按权限写入 GPTEAM Image MCP'
26
22
  ].join('\n');
27
23
  }
@@ -2,7 +2,7 @@ import { formatNetworkError } from '../errors.js';
2
2
 
3
3
  export class ImageMCPError extends Error {
4
4
  constructor(message, options = {}) {
5
- super(String(message || 'GPTeam image MCP error'));
5
+ super(String(message || 'GPTEAM image MCP error'));
6
6
  this.name = 'ImageMCPError';
7
7
  this.code = String(options.code || 'image_mcp_error');
8
8
  this.category = String(options.category || 'unknown');
@@ -77,7 +77,7 @@ export function normalizeImageFormat(value) {
77
77
  function decodeBase64Image(value) {
78
78
  const text = String(value || '').trim();
79
79
  if (!text) {
80
- throw new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
80
+ throw new ImageMCPError('GPTEAM 图片接口没有返回 b64_json 图片数据。', {
81
81
  code: 'image_data_missing',
82
82
  category: 'response_invalid',
83
83
  stage: 'local',
@@ -86,7 +86,7 @@ function decodeBase64Image(value) {
86
86
  }
87
87
  const bytes = Buffer.from(text, 'base64');
88
88
  if (bytes.length === 0) {
89
- throw new ImageMCPError('GPTeam 图片接口返回的图片数据为空。', {
89
+ throw new ImageMCPError('GPTEAM 图片接口返回的图片数据为空。', {
90
90
  code: 'image_data_empty',
91
91
  category: 'response_invalid',
92
92
  stage: 'local',
@@ -10,7 +10,7 @@ import {
10
10
  } from './errors.js';
11
11
  import { DEFAULT_IMAGE_FORMAT, localImageToDataURL, normalizeImageFormat, writeImageOutput } from './files.js';
12
12
 
13
- export const DEFAULT_BASE_URL = 'https://api.gpteamservices.com/v1';
13
+ export const DEFAULT_BASE_URL = 'https://api.gpteamservices.com';
14
14
  export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
15
15
  export { DEFAULT_IMAGE_FORMAT };
16
16
 
@@ -68,15 +68,15 @@ export function buildImageGenerationPayload(input = {}, options = {}) {
68
68
  return payload;
69
69
  }
70
70
 
71
- export function loadGPTeamCredentials(options = {}) {
71
+ export function loadGPTEAMCredentials(options = {}) {
72
72
  const env = options.env || process.env;
73
73
  const codexHome = resolveCodexHome(env, options.home || os.homedir());
74
74
  const readFile = options.readFile || ((filePath) => fs.readFileSync(filePath, 'utf8'));
75
75
  const configText = safeRead(path.join(codexHome, 'config.toml'), readFile);
76
- const configuredBaseUrl = parseGPTeamBaseUrl(configText);
76
+ const configuredBaseUrl = parseGPTEAMBaseUrl(configText);
77
77
  const apiKey = firstNonEmpty(env.GPTEAM_API_KEY);
78
78
  if (!apiKey) {
79
- throw new ImageMCPError('没有找到 GPTeam API key。请在 MCP 配置 env 中设置 GPTEAM_API_KEY,或先运行 npx gpteam 完成本地配置。', {
79
+ throw new ImageMCPError('没有找到 GPTEAM API key。请在 MCP 配置 env 中设置 GPTEAM_API_KEY,或先运行 npx gpteam 完成本地配置。', {
80
80
  code: 'api_key_missing',
81
81
  category: 'configuration',
82
82
  stage: 'configuration',
@@ -87,16 +87,16 @@ export function loadGPTeamCredentials(options = {}) {
87
87
  return { apiKey, baseUrl, codexHome };
88
88
  }
89
89
 
90
- export function parseGPTeamBaseUrl(configText) {
91
- let inGPTeamProvider = false;
90
+ export function parseGPTEAMBaseUrl(configText) {
91
+ let inGPTEAMProvider = false;
92
92
  for (const rawLine of String(configText || '').split(/\r?\n/)) {
93
93
  const line = rawLine.trim();
94
94
  const table = line.match(/^\[([^\]]+)\]$/);
95
95
  if (table) {
96
- inGPTeamProvider = table[1] === 'model_providers.gpteam';
96
+ inGPTEAMProvider = table[1] === 'model_providers.gpteam';
97
97
  continue;
98
98
  }
99
- if (!inGPTeamProvider) continue;
99
+ if (!inGPTEAMProvider) continue;
100
100
  const match = line.match(/^base_url\s*=\s*"((?:\\"|[^"])*)"/);
101
101
  if (match) return unescapeTomlString(match[1]);
102
102
  }
@@ -113,7 +113,7 @@ export function normalizeBaseUrl(value) {
113
113
  export async function generateImage(input = {}, options = {}) {
114
114
  const startedAt = now(options);
115
115
  validateImageInput(input, { requirePrompt: true });
116
- const credentials = loadGPTeamCredentials(options);
116
+ const credentials = loadGPTEAMCredentials(options);
117
117
  const payload = buildImageGenerationPayload(input, options);
118
118
  const fetchImpl = options.fetch || globalThis.fetch;
119
119
  if (typeof fetchImpl !== 'function') {
@@ -459,7 +459,7 @@ function imageDataURLToB64(value) {
459
459
  }
460
460
 
461
461
  function missingImageDataError() {
462
- return new ImageMCPError('GPTeam 图片接口没有返回 b64_json 图片数据。', {
462
+ return new ImageMCPError('GPTEAM 图片接口没有返回 b64_json 图片数据。', {
463
463
  code: 'image_data_missing',
464
464
  category: 'response_invalid',
465
465
  stage: 'local',
@@ -469,7 +469,7 @@ function missingImageDataError() {
469
469
 
470
470
  function imageErrorFromSSEPayload(payload) {
471
471
  const error = payload && payload.error;
472
- const message = typeof error === 'string' ? error : String(error && error.message || 'GPTeam 图片流返回错误。');
472
+ const message = typeof error === 'string' ? error : String(error && error.message || 'GPTEAM 图片流返回错误。');
473
473
  const code = typeof error === 'object' && error ? String(error.code || payload.code || 'upstream_stream_error') : String(payload.code || 'upstream_stream_error');
474
474
  return new ImageMCPError(message, {
475
475
  code,
@@ -651,7 +651,7 @@ function normalizeIdempotencyKey(value) {
651
651
  }
652
652
 
653
653
  function shapeDownloadResult(result, input = {}) {
654
- const includeImage = !input.metadata_only && input.include_image !== false;
654
+ const includeImage = input.include_image === true && !input.metadata_only;
655
655
  const includeRevisedPrompt = input.include_revised_prompt !== false;
656
656
  const output = {
657
657
  ...result,
@@ -94,7 +94,7 @@ const imageInputProperties = {
94
94
  },
95
95
  input_fidelity: {
96
96
  type: 'string',
97
- description: '兼容字段。当前 GPTeam Image 2 桥接会忽略该字段,因为上游 Codex 图片工具会拒绝 edits 中的该参数。',
97
+ description: '兼容字段。当前 GPTEAM Image 2 桥接会忽略该字段,因为上游 Codex 图片工具会拒绝 edits 中的该参数。',
98
98
  enum: ['low', 'high']
99
99
  },
100
100
  background: {
@@ -126,7 +126,7 @@ const imageInputProperties = {
126
126
  const tools = [
127
127
  {
128
128
  name: 'create_image_job',
129
- description: `推荐常规使用。创建本地后台 GPTeam Image 2 任务并立即返回 job_id。${imageToolPromptingInstruction}`,
129
+ description: `推荐常规使用。创建本地后台 GPTEAM Image 2 任务并立即返回 job_id。${imageToolPromptingInstruction}`,
130
130
  inputSchema: {
131
131
  type: 'object',
132
132
  properties: imageInputProperties,
@@ -136,12 +136,12 @@ const tools = [
136
136
  },
137
137
  {
138
138
  name: 'get_image_job_status',
139
- description: '查询本地 GPTeam Image 2 图片任务状态。',
139
+ description: '查询本地 GPTEAM Image 2 图片任务状态。',
140
140
  inputSchema: jobIDSchema()
141
141
  },
142
142
  {
143
143
  name: 'cancel_image_job',
144
- description: '取消仍在 queued 或 running 的本地 GPTeam Image 2 图片任务。取消是 best-effort,上游已开始生成时不保证同步取消。',
144
+ description: '取消仍在 queued 或 running 的本地 GPTEAM Image 2 图片任务。取消是 best-effort,上游已开始生成时不保证同步取消。',
145
145
  inputSchema: jobIDSchema()
146
146
  },
147
147
  {
@@ -151,7 +151,7 @@ const tools = [
151
151
  },
152
152
  {
153
153
  name: 'get_capabilities',
154
- description: '返回 GPTeam Image MCP 能力,包括支持尺寸、格式、质量、异步任务、取消语义、队列上限和参数约束。',
154
+ description: '返回 GPTEAM Image MCP 能力,包括支持尺寸、格式、质量、异步任务、取消语义、队列上限和参数约束。',
155
155
  inputSchema: {
156
156
  type: 'object',
157
157
  properties: {},
@@ -238,13 +238,13 @@ function downloadSchema() {
238
238
  const schema = jobIDSchema();
239
239
  schema.properties.metadata_only = {
240
240
  type: 'boolean',
241
- description: 'Return only metadata without MCP image content.',
242
- default: false
241
+ description: '只返回文件路径和元数据,不返回 MCP 图片内容。默认 true,避免大图进入上下文触发频繁 compact。',
242
+ default: true
243
243
  };
244
244
  schema.properties.include_image = {
245
245
  type: 'boolean',
246
- description: 'Include image content when the job succeeded.',
247
- default: true
246
+ description: '显式返回 MCP 图片内容。大图会显著增加上下文,通常保持 false,只使用本地文件路径。',
247
+ default: false
248
248
  };
249
249
  schema.properties.include_revised_prompt = {
250
250
  type: 'boolean',
package/lib/models.js CHANGED
@@ -1,12 +1,10 @@
1
1
  import { formatNetworkError } from './errors.js';
2
+ import { nodeAPIBaseUrl } from './nodes.js';
3
+
4
+ export const DEFAULT_OPENAI_MODEL_ID = 'gpt-5.5';
5
+ export const DEFAULT_OPENAI_SMALL_MODEL_ID = 'gpt-5.4-mini';
2
6
 
3
7
  export const FALLBACK_MODELS = {
4
- 'gpt-5.2': {
5
- id: 'gpt-5.2',
6
- contextLength: 400000,
7
- maxOutputTokens: 128000,
8
- efforts: ['none', 'low', 'medium', 'high', 'xhigh']
9
- },
10
8
  'gpt-5.3-codex': {
11
9
  id: 'gpt-5.3-codex',
12
10
  contextLength: 400000,
@@ -39,26 +37,63 @@ export const FALLBACK_MODELS = {
39
37
  }
40
38
  };
41
39
 
42
- export function normalizeModels(payload) {
40
+ class CapabilityEndpointUnavailableError extends Error {
41
+ constructor(message) {
42
+ super(message);
43
+ this.name = 'CapabilityEndpointUnavailableError';
44
+ this.capabilityEndpointUnavailable = true;
45
+ }
46
+ }
47
+
48
+ export function normalizeModels(payload, options = {}) {
43
49
  const items = Array.isArray(payload && payload.data) ? payload.data : [];
50
+ return normalizeModelItems(items, { fallbackWhenEmpty: options.fallbackWhenEmpty !== false });
51
+ }
52
+
53
+ export function normalizeCapabilities(payload) {
54
+ const models = payload && typeof payload === 'object' ? payload.models : {};
55
+ const chatItems = firstArray(models?.chat, payload?.chat_models, payload?.models_chat);
56
+ const imageItems = firstArray(models?.image, payload?.image_models, payload?.models_image);
57
+ return {
58
+ apiKey: normalizePlainObject(payload?.api_key),
59
+ user: normalizePlainObject(payload?.user),
60
+ group: normalizePlainObject(payload?.group),
61
+ protocols: normalizePlainObject(payload?.protocols),
62
+ defaultModel: String(payload?.default_model || payload?.defaultModel || models?.default_model || models?.defaultModel || '').trim(),
63
+ baseUrls: firstArray(payload?.base_urls, payload?.baseURLs),
64
+ clients: firstArray(payload?.clients),
65
+ models: normalizeModelItems(chatItems, { fallbackWhenEmpty: false }),
66
+ imageModels: normalizeModelItems(imageItems, {
67
+ fallbackWhenEmpty: false,
68
+ includeImageModels: true
69
+ }),
70
+ imageMCP: normalizePlainObject(payload?.image_mcp)
71
+ };
72
+ }
73
+
74
+ function normalizeModelItems(items, options = {}) {
44
75
  const result = new Map();
45
76
 
46
77
  for (const item of items) {
47
78
  const id = String(item.id || item.name || '').trim();
48
- if (!isConfigurableCodexModel(id, item)) continue;
79
+ if (!isConfigurableTextModel(id, item, options)) continue;
49
80
  const fallback = FALLBACK_MODELS[id] || {};
50
81
  const thinking = item.thinking && typeof item.thinking === 'object' ? item.thinking : {};
51
82
  const levels = Array.isArray(thinking.levels) ? thinking.levels : fallback.efforts;
83
+ const defaultEffort = String(thinking.default || '').trim().toLowerCase();
84
+ const efforts = normalizeEfforts(levels);
52
85
  result.set(id, {
53
86
  id,
54
87
  displayName: item.display_name || item.name || id,
55
88
  contextLength: Number(item.context_length || item.inputTokenLimit || fallback.contextLength || 400000),
56
89
  maxOutputTokens: Number(item.max_completion_tokens || item.outputTokenLimit || fallback.maxOutputTokens || 128000),
57
- efforts: normalizeEfforts(levels)
90
+ efforts: defaultEffort && efforts.includes(defaultEffort)
91
+ ? [defaultEffort, ...efforts.filter((effort) => effort !== defaultEffort)]
92
+ : efforts
58
93
  });
59
94
  }
60
95
 
61
- if (!result.size) {
96
+ if (!result.size && options.fallbackWhenEmpty !== false) {
62
97
  for (const model of Object.values(FALLBACK_MODELS)) {
63
98
  result.set(model.id, {
64
99
  id: model.id,
@@ -73,11 +108,60 @@ export function normalizeModels(payload) {
73
108
  return Array.from(result.values()).sort((a, b) => a.id.localeCompare(b.id));
74
109
  }
75
110
 
76
- function isConfigurableCodexModel(id, item) {
77
- if (!id || !id.startsWith('gpt-') || id.includes('image')) return false;
111
+ function isConfigurableTextModel(id, item, options = {}) {
112
+ if (!id) return false;
113
+ if (!options.includeImageModels && id.toLowerCase().includes('image')) return false;
78
114
  const owner = String(item?.owned_by || item?.provider || item?.type || '').trim().toLowerCase();
79
- if (!owner) return Object.prototype.hasOwnProperty.call(FALLBACK_MODELS, id);
80
- return owner === 'openai' || owner === 'codex' || owner === 'openai-compatible';
115
+ const knownTextPrefix = /^(gpt-|claude-|gemini-|doubao-|deepseek-|kimi-|qwen|glm-|moonshot-|yi-|baichuan-|step-|minimax-)/.test(id);
116
+ if (!owner) return knownTextPrefix || Object.prototype.hasOwnProperty.call(FALLBACK_MODELS, id);
117
+ return [
118
+ 'openai',
119
+ 'codex',
120
+ 'openai-compatible',
121
+ 'ark',
122
+ 'volcengine',
123
+ 'volc',
124
+ 'doubao',
125
+ 'bytedance',
126
+ 'deepseek',
127
+ 'kimi',
128
+ 'moonshot',
129
+ 'qwen',
130
+ 'aliyun',
131
+ 'zhipu',
132
+ 'glm',
133
+ 'baichuan',
134
+ 'stepfun',
135
+ 'minimax',
136
+ 'anthropic',
137
+ 'claude',
138
+ 'gemini',
139
+ 'google',
140
+ 'antigravity'
141
+ ].includes(owner);
142
+ }
143
+
144
+ export async function fetchCapabilities(baseUrl, apiKey, options = {}) {
145
+ let response;
146
+ try {
147
+ response = await fetch(`${baseUrl.replace(/\/$/, '')}/gpteam/config-capabilities`, {
148
+ signal: makeTimeoutSignal(options.timeoutMs || 15000),
149
+ headers: {
150
+ Authorization: `Bearer ${apiKey}`,
151
+ 'User-Agent': 'gpteam-api-config/0.1'
152
+ }
153
+ });
154
+ } catch (error) {
155
+ throw new Error(`/v1/gpteam/config-capabilities 请求失败:${formatNetworkError(error)}`);
156
+ }
157
+ if (response.status === 404 || response.status === 405) {
158
+ throw new CapabilityEndpointUnavailableError('/v1/gpteam/config-capabilities 未部署');
159
+ }
160
+ if (!response.ok) {
161
+ const detail = await readResponseError(response);
162
+ throw new Error(`/v1/gpteam/config-capabilities 返回 HTTP ${response.status}${detail ? `:${detail}` : ''}`);
163
+ }
164
+ return normalizeCapabilities(await response.json());
81
165
  }
82
166
 
83
167
  export async function fetchModels(baseUrl, apiKey, options = {}) {
@@ -108,13 +192,38 @@ export async function validateApiKey(nodes, apiKey) {
108
192
 
109
193
  const results = await Promise.all(candidates.map(async (node) => {
110
194
  try {
111
- return { ok: true, node, models: await fetchModels(node.baseUrl, apiKey) };
195
+ const baseUrl = nodeAPIBaseUrl(node);
196
+ try {
197
+ const capabilities = await fetchCapabilities(baseUrl, apiKey);
198
+ if (!capabilities.models.length) {
199
+ throw new Error('/v1/gpteam/config-capabilities 没有返回可配置模型');
200
+ }
201
+ return {
202
+ ok: true,
203
+ node,
204
+ ...capabilities,
205
+ capabilities
206
+ };
207
+ } catch (error) {
208
+ if (!error || error.capabilityEndpointUnavailable !== true) {
209
+ throw error;
210
+ }
211
+ }
212
+ return {
213
+ ok: true,
214
+ node,
215
+ models: await fetchModels(baseUrl, apiKey),
216
+ imageMCP: { enabled: false }
217
+ };
112
218
  } catch (error) {
113
219
  return { ok: false, node, error };
114
220
  }
115
221
  }));
116
- const success = results.find((item) => item.ok);
117
- if (success) return { node: success.node, models: success.models };
222
+ const success = results.find((item) => item.ok && item.capabilities) || results.find((item) => item.ok);
223
+ if (success) {
224
+ const { ok, ...result } = success;
225
+ return result;
226
+ }
118
227
 
119
228
  const detail = results
120
229
  .map((item) => `${item.node.label || item.node.id || item.node.baseUrl}: ${item.error && item.error.message ? item.error.message : item.error}`)
@@ -126,6 +235,17 @@ export function modelByID(models, id) {
126
235
  return models.find((model) => model.id === id) || models[0];
127
236
  }
128
237
 
238
+ function firstArray(...values) {
239
+ for (const value of values) {
240
+ if (Array.isArray(value)) return value;
241
+ }
242
+ return [];
243
+ }
244
+
245
+ function normalizePlainObject(value) {
246
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
247
+ }
248
+
129
249
  function normalizeEfforts(levels) {
130
250
  const out = [];
131
251
  for (const level of levels || []) {
package/lib/nodes.js CHANGED
@@ -3,22 +3,15 @@ export const INGRESS_NODES = [
3
3
  id: 'main',
4
4
  aliases: ['jp-direct'],
5
5
  label: '主入口',
6
- baseUrl: 'https://api.gpteamservices.com/v1',
6
+ baseUrl: 'https://api.gpteamservices.com',
7
7
  healthUrl: 'https://api.gpteamservices.com/api/health'
8
8
  },
9
9
  {
10
10
  id: 'jp',
11
11
  aliases: ['jp-split'],
12
12
  label: '日本入口',
13
- baseUrl: 'https://api-jp.gpteamservices.com/v1',
13
+ baseUrl: 'https://api-jp.gpteamservices.com',
14
14
  healthUrl: 'https://api-jp.gpteamservices.com/api/health'
15
- },
16
- {
17
- id: 'hk',
18
- aliases: ['hk-split'],
19
- label: '香港入口',
20
- baseUrl: 'https://api-hk.gpteamservices.com/v1',
21
- healthUrl: 'https://api-hk.gpteamservices.com/api/health'
22
15
  }
23
16
  ];
24
17
 
@@ -31,3 +24,36 @@ export function nodeMatchesID(node, id) {
31
24
  export function endpointRoot(baseUrl) {
32
25
  return String(baseUrl || '').replace(/\/v1\/?$/, '').replace(/\/$/, '');
33
26
  }
27
+
28
+ export function endpointAPIBase(baseUrl) {
29
+ const root = endpointRoot(baseUrl);
30
+ return root ? `${root}/v1` : '';
31
+ }
32
+
33
+ export function nodeAPIBaseUrl(node) {
34
+ if (!node) return '';
35
+ return endpointAPIBase(node.apiBaseUrl || node.baseUrl);
36
+ }
37
+
38
+ export function nodesFromCapabilities(capabilities, fallbackNodes = INGRESS_NODES) {
39
+ const entries = Array.isArray(capabilities?.baseUrls) ? capabilities.baseUrls : [];
40
+ const nodes = entries
41
+ .map((entry) => capabilityBaseURLToNode(entry))
42
+ .filter((node) => node && node.baseUrl);
43
+ return nodes.length ? nodes : fallbackNodes;
44
+ }
45
+
46
+ function capabilityBaseURLToNode(entry) {
47
+ if (!entry || typeof entry !== 'object') return null;
48
+ const id = String(entry.id || '').trim();
49
+ const baseUrl = endpointRoot(entry.url || entry.base_url || entry.baseUrl);
50
+ if (!id || !baseUrl) return null;
51
+ return {
52
+ id,
53
+ aliases: Array.isArray(entry.aliases) ? entry.aliases.map((item) => String(item)) : [],
54
+ label: String(entry.label || id),
55
+ baseUrl,
56
+ healthUrl: String(entry.health_url || entry.healthUrl || `${baseUrl}/api/health`),
57
+ recommended: Boolean(entry.recommended)
58
+ };
59
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gpteam",
3
- "version": "0.1.28",
4
- "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
3
+ "version": "0.1.30",
4
+ "description": "GPTEAM API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "gpteam": "bin/gpteam-api-config.js",