[3cc1729c] Add self-hosted LLM provider with dynamic model discovery (#128)

* [684dace4] Self-hosted LLM provider: API layer, hooks, UI section, routing mode button, and Mix mode grouping (#124) (#126)

* [684dace4] feat(providers): add self-hosted LLM API types, endpoints, and React Query hooks

- Add ModelProvider.SELF_HOSTED enum value to types/index.ts
- Extend RoutingMode to include 'self_hosted' in lib/api/providers.ts
- Add SelfHostedConfig, SelfHostedTestResult, SelfHostedModel interfaces
- Add SelfHostedConfigPayload for PUT requests
- Add 5 providersApi methods: getSelfHostedConfig, saveSelfHostedConfig,
  testSelfHosted, getSelfHostedModels, refreshSelfHostedModels
- Add 5 React Query hooks: useSelfHostedConfig, useSetSelfHostedConfig,
  useTestSelfHosted, useSelfHostedModels, useRefreshSelfHostedModels
- Cache keys follow existing providerKeys pattern with proper invalidation

* [684dace4] feat(settings): create SelfHostedSection component with full self-hosted LLM UI

- Base URL text input with placeholder showing saved URL when set
- Optional auth token field (type='password') with Eye/EyeOff toggle button
- Save button that calls useSetSelfHostedConfig mutation
- Test Connection button disabled until a URL is saved; shows inline
  green 'Connected — N models' badge on success or red error badge on fail
- Three empty states: no URL configured (CTA), error state (last-checked +
  Retry), connected with 0 models (pull-guidance)
- Model list with auto-discovered chip, Refresh Models button, and
  Last refreshed relative timestamp when test_status === 'connected'
- Token field shows masked placeholder when has_auth_token is true
  (consistent with Ollama Cloud key field pattern)

* [684dace4] feat(settings): add Self-Hosted mode button, model picker, and Mix mode provider grouping

- Wire SelfHostedSection into AIRoutingCard with testResult state tracking
- Expand routing mode grid from 3 to 4 buttons (2×2 on mobile, 4-col on md+)
- 4th 'Self-Hosted' mode button disabled until test_status === 'connected'
- Self-hosted model picker appears below mode grid when mode === 'self_hosted'
- flipToSelfHosted handler sends mode='self_hosted' with optional default_model
- Mix mode per-agent dropdown now groups entries under SelectGroup/SelectLabel
  headings: Anthropic, Ollama Cloud, Self-Hosted with colored ProviderBadge pill
- saveMix validates self-hosted model selection requires a successful test
- ProviderBadge helper renders blue/violet/purple pills for each provider type
- pnpm typecheck and pnpm lint pass with zero errors

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [2897ce90] Implement self-hosted LLM provider API, routing, and discovery (#125) (#127)

* [2897ce90] feat(provider): add self-hosted LLM provider API, routing, and discovery

- Add migration 027 to seed Self-Hosted (Ollama) LOCAL provider row
- Add probe_ollama_tags() helper for Ollama /api/tags connectivity checks
- Extend ModelRoutingService: derive_mode returns 'self_hosted' for LOCAL
  GLOBAL assignments; apply_mode handles 'self_hosted' mode; upsert_assignment
  routes non-catalog model names to LOCAL provider; resolve_for_agent falls
  back to Anthropic when self-hosted server is unreachable
- Add PUT /api/providers/self-hosted, POST /api/providers/self-hosted/test,
  GET /api/providers/self-hosted/models endpoints
- Extend ApplyModeRequest and ModeResponse literals with 'self_hosted'
- Add SelfHostedConfigRequest, SelfHostedConfigResponse, SelfHostedTestResponse schemas

* [2897ce90] test(provider): add integration tests for self-hosted routing and route endpoints

- Add llm_setup_with_local fixture that seeds LOCAL provider row
- Test derive_mode returns 'self_hosted' for single GLOBAL LOCAL assignment
- Test apply_mode('self_hosted') clears prior assignments, enables LOCAL, inserts GLOBAL
- Test apply_mode('self_hosted') requires default_model argument
- Test upsert_assignment routes non-catalog model names to LOCAL provider
- Test mix mode accepts self-hosted model names without ValueError
- Test resolve_for_agent returns base_url when LOCAL server is reachable
- Test resolve_for_agent falls back to Anthropic when LOCAL server is unreachable
- Test upsert_assignment raises ValueError when model unknown and no LOCAL provider
- Add app_client_with_local fixture for route tests
- Test PUT /self-hosted saves base_url and enables provider
- Test PUT /self-hosted stores encrypted token when auth_token provided
- Test PUT /self-hosted returns 404 when LOCAL provider not seeded
- Test POST /self-hosted/test returns {ok:true,model_count:N} when reachable
- Test POST /self-hosted/test returns {ok:false,error} (never 500) when unreachable
- Test GET /self-hosted/models returns model name list
- Test GET /self-hosted/models returns 404 when not configured
- Test GET /self-hosted/models returns 503 when server unreachable
- Rename migration from 027 to 028 to rebase on 027_system_settings

* [2897ce90] chore(migration): remove superseded 027 migration, fix formatter changes to provider schemas

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [042462df] feat(providers): align self-hosted types, hooks, and UI to backend contract (#129) (#131)

- SelfHostedConfig now has {base_url: string, has_token: boolean, enabled: boolean}
- SelfHostedTestResult now has {ok: boolean, model_count: number | null, error: string | null}
- Remove SelfHostedTestStatus type and refreshSelfHostedModels POST API function
- Remove SELF_HOSTED from ModelProvider enum (LOCAL covers self-hosted semantics)
- useRefreshSelfHostedModels now invalidates GET cache instead of calling POST
- isSelfHostedConnected derived from testResult?.ok === true
- Self-hosted model picker uses value='__clear__' sentinel (no empty-string SelectItem)
- self-hosted-section.tsx reads result.ok/result.error and config?.has_token
- pnpm typecheck passes with zero errors

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [f66d6d4d] Fix self-hosted API S1-S4/L1-L5: routes, schemas, services, migration 028, and tests (#130) (#132)

* [f66d6d4d] fix(provider): self-hosted API S1-S4/L1-L5 - routes, schemas, services, migration 028, and tests

AC1: Add GET /providers/self-hosted returning {base_url, has_token, enabled}
AC2: GET /self-hosted/models now returns list[SelfHostedModelEntry] with model_name and display_name
AC3: probe_ollama_tags generic except logs exception server-side and returns hardcoded generic string
AC4: upsert_assignment calls ProviderService.update_provider(enabled=True) when routing to LOCAL
AC5: derive_mode return annotation is Literal[...] — type:ignore comments removed
AC6: All migration refs in routes/services say 028 (not 027)
AC7: Migration 028 downgrade() deletes model_assignments before provider_configs
AC8: PUT /self-hosted only passes enabled=True when data.base_url is non-empty
AC9: ModelProvider.LOCAL docstring updated to describe self-hosted Ollama provider
AC10: Direct unit tests for probe_ollama_tags (5 cases) in tests/unit/llm/
AC11: Contract tests added/updated for GET /providers/self-hosted, models, and test endpoints
AC12: test_migration_028_seed_self_hosted.py with upgrade and FK-safe downgrade tests
AC13: test_apply_mode_ollama_without_provider_returns_404 asserts exactly HTTPStatus.NOT_FOUND
AC14: ruff and mypy pass with zero errors

* [f66d6d4d] fix(tests): add AC4 test proving LOCAL.enabled transitions False->True in upsert_assignment

The existing tests (test_upsert_assignment_routes_unknown_model_to_local and
test_mix_mode_with_self_hosted_models) both use llm_setup_with_local which seeds
LOCAL with enabled=True, making the AC4 assertion vacuous.

New test test_upsert_assignment_enables_local_when_disabled:
- Creates LOCAL ProviderConfigTable row with enabled=False
- Asserts pre-condition: local.enabled is False
- Calls upsert_assignment with a non-catalog model name ('non-catalog-model:7b')
- Refreshes LOCAL row via db_session.refresh(local)
- Asserts row.provider.type == ModelProvider.LOCAL and local.enabled is True

This proves the state transition from False->True, not merely that the
already-enabled state is preserved. ruff and mypy still pass with zero errors.

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [7cd6ae6e] fix(providers): type SelfHostedConfig.base_url as string | null to match backend contract (#133) (#136)

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [46ee9104] test(migration_028): replace upgrade test with self-seeding contract test (#134) (#135)

Remove test_migration_028_upgrade_local_row_inserted which relied on alembic
upgrade head having run (and thus the Self-Hosted Ollama row being present).

Replace it with test_migration_028_upgrade_insert_contract that:
- Executes the exact INSERT SQL from migration 028 upgrade() directly
- Asserts name='Self-Hosted (Ollama)', type='local', enabled=False
- Runs the INSERT a second time and asserts exactly one row (ON CONFLICT
  DO NOTHING idempotency)

The downgrade test is left byte-for-byte unchanged.

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [f0d19f30] test(provider): add DELETE-before-seed isolation and app_client_with_ollama fixture (#137) (#138)

- Add ModelAssignmentTable import to test_provider_routes.py
- Fix app_client_with_local: execute DELETE on ModelAssignmentTable then
  DELETE on ProviderConfigTable (FK-safe order) and flush before seeding
- Add new app_client_with_ollama fixture with same isolation pattern,
  seeding only ANTHROPIC + OLLAMA_CLOUD rows
- Update 7 tests to use app_client_with_ollama instead of app_client:
  test_get_catalog, test_get_ollama_key_status, test_set_ollama_key,
  test_get_current_mode, test_apply_mode_anthropic_clears_assignments,
  test_apply_mode_unknown_returns_4xx, test_apply_mode_mix_without_per_agent_returns_400

Fixes order-dependent failures in test_get_self_hosted_models_not_configured_returns_404:
routes call db.commit() which persists rows across test sessions; without
DELETE-before-seed, stale LOCAL provider rows with base_url set from prior
runs cause the test to see 503 instead of 404.

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* refactor(llm): split resolve_for_agent and apply_mode to clear xenon rank C

resolve_for_agent and apply_mode were cyclomatic rank C, failing the
xenon gate (--max-absolute B). Extract behavior-preserving helpers:

- resolve_for_agent -> _resolve_assignment (precedence ladder),
  _route_from_resolved / _local_route_or_none / _decrypt_route_or_none
  (None signals fall-through to legacy), _legacy_route.
- apply_mode -> _apply_anthropic / _apply_ollama / _apply_self_hosted /
  _apply_mix dispatched from a thin if/elif.

No behavior change. Also correct the stale 'default: Kimi K2.6' docstring
(OLLAMA_DEFAULT_MODEL is minimax-m3:cloud).

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-13 08:38:31 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Backend Developer 1 Renn F
parent 0daef044d2
commit 73b7c16211
13 changed files with 2201 additions and 120 deletions
+211 -10
View File
@@ -1,12 +1,13 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState, useCallback } from "react";
import {
useApplyMode,
useCatalog,
useOllamaKey,
useRoutingMode,
useSetOllamaKey,
useSelfHostedModels,
} from "@/hooks/use-providers";
import {
Card,
@@ -21,7 +22,9 @@ import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
@@ -31,12 +34,14 @@ import {
Cpu,
Key,
KeyRound,
Server,
ShieldCheck,
Sparkles,
} from "lucide-react";
import { toast } from "sonner";
import { AssignmentScope, ModelProvider } from "@/types";
import type { RoutingMode } from "@/lib/api/providers";
import type { RoutingMode, SelfHostedTestResult } from "@/lib/api/providers";
import { SelfHostedSection } from "@/components/settings/self-hosted-section";
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
@@ -70,6 +75,7 @@ export function AIRoutingCard() {
const { data: catalog = [] } = useCatalog();
const { data: keyStatus } = useOllamaKey();
const { data: snapshot } = useRoutingMode();
const { data: selfHostedModels = [] } = useSelfHostedModels();
const setKey = useSetOllamaKey();
const applyMode = useApplyMode();
@@ -77,6 +83,21 @@ export function AIRoutingCard() {
const hasOllamaKey = !!keyStatus?.has_key;
const currentMode: RoutingMode = snapshot?.mode ?? "anthropic";
// Track the latest self-hosted test result so ModeButton can gate access.
const [selfHostedTestResult, setSelfHostedTestResult] =
useState<SelfHostedTestResult | null>(null);
const isSelfHostedConnected = selfHostedTestResult?.ok === true;
const handleSelfHostedTestResult = useCallback(
(result: SelfHostedTestResult) => {
setSelfHostedTestResult(result);
},
[],
);
// Selected self-hosted model (used when mode === 'self_hosted').
const [selfHostedModel, setSelfHostedModel] = useState<string>("");
// --- API key input ---
const [apiKey, setApiKey] = useState("");
const [clearKey, setClearKey] = useState(false);
@@ -123,6 +144,9 @@ export function AIRoutingCard() {
const catalogOllamaOnly = catalog.filter(
(c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.OLLAMA_CLOUD,
);
const catalogAnthropicOnly = catalog.filter(
(c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.ANTHROPIC,
);
// --- Mode toggle handlers ---
const flipToAnthropic = async () => {
@@ -149,6 +173,28 @@ export function AIRoutingCard() {
}
};
const flipToSelfHosted = async () => {
if (!isSelfHostedConnected) {
toast.error("Test the self-hosted connection first");
return;
}
if (
!confirm(
"Switch every agent to the self-hosted LLM? Clears any overrides.",
)
)
return;
try {
await applyMode.mutateAsync({
mode: "self_hosted",
...(selfHostedModel ? { default_model: selfHostedModel } : {}),
});
toast.success("All agents now on Self-Hosted LLM");
} catch (e) {
toast.error("Switch failed: " + errMsg(e));
}
};
const saveMix = async () => {
// Filter out empty picks (nothing selected = inherit global).
const per_agent: Record<string, string> = {};
@@ -172,6 +218,15 @@ export function AIRoutingCard() {
);
return;
}
const needsSelfHosted = Object.values(per_agent).some((m) =>
selfHostedModels.find((sh) => sh.model_name === m),
);
if (needsSelfHosted && !isSelfHostedConnected) {
toast.error(
"At least one agent is routed to a self-hosted model but the connection has not been tested",
);
return;
}
try {
await applyMode.mutateAsync({ mode: "mix", per_agent });
toast.success("Per-agent routing saved");
@@ -189,7 +244,8 @@ export function AIRoutingCard() {
<CardDescription>
Decide which model backs each agent. Anthropic uses the mounted
<code className="px-1"> ~/.claude </code> auth; Ollama Cloud uses
the API key you save below.
the API key you save below; Self-Hosted connects to any OpenAI-compatible
endpoint you run locally.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
@@ -242,10 +298,19 @@ export function AIRoutingCard() {
<Separator />
{/* -------- Self-Hosted LLM -------- */}
<SelfHostedSection
testResult={selfHostedTestResult}
onTestResult={handleSelfHostedTestResult}
onTestSuccess={() => undefined}
/>
<Separator />
{/* -------- Mode toggle -------- */}
<section className="space-y-3">
<Label className="text-sm font-medium">Routing mode</Label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<ModeButton
icon={<ShieldCheck className="h-4 w-4" />}
label="Anthropic"
@@ -266,6 +331,18 @@ export function AIRoutingCard() {
onClick={flipToOllama}
disabled={applyMode.isPending || !hasOllamaKey}
/>
<ModeButton
icon={<Server className="h-4 w-4" />}
label="Self-Hosted"
description={
isSelfHostedConnected
? "Every agent uses your self-hosted LLM endpoint."
: "Test the connection above first."
}
active={currentMode === "self_hosted"}
onClick={flipToSelfHosted}
disabled={applyMode.isPending || !isSelfHostedConnected}
/>
<ModeButton
icon={<Cpu className="h-4 w-4" />}
label="Mix"
@@ -287,6 +364,43 @@ export function AIRoutingCard() {
) : null}
</section>
{/* -------- Self-Hosted model picker (when self_hosted mode active) -------- */}
{currentMode === "self_hosted" && (
<>
<Separator />
<section className="space-y-2">
<Label className="text-sm font-medium">
Self-Hosted default model
</Label>
<p className="text-xs text-muted-foreground">
Choose which discovered model all agents should use in
self-hosted mode.
</p>
<Select
value={selfHostedModel || "__clear__"}
onValueChange={(v: string) =>
setSelfHostedModel(v === "__clear__" ? "" : v)
}
>
<SelectTrigger className="w-full max-w-sm">
<SelectValue placeholder="(use server default)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">(use server default)</SelectItem>
{selfHostedModels.map((m) => (
<SelectItem key={m.model_name} value={m.model_name}>
{m.display_name}
{m.display_name !== m.model_name
? `${m.model_name}`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
</section>
</>
)}
{/* -------- Mix-mode per-agent picker -------- */}
<Separator />
<section className="space-y-3">
@@ -327,16 +441,72 @@ export function AIRoutingCard() {
}))
}
>
<SelectTrigger>
<SelectTrigger className="w-full">
<SelectValue placeholder="(inherit)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">(inherit global)</SelectItem>
{catalogForMix.map((c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name} {c.model_name}
</SelectItem>
))}
{/* Anthropic models */}
{catalogAnthropicOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="anthropic" />
Anthropic
</SelectLabel>
{catalogAnthropicOnly.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Ollama Cloud models */}
{catalogOllamaOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="ollama" />
Ollama Cloud
</SelectLabel>
{catalogOllamaOnly.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Self-Hosted models */}
{selfHostedModels.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="self-hosted" />
Self-Hosted
</SelectLabel>
{selfHostedModels.map((m) => (
<SelectItem key={m.model_name} value={m.model_name}>
{m.display_name}
</SelectItem>
))}
</SelectGroup>
)}
{/* Fallback: un-grouped catalog when no grouping is possible */}
{catalogAnthropicOnly.length === 0 &&
catalogOllamaOnly.length === 0 &&
selfHostedModels.length === 0 &&
catalogForMix.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name} {c.model_name}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
@@ -402,3 +572,34 @@ function ModeButton({
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : "Unknown error";
}
// ---------------------------------------------------------------------------
// Small colored pill to distinguish providers in the Mix mode dropdown.
// ---------------------------------------------------------------------------
function ProviderBadge({
variant,
}: {
variant: "anthropic" | "ollama" | "self-hosted";
}) {
const styles: Record<string, string> = {
anthropic: "bg-blue-500/20 text-blue-700 dark:text-blue-400",
ollama: "bg-violet-500/20 text-violet-700 dark:text-violet-400",
"self-hosted": "bg-purple-500/20 text-purple-700 dark:text-purple-400",
};
const labels: Record<string, string> = {
anthropic: "A",
ollama: "O",
"self-hosted": "S",
};
return (
<span
className={
"mr-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[10px] font-bold " +
(styles[variant] ?? "")
}
>
{labels[variant]}
</span>
);
}
@@ -0,0 +1,363 @@
"use client";
import { useState, useCallback } from "react";
import {
useSelfHostedConfig,
useSetSelfHostedConfig,
useTestSelfHosted,
useSelfHostedModels,
useRefreshSelfHostedModels,
} from "@/hooks/use-providers";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
AlertTriangle,
CheckCircle2,
Eye,
EyeOff,
RefreshCw,
Server,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import type { SelfHostedTestResult } from "@/lib/api/providers";
// Relative-time helper (no date-fns dependency).
function relativeTime(isoDate: string | null): string {
if (!isoDate) return "never";
const diff = Date.now() - new Date(isoDate).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : "Unknown error";
}
/** Props exposed to the parent (ai-routing-card) so it can read test status. */
export interface SelfHostedSectionProps {
/** Called whenever a successful connection test completes. */
onTestSuccess?: (modelCount: number) => void;
/** The last known test result — driven by parent if the parent stores it. */
testResult?: SelfHostedTestResult | null;
/** Called when the user clicks "Test Connection" and we get any result. */
onTestResult?: (result: SelfHostedTestResult) => void;
}
export function SelfHostedSection({
onTestSuccess,
testResult,
onTestResult,
}: SelfHostedSectionProps) {
const { data: config } = useSelfHostedConfig();
const { data: models = [] } = useSelfHostedModels();
const saveConfig = useSetSelfHostedConfig();
const testConnection = useTestSelfHosted();
const refreshModels = useRefreshSelfHostedModels();
// Local form state
const [baseUrl, setBaseUrl] = useState("");
const [authToken, setAuthToken] = useState("");
const [showToken, setShowToken] = useState(false);
// Timestamps for "Last refreshed" label
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
const hasSavedUrl = !!(config?.base_url);
// ---- Save handler --------------------------------------------------------
const handleSave = async () => {
const url = baseUrl.trim();
if (!url) {
toast.error("Enter a base URL first");
return;
}
try {
await saveConfig.mutateAsync({
base_url: url,
...(authToken ? { auth_token: authToken } : {}),
});
toast.success("Self-hosted config saved");
setBaseUrl("");
setAuthToken("");
} catch (e) {
toast.error("Save failed: " + errMsg(e));
}
};
// ---- Test Connection handler ---------------------------------------------
const handleTest = async () => {
try {
const result = await testConnection.mutateAsync();
onTestResult?.(result);
if (result.ok) {
onTestSuccess?.(result.model_count ?? 0);
setLastRefreshed(new Date().toISOString());
const count = result.model_count ?? 0;
toast.success(
`Connected — ${count} model${count === 1 ? "" : "s"} available`,
);
} else {
toast.error(`Connection failed: ${result.error ?? "unknown error"}`);
}
} catch (e) {
toast.error("Test failed: " + errMsg(e));
}
};
// ---- Refresh Models handler ----------------------------------------------
const handleRefresh = useCallback(async () => {
try {
await refreshModels.mutateAsync();
setLastRefreshed(new Date().toISOString());
toast.success("Model list refreshed");
} catch (e) {
toast.error("Refresh failed: " + errMsg(e));
}
}, [refreshModels]);
// ---- Retry handler (from error empty state) ------------------------------
const handleRetry = () => handleTest();
// Determine which empty state to show, if any.
const showNoUrlState = !hasSavedUrl;
const showErrorState =
hasSavedUrl && testResult?.ok === false && !showNoUrlState;
const showNoModelsState =
hasSavedUrl &&
testResult?.ok === true &&
models.length === 0 &&
!showNoUrlState;
const showModelList =
hasSavedUrl && testResult?.ok === true && models.length > 0;
return (
<section className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">Self-Hosted LLM</Label>
{testResult?.ok === true && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600">
<CheckCircle2 className="h-3 w-3" /> connected
</span>
)}
{testResult?.ok === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-2 py-0.5 text-xs font-medium text-red-600">
<XCircle className="h-3 w-3" /> error
</span>
)}
</div>
{/* Base URL input */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Base URL</Label>
<div className="flex gap-2">
<Input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder={
hasSavedUrl
? config.base_url ?? "http://localhost:11434"
: "http://localhost:11434"
}
className="font-mono text-sm"
/>
</div>
</div>
{/* Auth token input with Eye toggle */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Auth token{" "}
<span className="text-muted-foreground/60">(optional)</span>
</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showToken ? "text" : "password"}
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder={
config?.has_token
? "•••••••••••• (leave blank to keep)"
: "sk-… or Bearer token"
}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowToken((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showToken ? "Hide token" : "Show token"}
>
{showToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button onClick={handleSave} disabled={saveConfig.isPending}>
{saveConfig.isPending ? "Saving…" : "Save"}
</Button>
</div>
{config?.has_token && (
<p className="text-xs text-muted-foreground">
A token is stored. Type a new value to update it.
</p>
)}
{!config?.has_token && (
<p className="text-xs text-muted-foreground">
Stored Fernet-encrypted server-side; never returned by the API.
</p>
)}
</div>
{/* Test Connection button + inline result badge */}
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={handleTest}
disabled={!hasSavedUrl || testConnection.isPending}
>
{testConnection.isPending ? (
<>
<RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Testing
</>
) : (
"Test Connection"
)}
</Button>
{/* Inline result badge */}
{testResult?.ok === true && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-medium text-emerald-700 dark:text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
Connected &mdash; {testResult.model_count ?? 0} model
{(testResult.model_count ?? 0) === 1 ? "" : "s"} available
</span>
)}
{testResult?.ok === false && (
<span className="inline-flex items-center gap-1 rounded-full bg-red-500/10 px-3 py-1 text-xs font-medium text-red-700 dark:text-red-400">
<XCircle className="h-3.5 w-3.5" />
{testResult.error ?? "Connection failed"}
</span>
)}
</div>
{/* ── Empty state 1: no base URL configured ── */}
{showNoUrlState && (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium">No base URL configured</p>
<p className="mt-1 text-xs">
Enter the URL of your self-hosted LLM endpoint (e.g.{" "}
<code>http://localhost:11434</code> for Ollama) and click{" "}
<strong>Save</strong>. Then click <strong>Test Connection</strong>{" "}
to verify and discover available models.
</p>
</div>
)}
{/* ── Empty state 2: error state ── */}
{showErrorState && (
<div className="rounded-md border border-red-200 bg-red-50 p-4 dark:border-red-900/40 dark:bg-red-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-600" />
<div className="flex-1 space-y-1 text-sm">
<p className="font-medium text-red-700 dark:text-red-400">
Last test failed
</p>
<p className="text-xs text-red-600 dark:text-red-500">
{testResult?.error ?? "Unknown error"}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRetry}
disabled={testConnection.isPending}
>
Retry
</Button>
</div>
</div>
)}
{/* ── Empty state 3: connected but 0 models ── */}
{showNoModelsState && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/40 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<p className="text-sm text-amber-700 dark:text-amber-400">
Connected but no models found. Pull a model first (e.g.{" "}
<code className="font-mono">ollama pull llama3.2</code>) then
click <strong>Refresh Models</strong>.
</p>
</div>
</div>
)}
{/* ── Model list ── */}
{showModelList && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Last refreshed:{" "}
<span className="font-medium">
{relativeTime(lastRefreshed)}
</span>
</p>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={refreshModels.isPending}
>
{refreshModels.isPending ? (
<>
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" />
Refreshing
</>
) : (
<>
<RefreshCw className="mr-1.5 h-3 w-3" />
Refresh Models
</>
)}
</Button>
</div>
<div className="divide-y rounded-md border">
{models.map((m) => (
<div
key={m.model_name}
className="flex items-center justify-between px-3 py-2"
>
<div>
<span className="text-sm font-medium">{m.display_name}</span>
<span className="ml-2 font-mono text-xs text-muted-foreground">
{m.model_name}
</span>
</div>
<Badge variant="secondary" className="text-xs">
auto-discovered
</Badge>
</div>
))}
</div>
</div>
)}
</section>
);
}
+60
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
providersApi,
type ApplyModePayload,
type SelfHostedConfigPayload,
} from "@/lib/api/providers";
export const providerKeys = {
@@ -9,6 +10,9 @@ export const providerKeys = {
catalog: () => [...providerKeys.all, "catalog"] as const,
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
mode: () => [...providerKeys.all, "mode"] as const,
selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const,
selfHostedModels: () => [...providerKeys.all, "self-hosted-models"] as const,
selfHostedTest: () => [...providerKeys.all, "self-hosted-test"] as const,
};
export function useCatalog() {
@@ -56,3 +60,59 @@ export function useApplyMode() {
},
});
}
// ---------------------------------------------------------------------------
// Self-hosted LLM hooks
// ---------------------------------------------------------------------------
/** Query: fetch saved self-hosted config (base_url + has_token flag). */
export function useSelfHostedConfig() {
return useQuery({
queryKey: providerKeys.selfHostedConfig(),
queryFn: () => providersApi.getSelfHostedConfig(),
staleTime: 30_000,
});
}
/** Mutation: save self-hosted base URL + optional auth token. */
export function useSetSelfHostedConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: SelfHostedConfigPayload) =>
providersApi.saveSelfHostedConfig(payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: providerKeys.selfHostedConfig() });
},
});
}
/** Mutation: test the self-hosted connection and return status + model count. */
export function useTestSelfHosted() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => providersApi.testSelfHosted(),
onSuccess: () => {
// After a successful test, also refresh the model list.
qc.invalidateQueries({ queryKey: providerKeys.selfHostedModels() });
},
});
}
/** Query: list models discovered from the self-hosted endpoint. */
export function useSelfHostedModels() {
return useQuery({
queryKey: providerKeys.selfHostedModels(),
queryFn: () => providersApi.getSelfHostedModels(),
staleTime: 2 * 60_000, // 2 minutes
});
}
/** Mutation: invalidate the cached model list so it is re-fetched from GET /self-hosted/models. */
export function useRefreshSelfHostedModels() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
await qc.invalidateQueries({ queryKey: providerKeys.selfHostedModels() });
},
});
}
+64 -1
View File
@@ -24,7 +24,7 @@ export interface ModelAssignment {
model_name: string;
}
export type RoutingMode = "anthropic" | "ollama" | "mix";
export type RoutingMode = "anthropic" | "ollama" | "self_hosted" | "mix";
export interface ModeSnapshot {
mode: RoutingMode;
@@ -38,6 +38,36 @@ export interface ApplyModePayload {
per_agent?: Record<string, string>;
}
// ---------------------------------------------------------------------------
// Self-hosted LLM types
// ---------------------------------------------------------------------------
/** Configuration stored server-side for the self-hosted provider. */
export interface SelfHostedConfig {
base_url: string | null;
has_token: boolean;
enabled: boolean;
}
/** Result of a test-connection call. */
export interface SelfHostedTestResult {
ok: boolean;
model_count: number | null;
error: string | null;
}
/** One model entry returned by the discovery endpoint. */
export interface SelfHostedModel {
model_name: string;
display_name: string;
}
/** Payload for saving the self-hosted config (base URL + optional token). */
export interface SelfHostedConfigPayload {
base_url: string;
auth_token?: string; // omit to leave token unchanged; "" to clear
}
export const providersApi = {
catalog: async (): Promise<CatalogEntry[]> => {
const { data } = await api.get<CatalogEntry[]>("/providers/catalog");
@@ -65,4 +95,37 @@ export const providersApi = {
const { data } = await api.post<ModeSnapshot>("/providers", payload);
return data;
},
// -------------------------------------------------------------------------
// Self-hosted provider
// -------------------------------------------------------------------------
getSelfHostedConfig: async (): Promise<SelfHostedConfig> => {
const { data } = await api.get<SelfHostedConfig>("/providers/self-hosted");
return data;
},
saveSelfHostedConfig: async (
payload: SelfHostedConfigPayload,
): Promise<SelfHostedConfig> => {
const { data } = await api.put<SelfHostedConfig>(
"/providers/self-hosted",
payload,
);
return data;
},
testSelfHosted: async (): Promise<SelfHostedTestResult> => {
const { data } = await api.post<SelfHostedTestResult>(
"/providers/self-hosted/test",
);
return data;
},
getSelfHostedModels: async (): Promise<SelfHostedModel[]> => {
const { data } = await api.get<SelfHostedModel[]>(
"/providers/self-hosted/models",
);
return data;
},
};