Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2541bd7
feat(health): add claude speed probe to health checks
ymkiux Apr 23, 2026
337f556
fix(web-ui): improve health check error UX
ymkiux Apr 23, 2026
b53a389
perf(web-ui): cap health chat request timeout
ymkiux Apr 23, 2026
32a4019
perf(health): reduce provider chat fallback waits
ymkiux Apr 23, 2026
1ea0428
feat(codex): per-provider availability test
ymkiux Apr 23, 2026
6b78994
feat(codex): restore batch health check section
ymkiux Apr 23, 2026
d9492ba
perf(codex): show batch health progress and cap probe time
ymkiux Apr 23, 2026
d52bdf8
chore(cli): rename dev hint label
ymkiux Apr 23, 2026
10f38c7
feat(cli): auto-open web ui in run
ymkiux Apr 23, 2026
c15d284
ui(config): move test latency before configured pill
ymkiux Apr 23, 2026
75ce694
ui(codex): hide health check result summary
ymkiux Apr 23, 2026
0102d85
refactor(health): remove chat check ui and api
ymkiux Apr 23, 2026
bf0c31f
fix(claude): correct CLAUDE.md editor labels
ymkiux Apr 24, 2026
3bbc3dd
fix(speed-test): probe model endpoint before models list
ymkiux Apr 24, 2026
b2c08b5
Merge remote-tracking branch 'origin/main' into pr-105
ymkiux Apr 24, 2026
c88184c
ui(claude): move health check below CLAUDE.md
ymkiux Apr 24, 2026
0f141cb
ui(health): align Claude health check with Codex
ymkiux Apr 24, 2026
bf94c1f
ui(install): avoid CLI missing flicker before status loads
ymkiux Apr 24, 2026
39d9aad
chore(reset): default to origin/main without prompt
ymkiux Apr 24, 2026
7cfdd74
fix(ui): address sessions copy, prompt template import, service tier …
ymkiux Apr 24, 2026
ea006c9
ui(plugins): remove builtin prompt templates subtitle
ymkiux Apr 24, 2026
d543aa3
fix(health/docs): show health results, add modal, align help
ymkiux Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ npm run reset
npm run reset 79
```

- `npm run reset`: prompt for a PR number; leave it blank to return to default `origin/main`
- `npm run reset`: reset to default `origin/main`
- `npm run reset 79`: sync directly to the latest head snapshot of PR `#79`
- The script also handles local branch switching, workspace cleanup, untracked file cleanup, and final state validation

Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ npm run reset
npm run reset 79
```

- `npm run reset`:交互输入 PR 编号;留空则回到默认 `origin/main`
- `npm run reset`:直接重置到默认 `origin/main`
- `npm run reset 79`:直接同步到 PR `#79` 的最新 head 快照
- 脚本会自动完成本地分支切换、工作区清理、未跟踪文件清理与最终状态校验

Expand Down
299 changes: 126 additions & 173 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ const {
extractModelNames,
hasModelsListPayload,
buildModelsCacheKey,
buildApiProbeUrlCandidates,
buildModelProbeSpec,
buildModelConversationSpecs,
buildModelProbeSpecs,
extractModelResponseText,
normalizeWireApi,
getSupplementalModelsForBaseUrl,
Expand Down Expand Up @@ -6003,6 +6004,60 @@ function importConfigData(payload, options = {}) {
function resolveSpeedTestTarget(params) {
if (!params) return { error: 'Missing params' };

if (typeof params.kind === 'string' && params.kind.trim() === 'claude') {
const baseUrl = typeof params.url === 'string' ? params.url.trim() : '';
const apiKey = typeof params.apiKey === 'string' ? params.apiKey.trim() : '';
const model = typeof params.model === 'string' ? params.model.trim() : '';
if (!baseUrl) {
return { error: 'Missing url' };
}
if (!apiKey) {
return { error: 'Missing apiKey' };
}
if (!model) {
return { error: 'Missing model' };
}
const normalizedBase = baseUrl.replace(/\/+$/, '');
Comment on lines +6007 to +6020

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use the Claude config base URL when resolving Claude probes.

This branch only reads params.url, while the Claude config flow in this file uses baseUrl. A request with kind: 'claude', apiKey, and model will return Missing url before probing.

🐛 Proposed fix
     if (typeof params.kind === 'string' && params.kind.trim() === 'claude') {
-        const baseUrl = typeof params.url === 'string' ? params.url.trim() : '';
+        const explicitBaseUrl = typeof params.url === 'string' && params.url.trim()
+            ? params.url.trim()
+            : (typeof params.baseUrl === 'string' ? params.baseUrl.trim() : '');
+        const settingsInfo = explicitBaseUrl ? null : readClaudeSettingsInfo();
+        const baseUrl = explicitBaseUrl
+            || (settingsInfo && typeof settingsInfo.baseUrl === 'string' ? settingsInfo.baseUrl.trim() : '');
         const apiKey = typeof params.apiKey === 'string' ? params.apiKey.trim() : '';
         const model = typeof params.model === 'string' ? params.model.trim() : '';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli.js` around lines 6008 - 6021, The branch checks params.url but the Claude
config flow uses baseUrl, causing false "Missing url" errors; change how baseUrl
is derived so it prefers params.baseUrl when present and falls back to
params.url (i.e., set baseUrl from params.baseUrl if typeof params.baseUrl ===
'string' trimmed, otherwise from params.url trimmed, and then continue using
normalizedBase as before), keeping the existing validations for
baseUrl/apiKey/model (referencing params.kind, params.baseUrl, params.url,
baseUrl, and normalizedBase to locate the code).

let parsed = null;
try {
parsed = new URL(normalizedBase);
} catch (_) {
return { error: 'Invalid URL' };
}
const pathname = typeof parsed.pathname === 'string' ? parsed.pathname : '';
const trimmedPath = pathname.replace(/\/+$/, '');
const isRootPath = !trimmedPath || trimmedPath === '/';
const endsWithV1 = trimmedPath.endsWith('/v1');
const makeCandidate = (url) => ({
method: 'POST',
url,
body: {
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'ping' }]
}
});
const candidates = [];
if (endsWithV1) {
candidates.push(makeCandidate(`${normalizedBase}/messages`));
} else if (isRootPath) {
candidates.push(makeCandidate(`${normalizedBase}/v1/messages`));
candidates.push(makeCandidate(`${normalizedBase}/messages`));
} else {
candidates.push(makeCandidate(`${normalizedBase}/messages`));
candidates.push(makeCandidate(`${normalizedBase}/v1/messages`));
}
return {
kind: 'claude',
candidates,
apiKey,
apiKeyHeader: 'x-api-key',
headers: {
'anthropic-version': '2023-06-01'
}
};
}

if (params.name) {
const { config } = readConfigOrVirtualDefault();
const providers = config.model_providers || {};
Expand All @@ -6013,20 +6068,32 @@ function resolveSpeedTestTarget(params) {
if (!provider.base_url) {
return { error: 'Provider missing URL' };
}
const currentModel = typeof config.model === 'string' ? config.model.trim() : '';
const probeSpec = buildModelProbeSpec(provider, currentModel, provider.base_url);
if (probeSpec && probeSpec.url) {
return {
method: 'POST',
url: probeSpec.url,
body: probeSpec.body,
apiKey: provider.preferred_auth_method || ''
};
const providerName = String(params.name).trim();
const currentModels = readCurrentModels();
const selectedModel = typeof currentModels[providerName] === 'string' && currentModels[providerName].trim()
? currentModels[providerName].trim()
: (typeof config.model === 'string' ? config.model.trim() : '');

const apiKey = typeof provider.preferred_auth_method === 'string'
? provider.preferred_auth_method.trim()
: '';

const candidates = [];
for (const spec of buildModelProbeSpecs(provider, selectedModel, provider.base_url)) {
if (!spec || !spec.url) continue;
candidates.push({ method: 'POST', url: spec.url, body: spec.body });
}
Comment on lines +6071 to +6085

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t probe a provider with another provider’s active model.

When provider-current-models.json has no entry for params.name, this falls back to top-level config.model even if a different provider is active. If /models falls through to POST probing, that can falsely fail a healthy provider with an unrelated model.

🐛 Proposed fix
         const providerName = String(params.name).trim();
         const currentModels = readCurrentModels();
-        const selectedModel = typeof currentModels[providerName] === 'string' && currentModels[providerName].trim()
-            ? currentModels[providerName].trim()
-            : (typeof config.model === 'string' ? config.model.trim() : '');
+        const savedModel = typeof currentModels[providerName] === 'string'
+            ? currentModels[providerName].trim()
+            : '';
+        const activeProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
+        const activeModel = typeof config.model === 'string' ? config.model.trim() : '';
+        const selectedModel = savedModel || (activeProvider === providerName ? activeModel : '');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli.js` around lines 6072 - 6089, The code incorrectly falls back to
top-level config.model when currentModels has no entry for providerName, causing
probes to use another provider's active model; change the selectedModel
assignment (used by buildModelProbeSpecs) so it only uses
currentModels[providerName] when present and non-empty (trimmed) and otherwise
sets selectedModel to an empty string—remove the fallback to config.model—so
buildModelProbeSpecs is only fed a model known to belong to this provider.

for (const url of buildApiProbeUrlCandidates(provider.base_url, 'models')) {
candidates.push({ method: 'GET', url });
}
if (candidates.length === 0) {
candidates.push({ method: 'GET', url: provider.base_url });
}

return {
method: 'GET',
url: provider.base_url,
apiKey: provider.preferred_auth_method || ''
kind: 'provider',
candidates,
apiKey
};
}

Expand All @@ -6041,155 +6108,6 @@ function resolveSpeedTestTarget(params) {
return { error: 'Missing name or url' };
}

function extractApiPayloadErrorMessage(payload) {
if (!payload || typeof payload !== 'object') {
return '';
}
if (typeof payload.error === 'string' && payload.error.trim()) {
return payload.error.trim();
}
if (!payload.error || typeof payload.error !== 'object') {
return '';
}
if (typeof payload.error.message === 'string' && payload.error.message.trim()) {
return payload.error.message.trim();
}
if (typeof payload.error.code === 'string' && payload.error.code.trim()) {
return payload.error.code.trim();
}
return '';
}

function resolveProviderChatTarget(params) {
const providerName = typeof (params && params.name) === 'string' ? params.name.trim() : '';
const prompt = typeof (params && params.prompt) === 'string' ? params.prompt.trim() : '';
if (!providerName) {
return { error: 'Provider name is required' };
}
if (!prompt) {
return { error: 'Prompt is required' };
}

const { config } = readConfigOrVirtualDefault();
const providers = config.model_providers || {};
const provider = providers[providerName];
if (!provider || typeof provider !== 'object') {
return { error: `Provider not found: ${providerName}` };
}

const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : '';
if (!baseUrl) {
return { error: `Provider ${providerName} missing URL` };
}

const currentModels = readCurrentModels();
const savedModel = currentModels && typeof currentModels[providerName] === 'string'
? currentModels[providerName].trim()
: '';
const activeProvider = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
const activeModel = typeof config.model === 'string' ? config.model.trim() : '';
const model = savedModel || (activeProvider === providerName ? activeModel : '');
if (!model) {
return { error: `Provider ${providerName} missing current model` };
}

const specs = buildModelConversationSpecs(provider, model, baseUrl, prompt, {
maxOutputTokens: 256
});
if (!specs.length) {
return { error: `Provider ${providerName} missing available conversation endpoint` };
}

return {
providerName,
provider,
model,
prompt,
specs,
apiKey: typeof provider.preferred_auth_method === 'string'
? provider.preferred_auth_method.trim()
: ''
};
}

async function runProviderChatCheck(params = {}) {
const target = resolveProviderChatTarget(params);
if (target.error) {
return { ok: false, error: target.error };
}

const timeoutMs = Number.isFinite(params.timeoutMs)
? Math.max(1000, Number(params.timeoutMs))
: 30000;
let finalSpec = target.specs[0];
let result = null;

for (let index = 0; index < target.specs.length; index += 1) {
const candidate = target.specs[index];
const probeResult = await probeJsonPost(candidate.url, candidate.body, {
apiKey: target.apiKey,
timeoutMs,
maxBytes: 512 * 1024
});
finalSpec = candidate;
result = probeResult;
const shouldTryNextCandidate = index < target.specs.length - 1
&& (!probeResult.ok || probeResult.status === 404);
if (!shouldTryNextCandidate) {
break;
}
}

if (!result || !result.ok) {
return {
ok: false,
provider: target.providerName,
model: target.model,
url: finalSpec.url,
status: Number.isFinite(result && result.status) ? result.status : 0,
durationMs: Number.isFinite(result && result.durationMs) ? result.durationMs : 0,
reply: '',
rawPreview: '',
error: result && result.error ? result.error : 'request failed'
};
}

let payload = null;
try {
payload = result.body ? JSON.parse(result.body) : null;
} catch (e) {
payload = null;
}

const payloadError = extractApiPayloadErrorMessage(payload);
if (result.status >= 400 || payloadError) {
return {
ok: false,
provider: target.providerName,
model: target.model,
url: finalSpec.url,
status: Number.isFinite(result.status) ? result.status : 0,
durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0,
reply: '',
rawPreview: result.body ? truncateText(result.body, 600) : '',
error: payloadError || `HTTP ${result.status}`
};
}

const reply = extractModelResponseText(payload);
return {
ok: true,
provider: target.providerName,
model: target.model,
url: finalSpec.url,
status: Number.isFinite(result.status) ? result.status : 0,
durationMs: Number.isFinite(result.durationMs) ? result.durationMs : 0,
reply,
rawPreview: reply ? '' : (result.body ? truncateText(result.body, 600) : ''),
error: ''
};
}

function runSpeedTest(targetUrl, apiKey, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(1000, Number(options.timeoutMs))
Expand All @@ -6198,6 +6116,8 @@ function runSpeedTest(targetUrl, apiKey, options = {}) {
if (method === 'POST') {
return probeJsonPost(targetUrl, options.body || {}, {
apiKey,
apiKeyHeader: typeof options.apiKeyHeader === 'string' ? options.apiKeyHeader : '',
headers: options.headers && typeof options.headers === 'object' ? options.headers : null,
timeoutMs,
maxBytes: 256 * 1024
}).then((result) => ({
Expand All @@ -6209,6 +6129,8 @@ function runSpeedTest(targetUrl, apiKey, options = {}) {
}
return probeUrl(targetUrl, {
apiKey,
apiKeyHeader: typeof options.apiKeyHeader === 'string' ? options.apiKeyHeader : '',
headers: options.headers && typeof options.headers === 'object' ? options.headers : null,
timeoutMs,
maxBytes: 256 * 1024
}).then((result) => ({
Expand Down Expand Up @@ -8446,11 +8368,41 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
result = { error: target.error };
break;
}
result = await runSpeedTest(target.url, target.apiKey, target);
break;
}
case 'provider-chat-check': {
result = await runProviderChatCheck(params || {});
const timeoutMs = Number.isFinite(params && params.timeoutMs)
? Math.max(1000, Number(params.timeoutMs))
: 0;
if (Array.isArray(target.candidates) && target.candidates.length > 0) {
let finalCandidate = target.candidates[0];
let finalResult = null;
for (let index = 0; index < target.candidates.length; index += 1) {
const candidate = target.candidates[index];
const probeResult = await runSpeedTest(candidate.url, target.apiKey, {
...candidate,
apiKeyHeader: target.apiKeyHeader,
headers: target.headers,
timeoutMs: timeoutMs || undefined
});
finalCandidate = candidate;
finalResult = probeResult;
const status = Number.isFinite(probeResult && probeResult.status) ? probeResult.status : 0;
const shouldTryNext = index < target.candidates.length - 1 && status === 404;
if (!shouldTryNext) {
Comment on lines +8377 to +8389

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Continue past unsupported /models responses to POST probes.

Provider candidates put GET /models before POST model probes. This loop only advances on 404, so providers that return 405/501 for model listing never reach the actual availability probe; fetchModelsFromBaseUrlCore already treats those statuses as unsupported model-list endpoints.

🐛 Proposed fix
                                     finalCandidate = candidate;
                                     finalResult = probeResult;
                                     const status = Number.isFinite(probeResult && probeResult.status) ? probeResult.status : 0;
-                                    const shouldTryNext = index < target.candidates.length - 1 && status === 404;
+                                    const method = typeof candidate.method === 'string' ? candidate.method.toUpperCase() : 'GET';
+                                    const endpointUnsupported = status === 404
+                                        || (method === 'GET' && (status === 405 || status === 501));
+                                    const shouldTryNext = index < target.candidates.length - 1 && endpointUnsupported;
                                     if (!shouldTryNext) {
                                         break;
                                     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (let index = 0; index < target.candidates.length; index += 1) {
const candidate = target.candidates[index];
const probeResult = await runSpeedTest(candidate.url, target.apiKey, {
...candidate,
apiKeyHeader: target.apiKeyHeader,
headers: target.headers
});
finalCandidate = candidate;
finalResult = probeResult;
const status = Number.isFinite(probeResult && probeResult.status) ? probeResult.status : 0;
const shouldTryNext = index < target.candidates.length - 1 && status === 404;
if (!shouldTryNext) {
for (let index = 0; index < target.candidates.length; index += 1) {
const candidate = target.candidates[index];
const probeResult = await runSpeedTest(candidate.url, target.apiKey, {
...candidate,
apiKeyHeader: target.apiKeyHeader,
headers: target.headers
});
finalCandidate = candidate;
finalResult = probeResult;
const status = Number.isFinite(probeResult && probeResult.status) ? probeResult.status : 0;
const method = typeof candidate.method === 'string' ? candidate.method.toUpperCase() : 'GET';
const endpointUnsupported = status === 404
|| (method === 'GET' && (status === 405 || status === 501));
const shouldTryNext = index < target.candidates.length - 1 && endpointUnsupported;
if (!shouldTryNext) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli.js` around lines 8524 - 8535, The loop that tries provider candidates
only advances to the next candidate when probeResult.status is 404, so
candidates that return 405/501 (unsupported /models) never reach the POST probe;
update the shouldTryNext logic in the loop that calls runSpeedTest (iterating
target.candidates) to treat 405 and 501 as "unsupported model-list endpoint" as
well (e.g., check if status is in [404, 405, 501] or reuse the same
status-checking helper logic used by fetchModelsFromBaseUrlCore) so the loop
skips to the next candidate for those statuses in addition to 404.

break;
}
}
result = {
ok: !!(finalResult && finalResult.ok),
status: Number.isFinite(finalResult && finalResult.status) ? finalResult.status : 0,
durationMs: Number.isFinite(finalResult && finalResult.durationMs) ? finalResult.durationMs : 0,
error: finalResult && finalResult.ok ? '' : (finalResult && finalResult.error ? finalResult.error : ''),
url: finalCandidate && finalCandidate.url ? finalCandidate.url : ''
};
break;
}
result = await runSpeedTest(target.url, target.apiKey, {
...target,
timeoutMs: timeoutMs || undefined
});
break;
}
case 'openai-bridge-get-provider': {
Expand Down Expand Up @@ -8913,7 +8865,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
const openUrl = `http://${formatHostForUrl(openHost)}:${port}`;
server.listen(port, host, () => {
console.log('\n✓ Web UI 已启动');
console.log(` 待访问: ${openUrl}`);
const willOpenBrowser = !!openBrowser && !process.env.CODEXMATE_NO_BROWSER;
console.log(` ${willOpenBrowser ? '已打开' : '待访问'}: ${openUrl}`);
if (host && host !== openHost) {
console.log(' 监听地址:', host);
}
Expand All @@ -8923,9 +8876,8 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
console.warn(' 建议仅在可信网络使用,或改用 --host 127.0.0.1。');
}

if (!process.env.CODEXMATE_NO_BROWSER && openBrowser) {
const url = openUrl;
openBrowserAfterReady(url);
if (willOpenBrowser) {
openBrowserAfterReady(openUrl);
}
});

Expand Down Expand Up @@ -9036,14 +8988,15 @@ function cmdStart(options = {}) {
|| process.env.CODEXMATE_DEV === '1'
|| process.env.CODEXMATE_DEV === 'true';

// 禁止自动打开浏览器:仅输出 URL,交由用户自行点击/打开。
const shouldOpenBrowser = !options.noBrowser && !process.env.CODEXMATE_NO_BROWSER;

let serverHandle = createWebServer({
htmlPath,
assetsDir,
webDir,
host,
port,
openBrowser: false
openBrowser: shouldOpenBrowser
});

// 禁止前端变更侦测与自动重启:避免终端输出噪音与访问时短暂 Connection Refused。
Expand Down
Loading
Loading