From b23f2b71212b37d4722ecff3777f10c56b51b05c Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 12:25:33 -0700 Subject: [PATCH 1/4] fix(web): validate a local Ollama model against its own tags (CL-8464) --- apps/web/src/app.css | 8 +++ apps/web/src/onboarding/ollama-tags.test.ts | 64 +++++++++++++++++++ apps/web/src/onboarding/ollama-tags.ts | 49 ++++++++++++++ .../src/onboarding/provider-connect-step.tsx | 56 +++++++++++++--- 4 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/onboarding/ollama-tags.test.ts create mode 100644 apps/web/src/onboarding/ollama-tags.ts diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 1abf0f6d7..c4d054d62 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -2156,6 +2156,14 @@ select:disabled, color: var(--muted-foreground); } +/* Inline validation inside a connect form (e.g. an Ollama model Ollama + doesn't actually report) — same treatment as `.chat-thread-error`. */ +.onboarding-inline-error { + margin: 0.25rem 0 0; + font-size: 0.82rem; + color: var(--destructive); +} + /* The secondary three providers, tucked behind a subtle expander so the primary six-card row is what a person sees first — same square-cornered chrome as the rest of the wizard, not a popover. Full-width so it reads diff --git a/apps/web/src/onboarding/ollama-tags.test.ts b/apps/web/src/onboarding/ollama-tags.test.ts new file mode 100644 index 000000000..57c2bd892 --- /dev/null +++ b/apps/web/src/onboarding/ollama-tags.test.ts @@ -0,0 +1,64 @@ +// fetchOllamaTags: validates the base URL actually reaches Ollama and +// parses its native /api/tags shape, on the bare origin (not /v1). + +import { afterEach, describe, expect, test } from "bun:test"; + +import { fetchOllamaTags, OllamaTagsError, ollamaOrigin } from "./ollama-tags"; + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("ollamaOrigin", () => { + test("strips a trailing /v1 used for chat completions", () => { + expect(ollamaOrigin("http://localhost:11434/v1")).toBe("http://localhost:11434"); + }); + test("leaves a bare origin alone", () => { + expect(ollamaOrigin("http://localhost:11434")).toBe("http://localhost:11434"); + }); +}); + +describe("fetchOllamaTags", () => { + test("hits /api/tags on the bare origin and returns model names", async () => { + let requested = ""; + globalThis.fetch = ((input: RequestInfo | URL) => { + requested = String(input); + return Promise.resolve( + new Response(JSON.stringify({ models: [{ name: "qwen2.5:14b" }, { name: "llama3" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }) as typeof fetch; + + const tags = await fetchOllamaTags("http://localhost:11434/v1"); + expect(requested).toBe("http://localhost:11434/api/tags"); + expect(tags).toEqual(["qwen2.5:14b", "llama3"]); + }); + + test("rejects with a clear message when the server isn't Ollama", async () => { + globalThis.fetch = ((input: RequestInfo | URL) => { + void input; + return Promise.resolve(new Response("not found", { status: 404 })); + }) as typeof fetch; + await expect(fetchOllamaTags("http://localhost:11434/v1")).rejects.toBeInstanceOf( + OllamaTagsError, + ); + }); + + test("rejects when the response shape doesn't match", async () => { + globalThis.fetch = ((input: RequestInfo | URL) => { + void input; + return Promise.resolve( + new Response(JSON.stringify({ nope: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + }) as typeof fetch; + await expect(fetchOllamaTags("http://localhost:11434/v1")).rejects.toBeInstanceOf( + OllamaTagsError, + ); + }); +}); diff --git a/apps/web/src/onboarding/ollama-tags.ts b/apps/web/src/onboarding/ollama-tags.ts new file mode 100644 index 000000000..f9ad786ef --- /dev/null +++ b/apps/web/src/onboarding/ollama-tags.ts @@ -0,0 +1,49 @@ +// Validates a local Ollama connect against the server the user actually +// typed in, from the browser — the hub never sees this request. `GET +// /api/tags` is Ollama's own native listing route, served on the plain +// base origin even though the chat-completions base URL a caller stores +// carries a trailing `/v1`. + +import { type } from "arktype"; + +export class OllamaTagsError extends Error {} + +const TagsResponse = type({ models: type({ name: "string" }).array() }); + +/** Ollama's native routes (`/api/tags`) live on the bare origin; the + * OpenAI-compatible chat routes this app stores as the offering's base URL + * live under `/v1` on that same origin. */ +export function ollamaOrigin(baseURL: string): string { + return baseURL + .trim() + .replace(/\/v1\/?$/, "") + .replace(/\/+$/, ""); +} + +/** The tags this Ollama server currently has pulled, straight from the + * browser. Throws `OllamaTagsError` with a message safe to show as-is. */ +export async function fetchOllamaTags(baseURL: string): Promise { + const origin = ollamaOrigin(baseURL); + if (origin === "") { + throw new OllamaTagsError("Enter a base URL first."); + } + let response: Response; + try { + response = await fetch(`${origin}/api/tags`, { headers: { accept: "application/json" } }); + } catch (cause) { + throw new OllamaTagsError( + `Couldn't reach Ollama at ${origin}: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + if (!response.ok) { + throw new OllamaTagsError( + `Ollama at ${origin} answered ${String(response.status)} for /api/tags.`, + ); + } + const body: unknown = await response.json().catch(() => undefined); + const parsed = TagsResponse(body); + if (parsed instanceof type.errors) { + throw new OllamaTagsError(`Unexpected response shape from ${origin}/api/tags.`); + } + return parsed.models.map((model) => model.name); +} diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index f72a5bc6c..26eeb6ee7 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -1,14 +1,17 @@ // Connects one provider credential through the stock catalog routes and // derives the single offering it mints; a tenant that already resolves an // offering skips this step (see `resolveExistingOffering`). -import { Button, Input, RadioGroup, RadioOption } from "@corbits/react-ui"; +import { Button, Input, RadioGroup, RadioOption, Select } from "@corbits/react-ui"; import { getResolvedCatalog, shadowOffering } from "@/settings/inference"; import { reportError } from "@corbits/error-sink"; +import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import type { FormEvent } from "react"; import type { ModelProviderPlugin } from "@intx/types"; +import { fetchOllamaTags } from "./ollama-tags"; + export type ProviderOption = { readonly plugin: ModelProviderPlugin; readonly label: string; @@ -124,14 +127,30 @@ export function ProviderConnectStep({ const option = PROVIDER_OPTIONS.find((candidate) => candidate.plugin === selected) ?? PROVIDER_OPTIONS[0]; const isLocal = option?.local === true; + + // Fetched straight from the browser to the user-supplied base URL — this + // never touches the hub. Validates the base URL is actually an Ollama + // server and offers exactly the models it has pulled, so a fresh local + // setup can't name a model Ollama doesn't have. + const tagsQuery = useQuery({ + queryKey: ["onboarding", "ollama-tags", baseURL.trim()], + queryFn: () => fetchOllamaTags(baseURL), + enabled: isLocal && baseURL.trim().length > 0, + retry: false, + staleTime: 10_000, + }); + const tags = tagsQuery.data ?? []; + const modelKnown = !isLocal || tags.includes(modelName); + const ready = isLocal - ? baseURL.trim().length > 0 && modelName.trim().length > 0 + ? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown : apiKey.trim().length > 0; function selectOption(plugin: ModelProviderPlugin) { setSelected(plugin); const next = PROVIDER_OPTIONS.find((candidate) => candidate.plugin === plugin); setBaseURL(next?.local === true ? next.baseURL : ""); + setModelName(""); } async function handleSubmit(event: FormEvent) { @@ -198,15 +217,36 @@ export function ProviderConnectStep({ + {tagsQuery.isError ? ( +

+ {tagsQuery.error instanceof Error ? tagsQuery.error.message : String(tagsQuery.error)} +

+ ) : null} + {!tagsQuery.isError && modelName !== "" && !modelKnown ? ( +

+ {modelName} is not one of the models Ollama reports at this base URL. +

+ ) : null} ) : (