gpteam 0.1.2 → 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. 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
+ 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/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,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 = 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);
@@ -95,26 +98,16 @@ export function printResults(results) {
95
98
  formatMs(item.totalMs),
96
99
  `${Math.round(item.successRate * 100)}%`,
97
100
  formatMs(item.healthMs),
98
- item === recommended ? '推荐' : '-'
101
+ item === recommended ? '推荐' : '-',
102
+ item.error || '-'
99
103
  ]);
100
- const header = ['节点', 'DNS', 'TCP', 'TLS', '首包', '完成', '成功率', '健康检查', '推荐'];
104
+ const header = ['节点', 'DNS', 'TCP', 'TLS', '首包', '完成', '成功率', '健康检查', '推荐', '错误'];
101
105
  const widths = header.map((name, i) => Math.max(displayWidth(name), ...rows.map((row) => displayWidth(row[i]))) + 2);
102
106
  console.log('');
103
107
  console.log(formatRow(header, widths));
104
108
  for (const row of rows) console.log(formatRow(row, widths));
105
109
  }
106
110
 
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
111
  async function chooseModel(rl, models, preferred) {
119
112
  const selected = preferred ? modelByID(models, preferred) : null;
120
113
  if (selected) return selected;
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.2';
2
+ export const PACKAGE_VERSION = '0.1.3';
3
3
 
4
4
  export function getHelpText() {
5
5
  return [
@@ -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.2",
3
+ "version": "0.1.3",
4
4
  "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {