fix: LLM health check via mcplocal instead of spawning gemini directly
Status command now queries mcplocal's /llm/health endpoint instead of spawning the gemini binary. This uses the persistent ACP connection (fast) and works for any configured provider, not just gemini-cli. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,12 @@
|
||||
import { Command } from 'commander';
|
||||
import http from 'node:http';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { loadConfig } from '../config/index.js';
|
||||
import type { ConfigLoaderDeps, LlmConfig } from '../config/index.js';
|
||||
import type { ConfigLoaderDeps } from '../config/index.js';
|
||||
import { loadCredentials } from '../auth/index.js';
|
||||
import type { CredentialsDeps } from '../auth/index.js';
|
||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||
import { APP_VERSION } from '@mcpctl/shared';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ANSI helpers
|
||||
const GREEN = '\x1b[32m';
|
||||
const RED = '\x1b[31m';
|
||||
@@ -24,7 +20,8 @@ export interface StatusCommandDeps {
|
||||
log: (...args: string[]) => void;
|
||||
write: (text: string) => void;
|
||||
checkHealth: (url: string) => Promise<boolean>;
|
||||
checkLlm: (llm: LlmConfig) => Promise<string>;
|
||||
/** Check LLM health via mcplocal's /llm/health endpoint */
|
||||
checkLlm: (mcplocalUrl: string) => Promise<string>;
|
||||
isTTY: boolean;
|
||||
}
|
||||
|
||||
@@ -43,34 +40,34 @@ function defaultCheckHealth(url: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick LLM health check. Returns 'ok', 'binary not found', 'auth error', etc.
|
||||
* Check LLM health by querying mcplocal's /llm/health endpoint.
|
||||
* This tests the actual provider running inside the daemon (uses persistent ACP for gemini, etc.)
|
||||
*/
|
||||
async function defaultCheckLlm(llm: LlmConfig): Promise<string> {
|
||||
if (llm.provider === 'gemini-cli') {
|
||||
const bin = llm.binaryPath ?? 'gemini';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(bin, ['-p', 'respond with exactly: ok', '-m', llm.model ?? 'gemini-2.5-flash', '-o', 'text'], { timeout: 15000 });
|
||||
return stdout.trim().toLowerCase().includes('ok') ? 'ok' : 'unexpected response';
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
if (msg.includes('ENOENT')) return 'binary not found';
|
||||
if (msg.includes('auth') || msg.includes('token') || msg.includes('login') || msg.includes('401')) return 'not authenticated';
|
||||
return `error: ${msg.slice(0, 80)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (llm.provider === 'ollama') {
|
||||
const url = llm.url ?? 'http://localhost:11434';
|
||||
try {
|
||||
const ok = await defaultCheckHealth(url);
|
||||
return ok ? 'ok' : 'unreachable';
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
}
|
||||
}
|
||||
|
||||
// For API-key providers, we don't want to make a billable call on every status check
|
||||
return 'ok (key stored)';
|
||||
function defaultCheckLlm(mcplocalUrl: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`${mcplocalUrl}/llm/health`, { timeout: 30000 }, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as { status: string; error?: string };
|
||||
if (body.status === 'ok') {
|
||||
resolve('ok');
|
||||
} else if (body.status === 'not configured') {
|
||||
resolve('not configured');
|
||||
} else if (body.error) {
|
||||
resolve(body.error.slice(0, 80));
|
||||
} else {
|
||||
resolve(body.status);
|
||||
}
|
||||
} catch {
|
||||
resolve('invalid response');
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', () => resolve('mcplocal unreachable'));
|
||||
req.on('timeout', () => { req.destroy(); resolve('timeout'); });
|
||||
});
|
||||
}
|
||||
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
@@ -104,7 +101,7 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
|
||||
checkHealth(config.mcplocalUrl),
|
||||
checkHealth(config.mcpdUrl),
|
||||
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
|
||||
llmLabel ? checkLlm(config.mcplocalUrl) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const llm = llmLabel
|
||||
@@ -148,8 +145,8 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
|
||||
return;
|
||||
}
|
||||
|
||||
// LLM check with spinner
|
||||
const llmPromise = checkLlm(config.llm!);
|
||||
// LLM check with spinner — queries mcplocal's /llm/health endpoint
|
||||
const llmPromise = checkLlm(config.mcplocalUrl);
|
||||
|
||||
if (isTTY) {
|
||||
let frame = 0;
|
||||
|
||||
@@ -134,13 +134,23 @@ describe('status command', () => {
|
||||
expect(out).toContain('✗ not authenticated');
|
||||
});
|
||||
|
||||
it('shows binary not found error', async () => {
|
||||
it('shows error message from mcplocal', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('✗ binary not found');
|
||||
});
|
||||
|
||||
it('queries mcplocal URL for LLM health', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://custom:9999', llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||
let queriedUrl = '';
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
checkLlm: async (url) => { queriedUrl = url; return 'ok'; },
|
||||
}));
|
||||
await cmd.parseAsync([], { from: 'user' });
|
||||
expect(queriedUrl).toBe('http://custom:9999');
|
||||
});
|
||||
|
||||
it('uses spinner on TTY and writes final result', async () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
|
||||
const cmd = createStatusCommand(baseDeps({
|
||||
|
||||
@@ -81,6 +81,34 @@ export async function createHttpServer(
|
||||
reply.code(200).send({ status: 'ok' });
|
||||
});
|
||||
|
||||
// LLM health check — tests the active provider with a tiny prompt
|
||||
app.get('/llm/health', async (_request, reply) => {
|
||||
const provider = deps.providerRegistry?.getActive() ?? null;
|
||||
if (!provider) {
|
||||
reply.code(200).send({ status: 'not configured' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await provider.complete({
|
||||
messages: [{ role: 'user', content: 'Respond with exactly: ok' }],
|
||||
maxTokens: 10,
|
||||
});
|
||||
const ok = result.content.trim().toLowerCase().includes('ok');
|
||||
reply.code(200).send({
|
||||
status: ok ? 'ok' : 'unexpected response',
|
||||
provider: provider.name,
|
||||
response: result.content.trim().slice(0, 100),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? String(err);
|
||||
reply.code(200).send({
|
||||
status: 'error',
|
||||
provider: provider.name,
|
||||
error: msg.slice(0, 200),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy management routes to mcpd
|
||||
const mcpdClient = new McpdClient(config.mcpdUrl, config.mcpdToken);
|
||||
registerProxyRoutes(app, mcpdClient);
|
||||
|
||||
Reference in New Issue
Block a user