mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
co-authored by
Frontend Developer 1
Backend Developer 1
Renn F
parent
0daef044d2
commit
73b7c16211
@@ -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 — {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>
|
||||
);
|
||||
}
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user