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 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 results = [];
9
- for (const node of nodes) {
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 benchmarkNode(node, options));
12
+ samples.push(await runBenchmark(node, options));
13
13
  }
14
- results.push(summarizeNode(node, samples));
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 { fetchModels, modelByID, normalizeModels } from './models.js';
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 = await loadModels(apiKey);
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(`请输入上下文长度(最大 ${max},输出上限 ${model.maxOutputTokens},默认 ${max},回车即选择默认):`);
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}(输入上下文 ${context},输出上限 ${outputTokens})`;
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 cleaned = stripCodexManagedConfig(raw);
31
- const managed = [
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
- ].join('\n');
45
- const next = `${managed}\n\n${cleaned.trim()}`.trim() + '\n';
46
- fs.writeFileSync(configPath, next, { encoding: 'utf8', mode: 0o600 });
47
- fs.writeFileSync(authPath, `${JSON.stringify({ OPENAI_API_KEY: settings.apiKey }, null, 2)}\n`, {
48
- encoding: 'utf8',
49
- mode: 0o600
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
- fs.writeFileSync(filePath, `${lines.join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
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
- return String(raw || '')
139
- .replace(/^\s*model\s*=.*\n?/gm, '')
140
- .replace(/^\s*model_provider\s*=.*\n?/gm, '')
141
- .replace(/^\s*model_context_window\s*=.*\n?/gm, '')
142
- .replace(/^\s*model_reasoning_effort\s*=.*\n?/gm, '')
143
- .replace(/^\s*\[model_providers\.gpteam\][\s\S]*?(?=^\s*\[[^\]]+\]|$)/gm, '')
144
- .trim();
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
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
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.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
- '说明:测速会请求 GET /api/health 和流式 POST /v1/responses。写新配置前会先备份旧配置。'
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
- throw new Error(`/v1/models 返回 HTTP ${response.status}`);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpteam",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {