gpteam 0.1.2 → 0.1.4
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 +5 -1
- package/lib/bench.js +12 -4
- package/lib/cli.js +11 -17
- package/lib/config.js +103 -20
- package/lib/help.js +3 -3
- package/lib/models.js +47 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,11 @@ Interactive GPTeam API client configurator.
|
|
|
6
6
|
npx gpteam
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
The CLI asks for an API key, detects available models, benchmarks all production ingress endpoints with real API requests, then backs up old files and writes the selected client configuration. Ingress endpoints are benchmarked in parallel, while rounds for the same endpoint remain sequential to keep real API request pressure bounded.
|
|
9
|
+
The CLI asks for an API key, validates it with `/v1/models`, 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. Ingress endpoints are benchmarked in parallel, while rounds for the same endpoint remain sequential to keep real API request pressure bounded. Failed benchmark rows show their error reason in the result table.
|
|
10
|
+
|
|
11
|
+
Recommendation order is deterministic: success rate first, then a latency score using first SSE event, total completion time, and health-check time. 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.
|
|
12
|
+
|
|
13
|
+
Codex config writing follows the same safety pattern as cc-switch: keep top-level fields separate from provider tables, preserve unrelated sections such as MCP servers, and stop before writing if the generated TOML would contain duplicate keys.
|
|
10
14
|
|
|
11
15
|
Supported clients:
|
|
12
16
|
|
package/lib/bench.js
CHANGED
|
@@ -13,7 +13,7 @@ export async function benchmarkNodes(nodes, options) {
|
|
|
13
13
|
}
|
|
14
14
|
return summarizeNode(node, samples);
|
|
15
15
|
}));
|
|
16
|
-
return results.sort(
|
|
16
|
+
return results.sort(compareResults);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export async function benchmarkNode(node, options) {
|
|
@@ -50,9 +50,17 @@ export function formatMs(value) {
|
|
|
50
50
|
return `${(value / 1000).toFixed(2)}s`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
function compareResults(a, b) {
|
|
54
|
+
const successDelta = b.successRate - a.successRate;
|
|
55
|
+
if (successDelta !== 0) return successDelta;
|
|
56
|
+
return scoreLatency(a) - scoreLatency(b);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function scoreLatency(result) {
|
|
60
|
+
const firstEventMs = Number.isFinite(result.firstEventMs) ? result.firstEventMs : 999999;
|
|
61
|
+
const totalMs = Number.isFinite(result.totalMs) ? result.totalMs : 999999;
|
|
62
|
+
const healthMs = Number.isFinite(result.healthMs) ? result.healthMs : 999999;
|
|
63
|
+
return firstEventMs * 0.6 + totalMs * 0.35 + healthMs * 0.05;
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
async function measureHealth(url) {
|
package/lib/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { stdin as input, stdout as output } from 'node:process';
|
|
|
3
3
|
import { benchmarkNodes, formatMs } from './bench.js';
|
|
4
4
|
import { CLIENTS, writeClientConfig } from './config.js';
|
|
5
5
|
import { getHelpText, PACKAGE_NAME, PACKAGE_VERSION } from './help.js';
|
|
6
|
-
import {
|
|
6
|
+
import { modelByID, validateApiKey } from './models.js';
|
|
7
7
|
import { describeSplit, INGRESS_NODES } from './nodes.js';
|
|
8
8
|
|
|
9
9
|
export async function runCli(argv = []) {
|
|
@@ -23,8 +23,11 @@ export async function runCli(argv = []) {
|
|
|
23
23
|
console.log('接下来会用你的 key 跑几次真实请求测速,然后把选中的入口写进客户端配置。原来的配置会先备份。\n');
|
|
24
24
|
|
|
25
25
|
const apiKey = args.key || args.apiKey || await askRequired(rl, '请输入 API key:');
|
|
26
|
+
console.log('\n正在校验 API key,会请求 /v1/models。校验通过后才会继续。');
|
|
27
|
+
const validation = await validateApiKey(INGRESS_NODES, apiKey);
|
|
28
|
+
console.log(`API key 校验通过:${validation.node.label}`);
|
|
26
29
|
const client = await choose(rl, '请选择客户端类型', CLIENTS, args.client);
|
|
27
|
-
const models =
|
|
30
|
+
const models = validation.models;
|
|
28
31
|
const model = await chooseModel(rl, models, args.model);
|
|
29
32
|
const contextLength = await askContextLength(rl, model, args.context);
|
|
30
33
|
const effort = await choose(rl, '请选择思考深度', model.efforts.map((id) => ({ id, label: id })), args.effort);
|
|
@@ -32,6 +35,7 @@ export async function runCli(argv = []) {
|
|
|
32
35
|
|
|
33
36
|
console.log('\n开始真实测速:GET /api/health + POST /v1/responses stream=true');
|
|
34
37
|
console.log('测速会按入口并行执行,每个入口内部仍按轮次顺序执行,避免同时打出过多真实请求。');
|
|
38
|
+
console.log('推荐规则:成功率优先,其次按首包、完成、健康检查的综合延迟排序。');
|
|
35
39
|
console.log(`模型:${model.id},测速输出上限:${maxOutputTokens}`);
|
|
36
40
|
const results = await benchmarkNodes(INGRESS_NODES, {
|
|
37
41
|
apiKey,
|
|
@@ -95,26 +99,16 @@ export function printResults(results) {
|
|
|
95
99
|
formatMs(item.totalMs),
|
|
96
100
|
`${Math.round(item.successRate * 100)}%`,
|
|
97
101
|
formatMs(item.healthMs),
|
|
98
|
-
item === recommended ? '推荐' : '-'
|
|
102
|
+
item === recommended ? '推荐' : '-',
|
|
103
|
+
item.error || '-'
|
|
99
104
|
]);
|
|
100
|
-
const header = ['节点', 'DNS', 'TCP', 'TLS', '首包', '完成', '成功率', '健康检查', '推荐'];
|
|
105
|
+
const header = ['节点', 'DNS', 'TCP', 'TLS', '首包', '完成', '成功率', '健康检查', '推荐', '错误'];
|
|
101
106
|
const widths = header.map((name, i) => Math.max(displayWidth(name), ...rows.map((row) => displayWidth(row[i]))) + 2);
|
|
102
107
|
console.log('');
|
|
103
108
|
console.log(formatRow(header, widths));
|
|
104
109
|
for (const row of rows) console.log(formatRow(row, widths));
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
async function loadModels(apiKey) {
|
|
108
|
-
for (const node of INGRESS_NODES) {
|
|
109
|
-
try {
|
|
110
|
-
return await fetchModels(node.baseUrl, apiKey);
|
|
111
|
-
} catch {
|
|
112
|
-
// 继续尝试下一个入口,全部失败时用本地兜底模型表。
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
return normalizeModels({ data: [] });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
112
|
async function chooseModel(rl, models, preferred) {
|
|
119
113
|
const selected = preferred ? modelByID(models, preferred) : null;
|
|
120
114
|
if (selected) return selected;
|
|
@@ -130,7 +124,7 @@ async function chooseModel(rl, models, preferred) {
|
|
|
130
124
|
async function askContextLength(rl, model, preferred) {
|
|
131
125
|
const max = Number(model.contextLength || 400000);
|
|
132
126
|
if (preferred) return clamp(Number(preferred), 1, max);
|
|
133
|
-
const answer = await rl.question(
|
|
127
|
+
const answer = await rl.question(`请输入可配置上下文长度(最大 ${max},输出上限 ${model.maxOutputTokens},默认 ${max},回车即选择默认):`);
|
|
134
128
|
return clamp(Number(answer || max), 1, max);
|
|
135
129
|
}
|
|
136
130
|
|
|
@@ -187,7 +181,7 @@ function clamp(value, min, max) {
|
|
|
187
181
|
export function formatModelLabel(model) {
|
|
188
182
|
const context = Number(model.contextLength || 0);
|
|
189
183
|
const outputTokens = Number(model.maxOutputTokens || 0);
|
|
190
|
-
return `${model.id}
|
|
184
|
+
return `${model.id}(可配置上下文 ${context},输出上限 ${outputTokens})`;
|
|
191
185
|
}
|
|
192
186
|
|
|
193
187
|
export function formatNodeLabel(node) {
|
package/lib/config.js
CHANGED
|
@@ -27,27 +27,31 @@ export function writeCodexConfig(settings) {
|
|
|
27
27
|
backupIfExists(authPath);
|
|
28
28
|
|
|
29
29
|
const raw = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
|
|
30
|
-
const
|
|
31
|
-
const
|
|
30
|
+
const { rootLines, rest } = stripCodexManagedConfig(raw);
|
|
31
|
+
const managedRoot = [
|
|
32
32
|
`model = ${tomlString(settings.model)}`,
|
|
33
33
|
`model_provider = "gpteam"`,
|
|
34
34
|
`model_context_window = ${Number(settings.contextLength)}`,
|
|
35
35
|
`model_reasoning_effort = ${tomlString(settings.effort)}`,
|
|
36
|
-
'disable_response_storage = true'
|
|
37
|
-
|
|
36
|
+
'disable_response_storage = true'
|
|
37
|
+
];
|
|
38
|
+
const managedProvider = [
|
|
38
39
|
'[model_providers.gpteam]',
|
|
39
40
|
'name = "gpteam"',
|
|
40
41
|
`base_url = ${tomlString(settings.node.baseUrl)}`,
|
|
41
42
|
'wire_api = "responses"',
|
|
42
43
|
'requires_openai_auth = true',
|
|
43
44
|
'supports_websockets = false'
|
|
44
|
-
]
|
|
45
|
-
const next =
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
];
|
|
46
|
+
const next = joinTomlSections([
|
|
47
|
+
managedRoot.join('\n'),
|
|
48
|
+
rootLines.join('\n'),
|
|
49
|
+
rest.join('\n'),
|
|
50
|
+
managedProvider.join('\n')
|
|
51
|
+
]);
|
|
52
|
+
assertNoDuplicateTomlKeys(next);
|
|
53
|
+
writeTextAtomic(configPath, next, 0o600);
|
|
54
|
+
writeTextAtomic(authPath, `${JSON.stringify({ OPENAI_API_KEY: settings.apiKey }, null, 2)}\n`, 0o600);
|
|
51
55
|
return [configPath, authPath];
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -100,7 +104,7 @@ export function writeClaudeCodeEnv(settings) {
|
|
|
100
104
|
'',
|
|
101
105
|
'# 使用方式:source ~/.gpteam/claude-code.env'
|
|
102
106
|
];
|
|
103
|
-
|
|
107
|
+
writeTextAtomic(filePath, `${lines.join('\n')}\n`, 0o600);
|
|
104
108
|
return [filePath];
|
|
105
109
|
}
|
|
106
110
|
|
|
@@ -135,13 +139,81 @@ export function writeOpenClawConfig(settings) {
|
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
function stripCodexManagedConfig(raw) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
const rootLines = [];
|
|
143
|
+
const rest = [];
|
|
144
|
+
let inManagedProvider = false;
|
|
145
|
+
|
|
146
|
+
for (const line of String(raw || '').split(/\r?\n/)) {
|
|
147
|
+
if (isTableHeader(line)) {
|
|
148
|
+
inManagedProvider = isGpteamProviderHeader(line);
|
|
149
|
+
if (!inManagedProvider) rest.push(line);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (isManagedRootLine(line)) continue;
|
|
153
|
+
if (inManagedProvider) {
|
|
154
|
+
if (isManagedProviderLine(line)) continue;
|
|
155
|
+
if (isSalvageableRootLine(line)) rootLines.push(line);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
rest.push(line);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
rootLines: trimEmptyEdges(rootLines),
|
|
163
|
+
rest: trimEmptyEdges(rest)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function joinTomlSections(sections) {
|
|
168
|
+
return sections.map((section) => String(section || '').trim()).filter(Boolean).join('\n\n') + '\n';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assertNoDuplicateTomlKeys(text) {
|
|
172
|
+
const seen = new Map();
|
|
173
|
+
let section = '<root>';
|
|
174
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
175
|
+
const table = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
176
|
+
if (table) {
|
|
177
|
+
section = table[1];
|
|
178
|
+
if (!seen.has(section)) seen.set(section, new Set());
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const assignment = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
|
|
182
|
+
if (!assignment) continue;
|
|
183
|
+
const keys = seen.get(section) || new Set();
|
|
184
|
+
if (keys.has(assignment[1])) {
|
|
185
|
+
throw new Error(`生成的 Codex 配置存在重复字段:${section}.${assignment[1]},已停止写入`);
|
|
186
|
+
}
|
|
187
|
+
keys.add(assignment[1]);
|
|
188
|
+
seen.set(section, keys);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function trimEmptyEdges(lines) {
|
|
193
|
+
const out = [...lines];
|
|
194
|
+
while (out.length && !out[0].trim()) out.shift();
|
|
195
|
+
while (out.length && !out[out.length - 1].trim()) out.pop();
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function isTableHeader(line) {
|
|
200
|
+
return /^\s*\[[^\]]+\]\s*$/.test(line);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isGpteamProviderHeader(line) {
|
|
204
|
+
return /^\s*\[model_providers\.gpteam\]\s*$/.test(line);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isManagedRootLine(line) {
|
|
208
|
+
return /^\s*(model|model_provider|model_context_window|model_reasoning_effort|disable_response_storage)\s*=/.test(line);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isManagedProviderLine(line) {
|
|
212
|
+
return /^\s*(name|base_url|wire_api|requires_openai_auth|supports_websockets)\s*=/.test(line);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isSalvageableRootLine(line) {
|
|
216
|
+
return /^\s*(js_repl_node_path|preferred_auth_method|personality|plan_mode_reasoning_effort|service_tier)\s*=/.test(line);
|
|
145
217
|
}
|
|
146
218
|
|
|
147
219
|
function backupIfExists(filePath) {
|
|
@@ -150,6 +222,17 @@ function backupIfExists(filePath) {
|
|
|
150
222
|
fs.copyFileSync(filePath, `${filePath}.bak-${stamp}`);
|
|
151
223
|
}
|
|
152
224
|
|
|
225
|
+
function writeTextAtomic(filePath, text, mode) {
|
|
226
|
+
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
227
|
+
fs.writeFileSync(tmp, text, { encoding: 'utf8', mode });
|
|
228
|
+
fs.renameSync(tmp, filePath);
|
|
229
|
+
try {
|
|
230
|
+
fs.chmodSync(filePath, mode);
|
|
231
|
+
} catch {
|
|
232
|
+
// Windows 上 chmod 语义有限,写入成功优先。
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
153
236
|
function readJSON(filePath, fallback) {
|
|
154
237
|
if (!fs.existsSync(filePath)) return fallback;
|
|
155
238
|
try {
|
|
@@ -160,7 +243,7 @@ function readJSON(filePath, fallback) {
|
|
|
160
243
|
}
|
|
161
244
|
|
|
162
245
|
function writeJSON(filePath, value) {
|
|
163
|
-
|
|
246
|
+
writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`, 0o600);
|
|
164
247
|
}
|
|
165
248
|
|
|
166
249
|
function tomlString(value) {
|
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.4';
|
|
3
3
|
|
|
4
4
|
export function getHelpText() {
|
|
5
5
|
return [
|
|
@@ -13,7 +13,7 @@ export function getHelpText() {
|
|
|
13
13
|
' --api-key <key> 预填 API key',
|
|
14
14
|
' --client <id> codex / opencode / claude-code / openclaw',
|
|
15
15
|
' --model <id> 预选模型,例如 gpt-5.5',
|
|
16
|
-
' --context <tokens>
|
|
16
|
+
' --context <tokens> 预设可配置上下文长度',
|
|
17
17
|
' --effort <level> 按模型支持项预选,常见为 none / low / medium / high / xhigh',
|
|
18
18
|
' --node <id> jp-direct / jp-split / hk-split / us-split',
|
|
19
19
|
' --rounds <n> 每个入口测速轮数,默认 3',
|
|
@@ -21,6 +21,6 @@ export function getHelpText() {
|
|
|
21
21
|
' --help 显示帮助',
|
|
22
22
|
' --version 显示版本',
|
|
23
23
|
'',
|
|
24
|
-
'
|
|
24
|
+
'说明:输入 key 后会先请求 /v1/models 校验,通过后才继续。测速会请求 GET /api/health 和流式 POST /v1/responses。入口之间并行测速,写新配置前会先备份旧配置。'
|
|
25
25
|
].join('\n');
|
|
26
26
|
}
|
package/lib/models.js
CHANGED
|
@@ -72,19 +72,43 @@ export function normalizeModels(payload) {
|
|
|
72
72
|
return Array.from(result.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export async function fetchModels(baseUrl, apiKey) {
|
|
75
|
+
export async function fetchModels(baseUrl, apiKey, options = {}) {
|
|
76
76
|
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/models`, {
|
|
77
|
+
signal: makeTimeoutSignal(options.timeoutMs || 15000),
|
|
77
78
|
headers: {
|
|
78
79
|
Authorization: `Bearer ${apiKey}`,
|
|
79
80
|
'User-Agent': 'gpteam-api-config/0.1'
|
|
80
81
|
}
|
|
81
82
|
});
|
|
82
83
|
if (!response.ok) {
|
|
83
|
-
|
|
84
|
+
const detail = await readResponseError(response);
|
|
85
|
+
throw new Error(`/v1/models 返回 HTTP ${response.status}${detail ? `:${detail}` : ''}`);
|
|
84
86
|
}
|
|
85
87
|
return normalizeModels(await response.json());
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
export async function validateApiKey(nodes, apiKey) {
|
|
91
|
+
const candidates = (nodes || []).filter((node) => node && node.baseUrl);
|
|
92
|
+
if (!candidates.length) {
|
|
93
|
+
throw new Error('API key 校验失败:没有可用入口');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const results = await Promise.all(candidates.map(async (node) => {
|
|
97
|
+
try {
|
|
98
|
+
return { ok: true, node, models: await fetchModels(node.baseUrl, apiKey) };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return { ok: false, node, error };
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
const success = results.find((item) => item.ok);
|
|
104
|
+
if (success) return { node: success.node, models: success.models };
|
|
105
|
+
|
|
106
|
+
const detail = results
|
|
107
|
+
.map((item) => `${item.node.label || item.node.id || item.node.baseUrl}: ${item.error && item.error.message ? item.error.message : item.error}`)
|
|
108
|
+
.join(';');
|
|
109
|
+
throw new Error(`API key 校验失败:${detail}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
88
112
|
export function modelByID(models, id) {
|
|
89
113
|
return models.find((model) => model.id === id) || models[0];
|
|
90
114
|
}
|
|
@@ -98,3 +122,24 @@ function normalizeEfforts(levels) {
|
|
|
98
122
|
}
|
|
99
123
|
return out.length ? out : ['medium'];
|
|
100
124
|
}
|
|
125
|
+
|
|
126
|
+
function makeTimeoutSignal(timeoutMs) {
|
|
127
|
+
return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
|
128
|
+
? AbortSignal.timeout(timeoutMs)
|
|
129
|
+
: undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readResponseError(response) {
|
|
133
|
+
try {
|
|
134
|
+
const text = await response.text();
|
|
135
|
+
if (!text) return '';
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(text);
|
|
138
|
+
return parsed && parsed.error && parsed.error.message ? String(parsed.error.message) : text.slice(0, 240);
|
|
139
|
+
} catch {
|
|
140
|
+
return text.slice(0, 240);
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
}
|