diff --git a/src/api/services/index.ts b/src/api/services/index.ts index fb6ad360..8b213e17 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -64,6 +64,12 @@ export { // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker'; +export { + getLocalRuntimeReadiness, + type LocalRuntimeId, + type LocalRuntimeReadiness, + type LocalRuntimeStatus, +} from './local-runtime-readiness'; // Provider presets for CLI export { diff --git a/src/api/services/local-runtime-readiness.ts b/src/api/services/local-runtime-readiness.ts new file mode 100644 index 00000000..0472bfc1 --- /dev/null +++ b/src/api/services/local-runtime-readiness.ts @@ -0,0 +1,121 @@ +export type LocalRuntimeId = 'ollama' | 'llamacpp'; +export type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline'; + +export interface LocalRuntimeReadiness { + id: LocalRuntimeId; + name: string; + endpoint: string; + status: LocalRuntimeStatus; + commandHint: string; + recommendedModel: string | null; + recommendedModelInstalled: boolean; + detectedModelCount: number; +} + +interface LocalRuntimeDefinition { + id: LocalRuntimeId; + name: string; + endpoint: string; + modelsUrl: string; + commandHint: string; + recommendedModel: string | null; + parseModelIds: (payload: unknown) => string[]; +} + +const LOCAL_RUNTIME_DEFINITIONS: LocalRuntimeDefinition[] = [ + { + id: 'ollama', + name: 'Ollama', + endpoint: 'http://127.0.0.1:11434', + modelsUrl: 'http://127.0.0.1:11434/api/tags', + commandHint: 'ollama serve', + recommendedModel: 'gemma4:e4b', + parseModelIds: (payload) => { + const models = (payload as { models?: Array<{ name?: string }> })?.models; + return Array.isArray(models) + ? models + .map((model) => (typeof model?.name === 'string' ? model.name.trim() : '')) + .filter(Boolean) + : []; + }, + }, + { + id: 'llamacpp', + name: 'llama.cpp', + endpoint: 'http://127.0.0.1:8080', + modelsUrl: 'http://127.0.0.1:8080/v1/models', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + recommendedModel: null, + parseModelIds: (payload) => { + const models = (payload as { data?: Array<{ id?: string }> })?.data; + return Array.isArray(models) + ? models + .map((model) => (typeof model?.id === 'string' ? model.id.trim() : '')) + .filter(Boolean) + : []; + }, + }, +]; + +async function fetchModelIds(definition: LocalRuntimeDefinition): Promise { + const response = await fetch(definition.modelsUrl, { + signal: AbortSignal.timeout(1500), + }); + + if (!response.ok) { + throw new Error(`${definition.id} readiness probe failed (${response.status})`); + } + + return definition.parseModelIds(await response.json()); +} + +function toReadiness( + definition: LocalRuntimeDefinition, + modelIds: string[] +): LocalRuntimeReadiness { + const recommendedModelInstalled = definition.recommendedModel + ? modelIds.some((modelId) => modelId === definition.recommendedModel) + : modelIds.length > 0; + + const status: LocalRuntimeStatus = + modelIds.length === 0 || !recommendedModelInstalled ? 'missing-model' : 'ready'; + + return { + id: definition.id, + name: definition.name, + endpoint: definition.endpoint, + status, + commandHint: + status === 'missing-model' && definition.recommendedModel + ? `ollama pull ${definition.recommendedModel}` + : definition.commandHint, + recommendedModel: definition.recommendedModel, + recommendedModelInstalled, + detectedModelCount: modelIds.length, + }; +} + +function toOffline(definition: LocalRuntimeDefinition): LocalRuntimeReadiness { + return { + id: definition.id, + name: definition.name, + endpoint: definition.endpoint, + status: 'offline', + commandHint: definition.commandHint, + recommendedModel: definition.recommendedModel, + recommendedModelInstalled: false, + detectedModelCount: 0, + }; +} + +export async function getLocalRuntimeReadiness(): Promise { + return Promise.all( + LOCAL_RUNTIME_DEFINITIONS.map(async (definition) => { + try { + return toReadiness(definition, await fetchModelIds(definition)); + } catch { + return toOffline(definition); + } + }) + ); +} diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index d640d04e..84752b1d 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -10,6 +10,7 @@ import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-na import { createApiProfile, createCliproxyBridgeProfile, + getLocalRuntimeReadiness, removeApiProfile, updateApiProfileTarget, discoverApiProfileOrphans, @@ -86,6 +87,14 @@ router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => } }); +router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise => { + try { + res.json({ runtimes: await getLocalRuntimeReadiness() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + router.post('/cliproxy-bridge', (req: Request, res: Response): void => { const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']); if (!shape.ok) { diff --git a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts new file mode 100644 index 00000000..46c89b25 --- /dev/null +++ b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts @@ -0,0 +1,141 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes local runtime readiness', () => { + let server: Server; + let baseUrl = ''; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + + if (url.includes('11434/api/tags')) { + return new Response( + JSON.stringify({ + models: [{ name: 'gemma4:e4b' }, { name: 'qwen3-coder:latest' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url.includes('8080/v1/models')) { + return new Response( + JSON.stringify({ + data: [{ id: 'Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + throw new Error(`Unexpected URL: ${url}`); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('reports local runtimes as ready when their endpoints respond with models', async () => { + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + runtimes: Array<{ + id: string; + status: string; + recommendedModelInstalled: boolean; + }>; + }; + + expect(body.runtimes).toHaveLength(2); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'ollama', + status: 'ready', + recommendedModelInstalled: true, + }) + ); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'llamacpp', + status: 'ready', + }) + ); + }); + + it('reports setup guidance when local endpoints are unavailable', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + throw new Error('connect ECONNREFUSED'); + }) as typeof fetch; + + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + runtimes: Array<{ + id: string; + status: string; + commandHint: string; + }>; + }; + + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'ollama', + status: 'offline', + commandHint: 'ollama serve', + }) + ); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'llamacpp', + status: 'offline', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + }) + ); + }); +}); diff --git a/ui/src/hooks/use-local-runtime-readiness.ts b/ui/src/hooks/use-local-runtime-readiness.ts new file mode 100644 index 00000000..c207ae58 --- /dev/null +++ b/ui/src/hooks/use-local-runtime-readiness.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; + +export function useLocalRuntimeReadiness() { + return useQuery({ + queryKey: ['profiles', 'local-runtime-readiness'], + queryFn: () => api.profiles.getLocalRuntimeReadiness(), + staleTime: 15_000, + refetchOnWindowFocus: false, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index d8493a99..74a848ee 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -239,6 +239,23 @@ export interface Profile { cliproxyBridge?: CliproxyBridgeMetadata | null; } +export type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline'; + +export interface LocalRuntimeReadiness { + id: 'ollama' | 'llamacpp'; + name: string; + endpoint: string; + status: LocalRuntimeStatus; + commandHint: string; + recommendedModel: string | null; + recommendedModelInstalled: boolean; + detectedModelCount: number; +} + +export interface LocalRuntimeReadinessResponse { + runtimes: LocalRuntimeReadiness[]; +} + export interface CreateProfile { name: string; baseUrl: string; @@ -1024,6 +1041,8 @@ export interface CliproxyRestartResult { export const api = { profiles: { list: () => request<{ profiles: Profile[] }>('/profiles'), + getLocalRuntimeReadiness: () => + request('/profiles/local-runtime-readiness'), create: (data: CreateProfile) => request('/profiles', { method: 'POST',