diff --git a/tools/ui/src/lib/services/backends.service.ts b/tools/ui/src/lib/services/backends.service.ts index 140839d20b..37420ef79f 100644 --- a/tools/ui/src/lib/services/backends.service.ts +++ b/tools/ui/src/lib/services/backends.service.ts @@ -1,15 +1,24 @@ /** - * BackendsService - Stateless backend connectivity checks + * BackendsService - Stateless backend connectivity checks and model listing * - * Probes a backend's models endpoint to validate its URL and credentials. - * No reactive state; consumed by the backends settings UI. + * Probes a backend's models endpoint to validate its URL and credentials, and + * normalizes the response into the UI model shape. No reactive state; + * consumed by the backends settings UI and the per-backend model cache. */ -import type { Backend } from '$lib/types'; +import type { Backend, ModelOption } from '$lib/types'; import { isAbortError } from '$lib/utils/abort'; import { getAuthHeadersForBackend } from '$lib/utils/api-headers'; import { backendModelsUrl } from '$lib/utils/backend'; +/** Models returned by a backend, plus the failure detail when the call fails. */ +export interface BackendModelsResult { + error?: string; + models: ModelOption[]; + ok: boolean; + status: number | null; +} + /** Outcome of a backend connectivity check. */ export interface BackendTestResult { error?: string; @@ -20,14 +29,14 @@ export interface BackendTestResult { export class BackendsService { /** - * Check that a backend answers on its models endpoint. + * List the models a backend exposes on its models endpoint. * - * @param backend - Backend to probe. Does not need to be registered yet. - * @param signal - Optional abort signal for a cancelled test. + * @param backend - Backend to query. Does not need to be registered yet. + * @param signal - Optional abort signal for a cancelled request. */ - static async test(backend: Backend, signal?: AbortSignal): Promise { + static async listModels(backend: Backend, signal?: AbortSignal): Promise { if (!backend.baseUrl.trim()) { - return { error: 'Backend URL is required', ok: false, status: null }; + return { error: 'Backend URL is required', models: [], ok: false, status: null }; } try { @@ -37,25 +46,49 @@ export class BackendsService { }); if (!response.ok) { - return { error: await describeFailure(response), ok: false, status: response.status }; + return { + error: await describeFailure(response), + models: [], + ok: false, + status: response.status + }; } const body = (await response.json()) as { data?: unknown }; - const modelCount = Array.isArray(body?.data) ? body.data.length : 0; + const entries = Array.isArray(body?.data) ? body.data : []; + const models = entries.flatMap((entry) => normalizeBackendModel(entry)); - return { modelCount, ok: true, status: response.status }; + return { models, ok: true, status: response.status }; } catch (error) { if (isAbortError(error)) { - return { ok: false, status: null }; + return { models: [], ok: false, status: null }; } return { error: error instanceof Error ? error.message : String(error), + models: [], ok: false, status: null }; } } + + /** + * Check that a backend answers on its models endpoint. + * + * @param backend - Backend to probe. Does not need to be registered yet. + * @param signal - Optional abort signal for a cancelled test. + */ + static async test(backend: Backend, signal?: AbortSignal): Promise { + const result = await BackendsService.listModels(backend, signal); + + return { + error: result.error, + modelCount: result.models.length, + ok: result.ok, + status: result.status + }; + } } /** Build a human-readable message from a non-OK response. */ @@ -73,3 +106,18 @@ async function describeFailure(response: Response): Promise { return status; } + +/** + * Normalize one entry of an OpenAI-compatible `/v1/models` response. External + * backends only guarantee an id, so that doubles as the display name. + */ +function normalizeBackendModel(entry: unknown): ModelOption[] { + if (!entry || typeof entry !== 'object') return []; + + const raw = entry as Record; + const id = typeof raw.id === 'string' ? raw.id.trim() : ''; + + if (!id) return []; + + return [{ capabilities: [], id, model: id, name: id }]; +}