gpteam 0.1.1 → 0.1.3
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 +3 -1
- package/lib/bench.js +5 -5
- 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,9 @@ 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.
|
|
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
|
+
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
12
|
|
|
11
13
|
Supported clients:
|
|
12
14
|
|
package/lib/bench.js
CHANGED
|
@@ -5,14 +5,14 @@ import { inspectSSEBody } from './sse.js';
|
|
|
5
5
|
|
|
6
6
|
export async function benchmarkNodes(nodes, options) {
|
|
7
7
|
const rounds = options.rounds || 3;
|
|
8
|
-
const
|
|
9
|
-
|
|
8
|
+
const runBenchmark = options.benchmarkNode || benchmarkNode;
|
|
9
|
+
const results = await Promise.all(nodes.map(async (node) => {
|
|
10
10
|
const samples = [];
|
|
11
11
|
for (let index = 0; index < rounds; index += 1) {
|
|
12
|
-
samples.push(await
|
|
12
|
+
samples.push(await runBenchmark(node, options));
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
}
|
|
14
|
+
return summarizeNode(node, samples);
|
|
15
|
+
}));
|
|
16
16
|
return results.sort((a, b) => scoreResult(a) - scoreResult(b));
|
|
17
17
|
}
|
|
18
18
|
|
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,14 +23,18 @@ 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);
|
|
31
34
|
const maxOutputTokens = Number(args.maxOutputTokens || 648);
|
|
32
35
|
|
|
33
36
|
console.log('\n开始真实测速:GET /api/health + POST /v1/responses stream=true');
|
|
37
|
+
console.log('测速会按入口并行执行,每个入口内部仍按轮次顺序执行,避免同时打出过多真实请求。');
|
|
34
38
|
console.log(`模型:${model.id},测速输出上限:${maxOutputTokens}`);
|
|
35
39
|
const results = await benchmarkNodes(INGRESS_NODES, {
|
|
36
40
|
apiKey,
|
|
@@ -94,26 +98,16 @@ export function printResults(results) {
|
|
|
94
98
|
formatMs(item.totalMs),
|
|
95
99
|
`${Math.round(item.successRate * 100)}%`,
|
|
96
100
|
formatMs(item.healthMs),
|
|
97
|
-
item === recommended ? '推荐' : '-'
|
|
101
|
+
item === recommended ? '推荐' : '-',
|
|
102
|
+
item.error || '-'
|
|
98
103
|
]);
|
|
99
|
-
const header = ['节点', 'DNS', 'TCP', 'TLS', '首包', '完成', '成功率', '健康检查', '推荐'];
|
|
104
|
+
const header = ['节点', 'DNS', 'TCP', 'TLS', '首包', '完成', '成功率', '健康检查', '推荐', '错误'];
|
|
100
105
|
const widths = header.map((name, i) => Math.max(displayWidth(name), ...rows.map((row) => displayWidth(row[i]))) + 2);
|
|
101
106
|
console.log('');
|
|
102
107
|
console.log(formatRow(header, widths));
|
|
103
108
|
for (const row of rows) console.log(formatRow(row, widths));
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
async function loadModels(apiKey) {
|
|
107
|
-
for (const node of INGRESS_NODES) {
|
|
108
|
-
try {
|
|
109
|
-
return await fetchModels(node.baseUrl, apiKey);
|
|
110
|
-
} catch {
|
|
111
|
-
// 继续尝试下一个入口,全部失败时用本地兜底模型表。
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
return normalizeModels({ data: [] });
|
|
115
|
-
}
|
|
116
|
-
|
|
117
111
|
async function chooseModel(rl, models, preferred) {
|
|
118
112
|
const selected = preferred ? modelByID(models, preferred) : null;
|
|
119
113
|
if (selected) return selected;
|
|
@@ -129,7 +123,7 @@ async function chooseModel(rl, models, preferred) {
|
|
|
129
123
|
async function askContextLength(rl, model, preferred) {
|
|
130
124
|
const max = Number(model.contextLength || 400000);
|
|
131
125
|
if (preferred) return clamp(Number(preferred), 1, max);
|
|
132
|
-
const answer = await rl.question(
|
|
126
|
+
const answer = await rl.question(`请输入上下文窗口(最大 ${max},输出上限 ${model.maxOutputTokens},默认 ${max},回车即选择默认):`);
|
|
133
127
|
return clamp(Number(answer || max), 1, max);
|
|
134
128
|
}
|
|
135
129
|
|
|
@@ -186,7 +180,7 @@ function clamp(value, min, max) {
|
|
|
186
180
|
export function formatModelLabel(model) {
|
|
187
181
|
const context = Number(model.contextLength || 0);
|
|
188
182
|
const outputTokens = Number(model.maxOutputTokens || 0);
|
|
189
|
-
return `${model.id}
|
|
183
|
+
return `${model.id}(上下文窗口 ${context},输出上限 ${outputTokens})`;
|
|
190
184
|
}
|
|
191
185
|
|
|
192
186
|
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.3';
|
|
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
|
+
}
|