gpteam 0.1.27 → 0.1.29

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
@@ -6,13 +6,15 @@ Interactive GPTeam API client configurator.
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
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
- 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 explicitly disables WebSocket prewarm, 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`.
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
+
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`.
16
18
 
17
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`.
18
20
 
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,
@@ -41,6 +42,7 @@ export async function benchmarkNode(node, options) {
41
42
  export function summarizeNode(node, samples) {
42
43
  const successful = samples.filter((sample) => sample.ok);
43
44
  const streams = successful.map((sample) => sample.stream);
45
+ const allStreams = samples.map((sample) => sample.stream).filter(Boolean);
44
46
  const firstEvents = streams.map((item) => item.firstEventMs).filter(Number.isFinite);
45
47
  const totals = streams.map((item) => item.totalMs).filter(Number.isFinite);
46
48
  const summary = {
@@ -51,10 +53,10 @@ export function summarizeNode(node, samples) {
51
53
  totalMs: median(totals),
52
54
  tailFirstEventMs: percentile(firstEvents, 0.9),
53
55
  tailTotalMs: percentile(totals, 0.9),
54
- dnsMs: median(streams.map((item) => item.dnsMs).filter(Number.isFinite)),
55
- tcpMs: median(streams.map((item) => item.tcpMs).filter(Number.isFinite)),
56
- tlsMs: median(streams.map((item) => item.tlsMs).filter(Number.isFinite)),
57
- healthMs: median(successful.map((sample) => sample.health.totalMs).filter(Number.isFinite)),
56
+ dnsMs: median(allStreams.map((item) => item.dnsMs).filter(Number.isFinite)),
57
+ tcpMs: median(allStreams.map((item) => item.tcpMs).filter(Number.isFinite)),
58
+ tlsMs: median(allStreams.map((item) => item.tlsMs).filter(Number.isFinite)),
59
+ healthMs: median(samples.map((sample) => sample.health?.totalMs).filter(Number.isFinite)),
58
60
  error: samples.find((sample) => sample.error)?.error || ''
59
61
  };
60
62
  summary.experienceScore = scoreResult(summary);
@@ -157,16 +159,17 @@ function measureStream(baseUrl, options) {
157
159
  });
158
160
  response.on('end', () => {
159
161
  const semantic = inspectSSEBody(body);
160
- const ok = response.statusCode >= 200
161
- && response.statusCode < 300
162
+ const status = response.statusCode || 0;
163
+ const ok = status >= 200
164
+ && status < 300
162
165
  && semantic.ok
163
166
  && Number.isFinite(timings.firstEventMs);
164
167
  resolve({
165
168
  ok,
166
- status: response.statusCode,
169
+ status,
167
170
  ...timings,
168
171
  totalMs: performance.now() - started,
169
- error: ok ? '' : semantic.error || `stream HTTP ${response.statusCode}`
172
+ error: ok ? '' : formatStreamProbeError(status, response.headers, body, semantic.error)
170
173
  });
171
174
  });
172
175
  });
@@ -194,6 +197,40 @@ function measureStream(baseUrl, options) {
194
197
  });
195
198
  }
196
199
 
200
+ export function formatStreamProbeError(status, headers, body, semanticError) {
201
+ const statusCode = Number(status || 0);
202
+ const contentType = String(headers?.['content-type'] || headers?.['Content-Type'] || '').trim();
203
+ const detail = responseBodyDetail(body);
204
+ const parts = [];
205
+ if (statusCode < 200 || statusCode >= 300) {
206
+ parts.push(`stream HTTP ${statusCode}`);
207
+ } else if (semanticError === 'stream empty response' && detail) {
208
+ parts.push('stream non-SSE response');
209
+ } else {
210
+ parts.push(semanticError || `stream HTTP ${statusCode}`);
211
+ }
212
+ if (contentType) parts.push(`content-type ${contentType}`);
213
+ if (detail) parts.push(detail);
214
+ return parts.join(';');
215
+ }
216
+
217
+ function responseBodyDetail(body) {
218
+ const text = String(body || '').replace(/\s+/g, ' ').trim();
219
+ if (!text) return '';
220
+ const parsed = parseJSON(text);
221
+ const message = parsed?.error?.message || parsed?.message || parsed?.error;
222
+ if (message) return `body ${String(message).slice(0, 180)}`;
223
+ return `body ${text.slice(0, 180)}`;
224
+ }
225
+
226
+ function parseJSON(value) {
227
+ try {
228
+ return JSON.parse(value);
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
197
234
  function median(values) {
198
235
  if (!values.length) return NaN;
199
236
  const sorted = [...values].sort((a, b) => a - b);
package/lib/cli.js CHANGED
@@ -5,7 +5,7 @@ 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
7
  import { modelByID, validateApiKey } from './models.js';
8
- import { describeSplit, INGRESS_NODES } from './nodes.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,12 +25,14 @@ 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}`);
30
+ const ingressNodes = nodesFromCapabilities(validation);
31
+ printStatus(theme, '通过', formatValidationSummary(validation));
31
32
 
32
33
  printStep(theme, 2, 5, '选择客户端和模型');
33
- const client = await choose(rl, '请选择客户端类型', CLIENTS, args.client, theme);
34
+ const clientChoices = filterClientsForCapabilities(CLIENTS, validation.clients);
35
+ const client = await choose(rl, '请选择客户端类型', clientChoices, args.client, theme);
34
36
  await ensureSelectedClientInstalled(client.id, args, rl, theme);
35
37
  const models = validation.models;
36
38
  const model = await chooseModel(rl, models, args.model, theme);
@@ -42,7 +44,7 @@ export async function runCli(argv = []) {
42
44
  printHint(theme, '入口之间并行测速,单入口多轮顺序执行,避免同时打出过多真实请求。');
43
45
  printHint(theme, '综合推荐规则:成功率优先,其次按首包、完成、尾延迟和健康检查计算体验分,分数越低越好;完成最快会单独标出。');
44
46
  printHint(theme, `模型:${model.id},测速输出上限:${maxOutputTokens}`);
45
- const results = await benchmarkNodes(INGRESS_NODES, {
47
+ const results = await benchmarkNodes(ingressNodes, {
46
48
  apiKey,
47
49
  model: model.id,
48
50
  effort: effort.id,
@@ -63,7 +65,8 @@ export async function runCli(argv = []) {
63
65
  effort: effort.id,
64
66
  contextLength,
65
67
  maxOutputTokens: model.maxOutputTokens,
66
- node: selectedNode
68
+ node: selectedNode,
69
+ imageMCP: validation.imageMCP
67
70
  });
68
71
 
69
72
  console.log('');
@@ -71,7 +74,7 @@ export async function runCli(argv = []) {
71
74
  for (const filePath of written) console.log(`- ${filePath}`);
72
75
  console.log(`入口:${formatNodeLabel(selectedNode)}`);
73
76
  console.log(`地址:${selectedNode.baseUrl}`);
74
- if (['codex', 'opencode', 'claude-code'].includes(client.id)) {
77
+ if (['codex', 'opencode', 'claude-code'].includes(client.id) && validation.imageMCP?.enabled === true) {
75
78
  printHint(theme, '已写入 GPTeam Image MCP。客户端对话里需要生图或图生图时优先调用 create_image_job,再用 get_image_job_status 和 download_image_result 取结果,get_capabilities 可查看支持能力,MCP 使用专用环境变量读取 API key。');
76
79
  }
77
80
  } finally {
@@ -104,7 +107,7 @@ export function printResults(results, theme = createTheme()) {
104
107
  const recommended = results.find((item) => item.successRate > 0);
105
108
  const fastestTotal = findFastestTotalResult(results);
106
109
  const rows = results.map((item) => [
107
- `${item.node.label}(${describeSplit(item.node)})`,
110
+ formatNodeLabel(item.node),
108
111
  formatMs(item.dnsMs),
109
112
  formatMs(item.tcpMs),
110
113
  formatMs(item.tlsMs),
@@ -164,10 +167,11 @@ async function askContextLength(rl, model, preferred) {
164
167
 
165
168
  export async function chooseNode(rl, results, preferred, recommendedID, theme = createTheme()) {
166
169
  const nodes = results.map((item) => item.node);
167
- const preferredNode = nodes.find((node) => node.id === preferred);
170
+ const preferredNode = nodes.find((node) => nodeMatchesID(node, preferred));
168
171
  if (preferredNode) return preferredNode;
169
172
  if (recommendedID) {
170
- printStatus(theme, '综合推荐', recommendedID);
173
+ const recommendedNode = nodes.find((node) => nodeMatchesID(node, recommendedID));
174
+ printStatus(theme, '综合推荐', recommendedNode ? formatNodeLabel(recommendedNode) : recommendedID);
171
175
  }
172
176
  return (await choose(rl, '请选择最终写入的入口', nodes.map((node) => ({
173
177
  id: node.id,
@@ -222,8 +226,22 @@ export function formatModelLabel(model) {
222
226
  return `${model.id}(可配置上下文 ${context},输出上限 ${outputTokens})`;
223
227
  }
224
228
 
229
+ export function filterClientsForCapabilities(clients, allowedIDs) {
230
+ if (!Array.isArray(allowedIDs) || !allowedIDs.length) return clients;
231
+ const allowed = new Set(allowedIDs.map((id) => String(id)));
232
+ const filtered = clients.filter((client) => allowed.has(client.id));
233
+ return filtered.length ? filtered : clients;
234
+ }
235
+
236
+ function formatValidationSummary(validation) {
237
+ const group = validation.group && validation.group.name
238
+ ? `,分组:${validation.group.name}`
239
+ : '';
240
+ return `API key 可用,校验入口:${validation.node.label}${group}`;
241
+ }
242
+
225
243
  export function formatNodeLabel(node) {
226
- return `${node.label}(${describeSplit(node)})`;
244
+ return String(node?.label || node?.id || '');
227
245
  }
228
246
 
229
247
  export function assertNodeConfig(node) {
package/lib/config.js CHANGED
@@ -75,7 +75,7 @@ export function writeCodexConfig(settings) {
75
75
  managedRoot.join('\n'),
76
76
  rootLines.join('\n'),
77
77
  rest.join('\n'),
78
- managedImageMCP.join('\n'),
78
+ imageMCPEnabled(settings) ? managedImageMCP.join('\n') : '',
79
79
  managedProvider.join('\n')
80
80
  ]);
81
81
  assertNoDuplicateTomlKeys(next);
@@ -117,7 +117,11 @@ export function writeOpenCodeConfig(settings) {
117
117
  }
118
118
  };
119
119
  config.mcp = config.mcp && typeof config.mcp === 'object' ? config.mcp : {};
120
- config.mcp[IMAGE_MCP_ID] = openCodeImageMCPConfig(settings);
120
+ if (imageMCPEnabled(settings)) {
121
+ config.mcp[IMAGE_MCP_ID] = openCodeImageMCPConfig(settings);
122
+ } else {
123
+ delete config.mcp[IMAGE_MCP_ID];
124
+ }
121
125
  writeJSON(filePath, config);
122
126
  return [filePath];
123
127
  }
@@ -145,7 +149,11 @@ export function writeClaudeCodeConfig(settings) {
145
149
  mcpConfig.mcpServers = mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object'
146
150
  ? mcpConfig.mcpServers
147
151
  : {};
148
- mcpConfig.mcpServers[IMAGE_MCP_ID] = imageMCPServer(settings);
152
+ if (imageMCPEnabled(settings)) {
153
+ mcpConfig.mcpServers[IMAGE_MCP_ID] = imageMCPServer(settings);
154
+ } else {
155
+ delete mcpConfig.mcpServers[IMAGE_MCP_ID];
156
+ }
149
157
  writeJSON(mcpPath, mcpConfig);
150
158
  return [filePath, mcpPath];
151
159
  }
@@ -254,6 +262,10 @@ function openCodeImageMCPConfig(settings) {
254
262
  };
255
263
  }
256
264
 
265
+ function imageMCPEnabled(settings) {
266
+ return settings?.imageMCP?.enabled === true;
267
+ }
268
+
257
269
  function stripCodexManagedConfig(raw) {
258
270
  const rootLines = [];
259
271
  const rest = [];
package/lib/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PACKAGE_NAME = 'gpteam';
2
- export const PACKAGE_VERSION = '0.1.27';
2
+ export const PACKAGE_VERSION = '0.1.29';
3
3
 
4
4
  export function getHelpText() {
5
5
  return [
@@ -15,13 +15,13 @@ export function getHelpText() {
15
15
  ' --model <id> 预选模型,例如 gpt-5.5',
16
16
  ' --context <tokens> 预设可配置上下文长度',
17
17
  ' --effort <level> 按模型支持项预选,常见为 none / low / medium / high / xhigh',
18
- ' --node <id> jp-direct / jp-split / hk-split',
18
+ ' --node <id> main / jp(兼容旧参数 jp-direct / jp-split',
19
19
  ' --rounds <n> 每个入口测速轮数,默认 3',
20
20
  ' --max-output-tokens <n> 测速输出上限,默认 648',
21
21
  ' --install-client 客户端命令缺失时直接安装,不再二次确认',
22
22
  ' --help 显示帮助',
23
23
  ' --version 显示版本',
24
24
  '',
25
- '说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。选好客户端后会检测本机命令是否可用,缺失时提示安装。Windows 暂不支持自动配置 OpenClaw。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。Codex、OpenCode、Claude Code 会写入 GPTeam Image MCP,生图和图生图工具从 MCP env 读取 key,长时间任务优先用 create_image_job 异步任务,get_capabilities 可查看支持能力。'
25
+ '说明:输入 key 后按分组能力展示可用客户端和模型,再测速并写入配置;旧配置会先备份。Codex、OpenCode、Claude Code 会按权限写入 GPTeam Image MCP'
26
26
  ].join('\n');
27
27
  }
@@ -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
 
@@ -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,
@@ -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,4 +1,5 @@
1
1
  import { formatNetworkError } from './errors.js';
2
+ import { nodeAPIBaseUrl } from './nodes.js';
2
3
 
3
4
  export const FALLBACK_MODELS = {
4
5
  'gpt-5.2': {
@@ -39,26 +40,62 @@ export const FALLBACK_MODELS = {
39
40
  }
40
41
  };
41
42
 
42
- export function normalizeModels(payload) {
43
+ class CapabilityEndpointUnavailableError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'CapabilityEndpointUnavailableError';
47
+ this.capabilityEndpointUnavailable = true;
48
+ }
49
+ }
50
+
51
+ export function normalizeModels(payload, options = {}) {
43
52
  const items = Array.isArray(payload && payload.data) ? payload.data : [];
53
+ return normalizeModelItems(items, { fallbackWhenEmpty: options.fallbackWhenEmpty !== false });
54
+ }
55
+
56
+ export function normalizeCapabilities(payload) {
57
+ const models = payload && typeof payload === 'object' ? payload.models : {};
58
+ const chatItems = firstArray(models?.chat, payload?.chat_models, payload?.models_chat);
59
+ const imageItems = firstArray(models?.image, payload?.image_models, payload?.models_image);
60
+ return {
61
+ apiKey: normalizePlainObject(payload?.api_key),
62
+ user: normalizePlainObject(payload?.user),
63
+ group: normalizePlainObject(payload?.group),
64
+ protocols: normalizePlainObject(payload?.protocols),
65
+ baseUrls: firstArray(payload?.base_urls, payload?.baseURLs),
66
+ clients: firstArray(payload?.clients),
67
+ models: normalizeModelItems(chatItems, { fallbackWhenEmpty: false }),
68
+ imageModels: normalizeModelItems(imageItems, {
69
+ fallbackWhenEmpty: false,
70
+ includeImageModels: true
71
+ }),
72
+ imageMCP: normalizePlainObject(payload?.image_mcp)
73
+ };
74
+ }
75
+
76
+ function normalizeModelItems(items, options = {}) {
44
77
  const result = new Map();
45
78
 
46
79
  for (const item of items) {
47
80
  const id = String(item.id || item.name || '').trim();
48
- if (!isConfigurableCodexModel(id, item)) continue;
81
+ if (!isConfigurableTextModel(id, item, options)) continue;
49
82
  const fallback = FALLBACK_MODELS[id] || {};
50
83
  const thinking = item.thinking && typeof item.thinking === 'object' ? item.thinking : {};
51
84
  const levels = Array.isArray(thinking.levels) ? thinking.levels : fallback.efforts;
85
+ const defaultEffort = String(thinking.default || '').trim().toLowerCase();
86
+ const efforts = normalizeEfforts(levels);
52
87
  result.set(id, {
53
88
  id,
54
89
  displayName: item.display_name || item.name || id,
55
90
  contextLength: Number(item.context_length || item.inputTokenLimit || fallback.contextLength || 400000),
56
91
  maxOutputTokens: Number(item.max_completion_tokens || item.outputTokenLimit || fallback.maxOutputTokens || 128000),
57
- efforts: normalizeEfforts(levels)
92
+ efforts: defaultEffort && efforts.includes(defaultEffort)
93
+ ? [defaultEffort, ...efforts.filter((effort) => effort !== defaultEffort)]
94
+ : efforts
58
95
  });
59
96
  }
60
97
 
61
- if (!result.size) {
98
+ if (!result.size && options.fallbackWhenEmpty !== false) {
62
99
  for (const model of Object.values(FALLBACK_MODELS)) {
63
100
  result.set(model.id, {
64
101
  id: model.id,
@@ -73,11 +110,45 @@ export function normalizeModels(payload) {
73
110
  return Array.from(result.values()).sort((a, b) => a.id.localeCompare(b.id));
74
111
  }
75
112
 
76
- function isConfigurableCodexModel(id, item) {
77
- if (!id || !id.startsWith('gpt-') || id.includes('image')) return false;
113
+ function isConfigurableTextModel(id, item, options = {}) {
114
+ if (!id) return false;
115
+ if (!options.includeImageModels && id.toLowerCase().includes('image')) return false;
78
116
  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';
117
+ const knownTextPrefix = /^(gpt-|claude-|gemini-)/.test(id);
118
+ if (!owner) return knownTextPrefix || Object.prototype.hasOwnProperty.call(FALLBACK_MODELS, id);
119
+ return [
120
+ 'openai',
121
+ 'codex',
122
+ 'openai-compatible',
123
+ 'anthropic',
124
+ 'claude',
125
+ 'gemini',
126
+ 'google',
127
+ 'antigravity'
128
+ ].includes(owner);
129
+ }
130
+
131
+ export async function fetchCapabilities(baseUrl, apiKey, options = {}) {
132
+ let response;
133
+ try {
134
+ response = await fetch(`${baseUrl.replace(/\/$/, '')}/gpteam/config-capabilities`, {
135
+ signal: makeTimeoutSignal(options.timeoutMs || 15000),
136
+ headers: {
137
+ Authorization: `Bearer ${apiKey}`,
138
+ 'User-Agent': 'gpteam-api-config/0.1'
139
+ }
140
+ });
141
+ } catch (error) {
142
+ throw new Error(`/v1/gpteam/config-capabilities 请求失败:${formatNetworkError(error)}`);
143
+ }
144
+ if (response.status === 404 || response.status === 405) {
145
+ throw new CapabilityEndpointUnavailableError('/v1/gpteam/config-capabilities 未部署');
146
+ }
147
+ if (!response.ok) {
148
+ const detail = await readResponseError(response);
149
+ throw new Error(`/v1/gpteam/config-capabilities 返回 HTTP ${response.status}${detail ? `:${detail}` : ''}`);
150
+ }
151
+ return normalizeCapabilities(await response.json());
81
152
  }
82
153
 
83
154
  export async function fetchModels(baseUrl, apiKey, options = {}) {
@@ -108,13 +179,38 @@ export async function validateApiKey(nodes, apiKey) {
108
179
 
109
180
  const results = await Promise.all(candidates.map(async (node) => {
110
181
  try {
111
- return { ok: true, node, models: await fetchModels(node.baseUrl, apiKey) };
182
+ const baseUrl = nodeAPIBaseUrl(node);
183
+ try {
184
+ const capabilities = await fetchCapabilities(baseUrl, apiKey);
185
+ if (!capabilities.models.length) {
186
+ throw new Error('/v1/gpteam/config-capabilities 没有返回可配置模型');
187
+ }
188
+ return {
189
+ ok: true,
190
+ node,
191
+ ...capabilities,
192
+ capabilities
193
+ };
194
+ } catch (error) {
195
+ if (!error || error.capabilityEndpointUnavailable !== true) {
196
+ throw error;
197
+ }
198
+ }
199
+ return {
200
+ ok: true,
201
+ node,
202
+ models: await fetchModels(baseUrl, apiKey),
203
+ imageMCP: { enabled: false }
204
+ };
112
205
  } catch (error) {
113
206
  return { ok: false, node, error };
114
207
  }
115
208
  }));
116
- const success = results.find((item) => item.ok);
117
- if (success) return { node: success.node, models: success.models };
209
+ const success = results.find((item) => item.ok && item.capabilities) || results.find((item) => item.ok);
210
+ if (success) {
211
+ const { ok, ...result } = success;
212
+ return result;
213
+ }
118
214
 
119
215
  const detail = results
120
216
  .map((item) => `${item.node.label || item.node.id || item.node.baseUrl}: ${item.error && item.error.message ? item.error.message : item.error}`)
@@ -126,6 +222,17 @@ export function modelByID(models, id) {
126
222
  return models.find((model) => model.id === id) || models[0];
127
223
  }
128
224
 
225
+ function firstArray(...values) {
226
+ for (const value of values) {
227
+ if (Array.isArray(value)) return value;
228
+ }
229
+ return [];
230
+ }
231
+
232
+ function normalizePlainObject(value) {
233
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
234
+ }
235
+
129
236
  function normalizeEfforts(levels) {
130
237
  const out = [];
131
238
  for (const level of levels || []) {
package/lib/nodes.js CHANGED
@@ -1,34 +1,59 @@
1
1
  export const INGRESS_NODES = [
2
2
  {
3
- id: 'jp-direct',
4
- label: '直连',
5
- region: 'JP',
6
- split: false,
7
- baseUrl: 'https://api.gpteamservices.com/v1',
3
+ id: 'main',
4
+ aliases: ['jp-direct'],
5
+ label: '主入口',
6
+ baseUrl: 'https://api.gpteamservices.com',
8
7
  healthUrl: 'https://api.gpteamservices.com/api/health'
9
8
  },
10
9
  {
11
- id: 'jp-split',
10
+ id: 'jp',
11
+ aliases: ['jp-split'],
12
12
  label: '日本入口',
13
- region: 'JP',
14
- split: true,
15
- baseUrl: 'https://api-jp.gpteamservices.com/v1',
13
+ baseUrl: 'https://api-jp.gpteamservices.com',
16
14
  healthUrl: 'https://api-jp.gpteamservices.com/api/health'
17
- },
18
- {
19
- id: 'hk-split',
20
- label: '香港入口',
21
- region: 'HK',
22
- split: true,
23
- baseUrl: 'https://api-hk.gpteamservices.com/v1',
24
- healthUrl: 'https://api-hk.gpteamservices.com/api/health'
25
15
  }
26
16
  ];
27
17
 
28
- export function describeSplit(node) {
29
- return node.split ? '分流' : '不分流';
18
+ export function nodeMatchesID(node, id) {
19
+ const value = String(id || '').trim();
20
+ if (!value) return false;
21
+ return node.id === value || (Array.isArray(node.aliases) && node.aliases.includes(value));
30
22
  }
31
23
 
32
24
  export function endpointRoot(baseUrl) {
33
25
  return String(baseUrl || '').replace(/\/v1\/?$/, '').replace(/\/$/, '');
34
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,6 +1,6 @@
1
1
  {
2
2
  "name": "gpteam",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {