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 +3 -1
- package/lib/cli.js +8 -15
- package/lib/config.js +103 -20
- package/lib/help.js +2 -2
- 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. 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 {
|
|
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);
|
|
@@ -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
|
|
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 [
|
|
@@ -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
|
+
}
|