gpteam 0.1.27 → 0.1.28

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
@@ -12,7 +12,9 @@ When a selected client is missing, the interactive CLI shows the exact install c
12
12
 
13
13
  Recommendation order is deterministic: success rate first, then an experience score. The score weighs first SSE event time, total completion time, p90 completion tail latency, and health-check time, so a node with one very slow probe is not recommended just because its median result looks good. The result table also marks the fastest full completion separately from the balanced recommendation, because deep or long-output model runs may care more about full completion time than first-token responsiveness. 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.
14
14
 
15
- Client config writing follows the same safety pattern as cc-switch: keep Codex top-level fields separate from provider tables, preserve unrelated sections such as MCP servers, merge OpenCode/OpenClaw providers additively, and stop before writing when an existing JSON/JSON5 config cannot be parsed. GPTeam keeps three proxy-specific extensions on top of that baseline: Codex explicitly disables WebSocket prewarm, Codex/OpenCode/Claude Code write the `gpteam_image` MCP server so Image 2 can be called from chat, and Claude Code writes the reasoning-effort header through `ANTHROPIC_CUSTOM_HEADERS`.
15
+ Production endpoints are shown as the main, Japan, and Hong Kong ingress domains. They are all GPTeam production API entry points backed by the current new backend stack; the CLI no longer describes them as split-routing or secondary-worker nodes. The current node ids are `main`, `jp`, and `hk`. Older scripted values `jp-direct`, `jp-split`, and `hk-split` remain accepted as compatibility aliases.
16
+
17
+ Client config writing follows the same safety pattern as cc-switch: keep Codex top-level fields separate from provider tables, preserve unrelated sections such as MCP servers, merge OpenCode/OpenClaw providers additively, and stop before writing when an existing JSON/JSON5 config cannot be parsed. GPTeam keeps three proxy-specific extensions on top of that baseline: Codex enables WebSocket capability for the GPTeam provider, Codex/OpenCode/Claude Code write the `gpteam_image` MCP server so Image 2 can be called from chat, and Claude Code writes the reasoning-effort header through `ANTHROPIC_CUSTOM_HEADERS`.
16
18
 
17
19
  The Image MCP config uses cc-switch-style per-client env blocks. Codex writes `[mcp_servers.gpteam_image.env]`, OpenCode writes `mcp.gpteam_image.environment`, and Claude Code writes `mcpServers.gpteam_image.env` in `~/.claude.json`. The MCP receives `GPTEAM_API_KEY` and `GPTEAM_BASE_URL` from that MCP config, so it does not depend on Codex `auth.json` or inherited `OPENAI_API_KEY`.
18
20
 
package/lib/bench.js CHANGED
@@ -41,6 +41,7 @@ export async function benchmarkNode(node, options) {
41
41
  export function summarizeNode(node, samples) {
42
42
  const successful = samples.filter((sample) => sample.ok);
43
43
  const streams = successful.map((sample) => sample.stream);
44
+ const allStreams = samples.map((sample) => sample.stream).filter(Boolean);
44
45
  const firstEvents = streams.map((item) => item.firstEventMs).filter(Number.isFinite);
45
46
  const totals = streams.map((item) => item.totalMs).filter(Number.isFinite);
46
47
  const summary = {
@@ -51,10 +52,10 @@ export function summarizeNode(node, samples) {
51
52
  totalMs: median(totals),
52
53
  tailFirstEventMs: percentile(firstEvents, 0.9),
53
54
  tailTotalMs: percentile(totals, 0.9),
54
- dnsMs: median(streams.map((item) => item.dnsMs).filter(Number.isFinite)),
55
- tcpMs: median(streams.map((item) => item.tcpMs).filter(Number.isFinite)),
56
- tlsMs: median(streams.map((item) => item.tlsMs).filter(Number.isFinite)),
57
- healthMs: median(successful.map((sample) => sample.health.totalMs).filter(Number.isFinite)),
55
+ dnsMs: median(allStreams.map((item) => item.dnsMs).filter(Number.isFinite)),
56
+ tcpMs: median(allStreams.map((item) => item.tcpMs).filter(Number.isFinite)),
57
+ tlsMs: median(allStreams.map((item) => item.tlsMs).filter(Number.isFinite)),
58
+ healthMs: median(samples.map((sample) => sample.health?.totalMs).filter(Number.isFinite)),
58
59
  error: samples.find((sample) => sample.error)?.error || ''
59
60
  };
60
61
  summary.experienceScore = scoreResult(summary);
@@ -157,16 +158,17 @@ function measureStream(baseUrl, options) {
157
158
  });
158
159
  response.on('end', () => {
159
160
  const semantic = inspectSSEBody(body);
160
- const ok = response.statusCode >= 200
161
- && response.statusCode < 300
161
+ const status = response.statusCode || 0;
162
+ const ok = status >= 200
163
+ && status < 300
162
164
  && semantic.ok
163
165
  && Number.isFinite(timings.firstEventMs);
164
166
  resolve({
165
167
  ok,
166
- status: response.statusCode,
168
+ status,
167
169
  ...timings,
168
170
  totalMs: performance.now() - started,
169
- error: ok ? '' : semantic.error || `stream HTTP ${response.statusCode}`
171
+ error: ok ? '' : formatStreamProbeError(status, response.headers, body, semantic.error)
170
172
  });
171
173
  });
172
174
  });
@@ -194,6 +196,40 @@ function measureStream(baseUrl, options) {
194
196
  });
195
197
  }
196
198
 
199
+ export function formatStreamProbeError(status, headers, body, semanticError) {
200
+ const statusCode = Number(status || 0);
201
+ const contentType = String(headers?.['content-type'] || headers?.['Content-Type'] || '').trim();
202
+ const detail = responseBodyDetail(body);
203
+ const parts = [];
204
+ if (statusCode < 200 || statusCode >= 300) {
205
+ parts.push(`stream HTTP ${statusCode}`);
206
+ } else if (semanticError === 'stream empty response' && detail) {
207
+ parts.push('stream non-SSE response');
208
+ } else {
209
+ parts.push(semanticError || `stream HTTP ${statusCode}`);
210
+ }
211
+ if (contentType) parts.push(`content-type ${contentType}`);
212
+ if (detail) parts.push(detail);
213
+ return parts.join(';');
214
+ }
215
+
216
+ function responseBodyDetail(body) {
217
+ const text = String(body || '').replace(/\s+/g, ' ').trim();
218
+ if (!text) return '';
219
+ const parsed = parseJSON(text);
220
+ const message = parsed?.error?.message || parsed?.message || parsed?.error;
221
+ if (message) return `body ${String(message).slice(0, 180)}`;
222
+ return `body ${text.slice(0, 180)}`;
223
+ }
224
+
225
+ function parseJSON(value) {
226
+ try {
227
+ return JSON.parse(value);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
197
233
  function median(values) {
198
234
  if (!values.length) return NaN;
199
235
  const sorted = [...values].sort((a, b) => a - b);
package/lib/cli.js CHANGED
@@ -5,7 +5,7 @@ import { ensureClientInstalled, formatInstallCommand } from './client-install.js
5
5
  import { CLIENTS, writeClientConfig } from './config.js';
6
6
  import { getHelpText, PACKAGE_NAME, PACKAGE_VERSION } from './help.js';
7
7
  import { modelByID, validateApiKey } from './models.js';
8
- import { describeSplit, INGRESS_NODES } from './nodes.js';
8
+ import { INGRESS_NODES, nodeMatchesID } from './nodes.js';
9
9
  import { createTheme, stripAnsi } from './terminal.js';
10
10
 
11
11
  export async function runCli(argv = []) {
@@ -104,7 +104,7 @@ export function printResults(results, theme = createTheme()) {
104
104
  const recommended = results.find((item) => item.successRate > 0);
105
105
  const fastestTotal = findFastestTotalResult(results);
106
106
  const rows = results.map((item) => [
107
- `${item.node.label}(${describeSplit(item.node)})`,
107
+ formatNodeLabel(item.node),
108
108
  formatMs(item.dnsMs),
109
109
  formatMs(item.tcpMs),
110
110
  formatMs(item.tlsMs),
@@ -164,10 +164,11 @@ async function askContextLength(rl, model, preferred) {
164
164
 
165
165
  export async function chooseNode(rl, results, preferred, recommendedID, theme = createTheme()) {
166
166
  const nodes = results.map((item) => item.node);
167
- const preferredNode = nodes.find((node) => node.id === preferred);
167
+ const preferredNode = nodes.find((node) => nodeMatchesID(node, preferred));
168
168
  if (preferredNode) return preferredNode;
169
169
  if (recommendedID) {
170
- printStatus(theme, '综合推荐', recommendedID);
170
+ const recommendedNode = nodes.find((node) => nodeMatchesID(node, recommendedID));
171
+ printStatus(theme, '综合推荐', recommendedNode ? formatNodeLabel(recommendedNode) : recommendedID);
171
172
  }
172
173
  return (await choose(rl, '请选择最终写入的入口', nodes.map((node) => ({
173
174
  id: node.id,
@@ -223,7 +224,7 @@ export function formatModelLabel(model) {
223
224
  }
224
225
 
225
226
  export function formatNodeLabel(node) {
226
- return `${node.label}(${describeSplit(node)})`;
227
+ return String(node?.label || node?.id || '');
227
228
  }
228
229
 
229
230
  export function assertNodeConfig(node) {
package/lib/help.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export const PACKAGE_NAME = 'gpteam';
2
- export const PACKAGE_VERSION = '0.1.27';
2
+ export const PACKAGE_VERSION = '0.1.28';
3
3
 
4
4
  export function getHelpText() {
5
5
  return [
@@ -15,7 +15,7 @@ export function getHelpText() {
15
15
  ' --model <id> 预选模型,例如 gpt-5.5',
16
16
  ' --context <tokens> 预设可配置上下文长度',
17
17
  ' --effort <level> 按模型支持项预选,常见为 none / low / medium / high / xhigh',
18
- ' --node <id> jp-direct / jp-split / hk-split',
18
+ ' --node <id> main / jp / hk(兼容旧参数 jp-direct / jp-split / hk-split',
19
19
  ' --rounds <n> 每个入口测速轮数,默认 3',
20
20
  ' --max-output-tokens <n> 测速输出上限,默认 648',
21
21
  ' --install-client 客户端命令缺失时直接安装,不再二次确认',
package/lib/nodes.js CHANGED
@@ -1,32 +1,31 @@
1
1
  export const INGRESS_NODES = [
2
2
  {
3
- id: 'jp-direct',
4
- label: '直连',
5
- region: 'JP',
6
- split: false,
3
+ id: 'main',
4
+ aliases: ['jp-direct'],
5
+ label: '主入口',
7
6
  baseUrl: 'https://api.gpteamservices.com/v1',
8
7
  healthUrl: 'https://api.gpteamservices.com/api/health'
9
8
  },
10
9
  {
11
- id: 'jp-split',
10
+ id: 'jp',
11
+ aliases: ['jp-split'],
12
12
  label: '日本入口',
13
- region: 'JP',
14
- split: true,
15
13
  baseUrl: 'https://api-jp.gpteamservices.com/v1',
16
14
  healthUrl: 'https://api-jp.gpteamservices.com/api/health'
17
15
  },
18
16
  {
19
- id: 'hk-split',
17
+ id: 'hk',
18
+ aliases: ['hk-split'],
20
19
  label: '香港入口',
21
- region: 'HK',
22
- split: true,
23
20
  baseUrl: 'https://api-hk.gpteamservices.com/v1',
24
21
  healthUrl: 'https://api-hk.gpteamservices.com/api/health'
25
22
  }
26
23
  ];
27
24
 
28
- export function describeSplit(node) {
29
- return node.split ? '分流' : '不分流';
25
+ export function nodeMatchesID(node, id) {
26
+ const value = String(id || '').trim();
27
+ if (!value) return false;
28
+ return node.id === value || (Array.isArray(node.aliases) && node.aliases.includes(value));
30
29
  }
31
30
 
32
31
  export function endpointRoot(baseUrl) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gpteam",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "GPTeam API interactive client configurator and ingress benchmark CLI.",
5
5
  "type": "module",
6
6
  "bin": {