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 +4 -2
- package/bin/gpteam-image-mcp.js +0 -0
- package/lib/bench.js +46 -9
- package/lib/cli.js +29 -11
- package/lib/config.js +15 -3
- package/lib/help.js +3 -3
- package/lib/image-mcp/image.js +2 -2
- package/lib/image-mcp/server.js +4 -4
- package/lib/models.js +118 -11
- package/lib/nodes.js +44 -19
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
|
package/bin/gpteam-image-mcp.js
CHANGED
|
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
|
|
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(
|
|
55
|
-
tcpMs: median(
|
|
56
|
-
tlsMs: median(
|
|
57
|
-
healthMs: median(
|
|
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
|
|
161
|
-
|
|
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
|
|
169
|
+
status,
|
|
167
170
|
...timings,
|
|
168
171
|
totalMs: performance.now() - started,
|
|
169
|
-
error: ok ? '' :
|
|
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 {
|
|
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', '
|
|
28
|
+
printStep(theme, 1, 5, '校验 API key', '读取 Key 分组能力,校验通过后才继续。');
|
|
29
29
|
const validation = await validateApiKey(INGRESS_NODES, apiKey);
|
|
30
|
-
|
|
30
|
+
const ingressNodes = nodesFromCapabilities(validation);
|
|
31
|
+
printStatus(theme, '通过', formatValidationSummary(validation));
|
|
31
32
|
|
|
32
33
|
printStep(theme, 2, 5, '选择客户端和模型');
|
|
33
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
170
|
+
const preferredNode = nodes.find((node) => nodeMatchesID(node, preferred));
|
|
168
171
|
if (preferredNode) return preferredNode;
|
|
169
172
|
if (recommendedID) {
|
|
170
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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>
|
|
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
|
|
25
|
+
'说明:输入 key 后按分组能力展示可用客户端和模型,再测速并写入配置;旧配置会先备份。Codex、OpenCode、Claude Code 会按权限写入 GPTeam Image MCP。'
|
|
26
26
|
].join('\n');
|
|
27
27
|
}
|
package/lib/image-mcp/image.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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,
|
package/lib/image-mcp/server.js
CHANGED
|
@@ -238,13 +238,13 @@ function downloadSchema() {
|
|
|
238
238
|
const schema = jobIDSchema();
|
|
239
239
|
schema.properties.metadata_only = {
|
|
240
240
|
type: 'boolean',
|
|
241
|
-
description: '
|
|
242
|
-
default:
|
|
241
|
+
description: '只返回文件路径和元数据,不返回 MCP 图片内容。默认 true,避免大图进入上下文触发频繁 compact。',
|
|
242
|
+
default: true
|
|
243
243
|
};
|
|
244
244
|
schema.properties.include_image = {
|
|
245
245
|
type: 'boolean',
|
|
246
|
-
description: '
|
|
247
|
-
default:
|
|
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
|
-
|
|
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 (!
|
|
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:
|
|
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
|
|
77
|
-
if (!id
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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)
|
|
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: '
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
10
|
+
id: 'jp',
|
|
11
|
+
aliases: ['jp-split'],
|
|
12
12
|
label: '日本入口',
|
|
13
|
-
|
|
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
|
|
29
|
-
|
|
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
|
+
}
|