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
@@ -0,0 +1,68 @@
|
|||||||
|
"""Idempotently seed the Self-Hosted (Ollama) LOCAL provider row.
|
||||||
|
|
||||||
|
Revision ID: 028_seed_self_hosted_provider
|
||||||
|
Revises: 027_system_settings
|
||||||
|
Create Date: 2026-06-12
|
||||||
|
|
||||||
|
The `provider_configs` table already has `type='local'` in the
|
||||||
|
`modelprovider` enum (seeded in 004_provider_routing). This migration
|
||||||
|
seeds the corresponding row so the Settings UI can configure a
|
||||||
|
self-hosted Ollama server without requiring an additional API call.
|
||||||
|
|
||||||
|
The row starts disabled with no base_url — the user configures those
|
||||||
|
via PUT /api/providers/self-hosted. ON CONFLICT DO NOTHING makes this
|
||||||
|
migration safe to re-run on a DB that already has the row (e.g. from
|
||||||
|
a prior manual insert or a future fresh-schema bootstrap).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "028_seed_self_hosted_provider"
|
||||||
|
down_revision = "027_system_settings"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO provider_configs
|
||||||
|
(id, name, type, base_url, auth_token_encrypted, enabled, created_at)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
gen_random_uuid(),
|
||||||
|
'Self-Hosted (Ollama)',
|
||||||
|
'local',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
false,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Delete any model_assignments that point to the LOCAL provider row first
|
||||||
|
# to avoid a FK RESTRICT violation on provider_configs.id.
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"DELETE FROM model_assignments "
|
||||||
|
"WHERE provider_config_id IN ("
|
||||||
|
" SELECT id FROM provider_configs WHERE name = 'Self-Hosted (Ollama)'"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"DELETE FROM provider_configs "
|
||||||
|
"WHERE name = 'Self-Hosted (Ollama)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
useApplyMode,
|
useApplyMode,
|
||||||
useCatalog,
|
useCatalog,
|
||||||
useOllamaKey,
|
useOllamaKey,
|
||||||
useRoutingMode,
|
useRoutingMode,
|
||||||
useSetOllamaKey,
|
useSetOllamaKey,
|
||||||
|
useSelfHostedModels,
|
||||||
} from "@/hooks/use-providers";
|
} from "@/hooks/use-providers";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -21,7 +22,9 @@ import { Label } from "@/components/ui/label";
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
@@ -31,12 +34,14 @@ import {
|
|||||||
Cpu,
|
Cpu,
|
||||||
Key,
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AssignmentScope, ModelProvider } from "@/types";
|
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.
|
// 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
|
// 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: catalog = [] } = useCatalog();
|
||||||
const { data: keyStatus } = useOllamaKey();
|
const { data: keyStatus } = useOllamaKey();
|
||||||
const { data: snapshot } = useRoutingMode();
|
const { data: snapshot } = useRoutingMode();
|
||||||
|
const { data: selfHostedModels = [] } = useSelfHostedModels();
|
||||||
|
|
||||||
const setKey = useSetOllamaKey();
|
const setKey = useSetOllamaKey();
|
||||||
const applyMode = useApplyMode();
|
const applyMode = useApplyMode();
|
||||||
@@ -77,6 +83,21 @@ export function AIRoutingCard() {
|
|||||||
const hasOllamaKey = !!keyStatus?.has_key;
|
const hasOllamaKey = !!keyStatus?.has_key;
|
||||||
const currentMode: RoutingMode = snapshot?.mode ?? "anthropic";
|
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 ---
|
// --- API key input ---
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [clearKey, setClearKey] = useState(false);
|
const [clearKey, setClearKey] = useState(false);
|
||||||
@@ -123,6 +144,9 @@ export function AIRoutingCard() {
|
|||||||
const catalogOllamaOnly = catalog.filter(
|
const catalogOllamaOnly = catalog.filter(
|
||||||
(c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.OLLAMA_CLOUD,
|
(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 ---
|
// --- Mode toggle handlers ---
|
||||||
const flipToAnthropic = async () => {
|
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 () => {
|
const saveMix = async () => {
|
||||||
// Filter out empty picks (nothing selected = inherit global).
|
// Filter out empty picks (nothing selected = inherit global).
|
||||||
const per_agent: Record<string, string> = {};
|
const per_agent: Record<string, string> = {};
|
||||||
@@ -172,6 +218,15 @@ export function AIRoutingCard() {
|
|||||||
);
|
);
|
||||||
return;
|
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 {
|
try {
|
||||||
await applyMode.mutateAsync({ mode: "mix", per_agent });
|
await applyMode.mutateAsync({ mode: "mix", per_agent });
|
||||||
toast.success("Per-agent routing saved");
|
toast.success("Per-agent routing saved");
|
||||||
@@ -189,7 +244,8 @@ export function AIRoutingCard() {
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
Decide which model backs each agent. Anthropic uses the mounted
|
Decide which model backs each agent. Anthropic uses the mounted
|
||||||
<code className="px-1"> ~/.claude </code> auth; Ollama Cloud uses
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
@@ -242,10 +298,19 @@ export function AIRoutingCard() {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* -------- Self-Hosted LLM -------- */}
|
||||||
|
<SelfHostedSection
|
||||||
|
testResult={selfHostedTestResult}
|
||||||
|
onTestResult={handleSelfHostedTestResult}
|
||||||
|
onTestSuccess={() => undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* -------- Mode toggle -------- */}
|
{/* -------- Mode toggle -------- */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<Label className="text-sm font-medium">Routing mode</Label>
|
<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
|
<ModeButton
|
||||||
icon={<ShieldCheck className="h-4 w-4" />}
|
icon={<ShieldCheck className="h-4 w-4" />}
|
||||||
label="Anthropic"
|
label="Anthropic"
|
||||||
@@ -266,6 +331,18 @@ export function AIRoutingCard() {
|
|||||||
onClick={flipToOllama}
|
onClick={flipToOllama}
|
||||||
disabled={applyMode.isPending || !hasOllamaKey}
|
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
|
<ModeButton
|
||||||
icon={<Cpu className="h-4 w-4" />}
|
icon={<Cpu className="h-4 w-4" />}
|
||||||
label="Mix"
|
label="Mix"
|
||||||
@@ -287,6 +364,43 @@ export function AIRoutingCard() {
|
|||||||
) : null}
|
) : null}
|
||||||
</section>
|
</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 -------- */}
|
{/* -------- Mix-mode per-agent picker -------- */}
|
||||||
<Separator />
|
<Separator />
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
@@ -327,16 +441,72 @@ export function AIRoutingCard() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="(inherit)" />
|
<SelectValue placeholder="(inherit)" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__clear__">(inherit global)</SelectItem>
|
<SelectItem value="__clear__">(inherit global)</SelectItem>
|
||||||
{catalogForMix.map((c: { model_name: string; display_name: string }) => (
|
|
||||||
|
{/* 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}>
|
<SelectItem key={c.model_name} value={c.model_name}>
|
||||||
{c.display_name} — {c.model_name}
|
{c.display_name} — {c.model_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,3 +572,34 @@ function ModeButton({
|
|||||||
function errMsg(e: unknown): string {
|
function errMsg(e: unknown): string {
|
||||||
return e instanceof Error ? e.message : "Unknown error";
|
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 {
|
import {
|
||||||
providersApi,
|
providersApi,
|
||||||
type ApplyModePayload,
|
type ApplyModePayload,
|
||||||
|
type SelfHostedConfigPayload,
|
||||||
} from "@/lib/api/providers";
|
} from "@/lib/api/providers";
|
||||||
|
|
||||||
export const providerKeys = {
|
export const providerKeys = {
|
||||||
@@ -9,6 +10,9 @@ export const providerKeys = {
|
|||||||
catalog: () => [...providerKeys.all, "catalog"] as const,
|
catalog: () => [...providerKeys.all, "catalog"] as const,
|
||||||
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
|
ollamaKey: () => [...providerKeys.all, "ollama-key"] as const,
|
||||||
mode: () => [...providerKeys.all, "mode"] 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() {
|
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;
|
model_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RoutingMode = "anthropic" | "ollama" | "mix";
|
export type RoutingMode = "anthropic" | "ollama" | "self_hosted" | "mix";
|
||||||
|
|
||||||
export interface ModeSnapshot {
|
export interface ModeSnapshot {
|
||||||
mode: RoutingMode;
|
mode: RoutingMode;
|
||||||
@@ -38,6 +38,36 @@ export interface ApplyModePayload {
|
|||||||
per_agent?: Record<string, string>;
|
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 = {
|
export const providersApi = {
|
||||||
catalog: async (): Promise<CatalogEntry[]> => {
|
catalog: async (): Promise<CatalogEntry[]> => {
|
||||||
const { data } = await api.get<CatalogEntry[]>("/providers/catalog");
|
const { data } = await api.get<CatalogEntry[]>("/providers/catalog");
|
||||||
@@ -65,4 +95,37 @@ export const providersApi = {
|
|||||||
const { data } = await api.post<ModeSnapshot>("/providers", payload);
|
const { data } = await api.post<ModeSnapshot>("/providers", payload);
|
||||||
return data;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Provider Routes
|
Provider Routes
|
||||||
|
|
||||||
Thin HTTP plumbing for the Settings UI's AI-routing panel. Four endpoints
|
Thin HTTP plumbing for the Settings UI's AI-routing panel. Endpoints
|
||||||
cover the whole UX: fetch the catalog, get / set the Ollama key, fetch the
|
cover the whole UX: fetch the catalog, get / set the Ollama key,
|
||||||
current mode + assignments, apply a mode change. No provider CRUD — the
|
configure / test / discover the self-hosted server, fetch the current
|
||||||
two providers (Anthropic + Ollama Cloud) are pre-seeded by migration 004.
|
mode + assignments, apply a mode change. No provider CRUD — the
|
||||||
|
providers (Anthropic, Ollama Cloud, Self-Hosted) are pre-seeded by
|
||||||
|
migrations 004 and 028.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
@@ -15,14 +17,19 @@ from roboco.api.schemas.provider import (
|
|||||||
CatalogEntryResponse,
|
CatalogEntryResponse,
|
||||||
ModeResponse,
|
ModeResponse,
|
||||||
OllamaKeyStatus,
|
OllamaKeyStatus,
|
||||||
|
SelfHostedConfigRequest,
|
||||||
|
SelfHostedConfigResponse,
|
||||||
|
SelfHostedModelEntry,
|
||||||
|
SelfHostedTestResponse,
|
||||||
SetOllamaKeyRequest,
|
SetOllamaKeyRequest,
|
||||||
assignment_to_response,
|
assignment_to_response,
|
||||||
)
|
)
|
||||||
from roboco.models.base import ModelProvider
|
from roboco.models.base import ModelProvider
|
||||||
from roboco.models.llm_catalog import MODEL_CATALOG
|
from roboco.models.llm_catalog import MODEL_CATALOG
|
||||||
from roboco.services.base import NotFoundError
|
from roboco.services.base import NotFoundError
|
||||||
from roboco.services.llm import get_model_routing_service
|
from roboco.services.llm import get_model_routing_service, probe_ollama_tags
|
||||||
from roboco.services.provider import get_provider_service
|
from roboco.services.provider import ProviderUpdate, get_provider_service
|
||||||
|
from roboco.utils.converters import require_uuid
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -106,6 +113,172 @@ async def set_ollama_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SELF-HOSTED (LOCAL) OLLAMA SERVER
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/self-hosted", response_model=SelfHostedConfigResponse)
|
||||||
|
async def get_self_hosted_config(
|
||||||
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
|
) -> SelfHostedConfigResponse:
|
||||||
|
"""Return the current configuration of the LOCAL (self-hosted) provider.
|
||||||
|
|
||||||
|
The LOCAL provider row must be seeded (migration 028). Returns
|
||||||
|
``{base_url, has_token, enabled}`` so the Settings UI can display
|
||||||
|
the current state without exposing the encrypted token.
|
||||||
|
"""
|
||||||
|
require_pm_or_above(agent.role, "view the self-hosted provider config")
|
||||||
|
provider_svc = get_provider_service(db)
|
||||||
|
providers = await provider_svc.list_providers(include_disabled=True)
|
||||||
|
local = next(
|
||||||
|
(p for p in providers if p.type == ModelProvider.LOCAL),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if local is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=(
|
||||||
|
"Self-Hosted provider not seeded. "
|
||||||
|
"Run alembic upgrade head (migration 028)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return SelfHostedConfigResponse(
|
||||||
|
base_url=local.base_url,
|
||||||
|
has_token=bool(local.auth_token_encrypted),
|
||||||
|
enabled=local.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/self-hosted", response_model=SelfHostedConfigResponse)
|
||||||
|
async def set_self_hosted_config(
|
||||||
|
data: SelfHostedConfigRequest,
|
||||||
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
|
) -> SelfHostedConfigResponse:
|
||||||
|
"""Save the base URL (and optionally an auth token) for the LOCAL provider.
|
||||||
|
|
||||||
|
The LOCAL provider row must be seeded (migration 028). The token, when
|
||||||
|
provided and non-empty, is Fernet-encrypted before storing. An empty
|
||||||
|
string for `auth_token` clears the stored token. The provider is
|
||||||
|
automatically enabled only when a non-empty base_url is provided.
|
||||||
|
"""
|
||||||
|
require_pm_or_above(agent.role, "configure the self-hosted provider")
|
||||||
|
provider_svc = get_provider_service(db)
|
||||||
|
providers = await provider_svc.list_providers(include_disabled=True)
|
||||||
|
local = next(
|
||||||
|
(p for p in providers if p.type == ModelProvider.LOCAL),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if local is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=(
|
||||||
|
"Self-Hosted provider not seeded. "
|
||||||
|
"Run alembic upgrade head (migration 028)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine token update intent:
|
||||||
|
# None → leave unchanged, "" → clear, non-empty str → re-encrypt.
|
||||||
|
clear_token = data.auth_token is not None and data.auth_token == ""
|
||||||
|
new_token = data.auth_token if (data.auth_token and data.auth_token != "") else None
|
||||||
|
|
||||||
|
await provider_svc.update_provider(
|
||||||
|
require_uuid(local.id),
|
||||||
|
ProviderUpdate(
|
||||||
|
base_url=data.base_url,
|
||||||
|
auth_token=new_token,
|
||||||
|
clear_auth_token=clear_token,
|
||||||
|
# Only enable the provider when a non-empty base_url is provided.
|
||||||
|
enabled=bool(data.base_url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Re-fetch after commit to get the persisted state.
|
||||||
|
updated = await provider_svc.list_providers(include_disabled=True)
|
||||||
|
local_updated = next(p for p in updated if p.type == ModelProvider.LOCAL)
|
||||||
|
return SelfHostedConfigResponse(
|
||||||
|
base_url=local_updated.base_url,
|
||||||
|
has_token=bool(local_updated.auth_token_encrypted),
|
||||||
|
enabled=local_updated.enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/self-hosted/test", response_model=SelfHostedTestResponse)
|
||||||
|
async def test_self_hosted_connection(
|
||||||
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
|
) -> SelfHostedTestResponse:
|
||||||
|
"""Probe the configured self-hosted Ollama server.
|
||||||
|
|
||||||
|
Returns ``{ok: true, model_count: N}`` when the server is reachable
|
||||||
|
and returns a valid model list from ``{base_url}/api/tags``.
|
||||||
|
Returns ``{ok: false, error: '<message>'}`` on any failure — never
|
||||||
|
raises a 500, so the Settings UI can display a friendly error.
|
||||||
|
"""
|
||||||
|
require_pm_or_above(agent.role, "test the self-hosted connection")
|
||||||
|
provider_svc = get_provider_service(db)
|
||||||
|
providers = await provider_svc.list_providers(include_disabled=True)
|
||||||
|
local = next(
|
||||||
|
(p for p in providers if p.type == ModelProvider.LOCAL),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if local is None or not local.base_url:
|
||||||
|
return SelfHostedTestResponse(
|
||||||
|
ok=False,
|
||||||
|
error=(
|
||||||
|
"Self-hosted server is not configured."
|
||||||
|
" Set base_url first via PUT /self-hosted."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
models, error = await probe_ollama_tags(local.base_url)
|
||||||
|
if error is not None:
|
||||||
|
return SelfHostedTestResponse(ok=False, error=error)
|
||||||
|
return SelfHostedTestResponse(ok=True, model_count=len(models))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/self-hosted/models", response_model=list[SelfHostedModelEntry])
|
||||||
|
async def get_self_hosted_models(
|
||||||
|
db: DbSession,
|
||||||
|
agent: CurrentAgentContext,
|
||||||
|
) -> list[SelfHostedModelEntry]:
|
||||||
|
"""Return the list of models available on the self-hosted Ollama server.
|
||||||
|
|
||||||
|
Queries ``{base_url}/api/tags`` and returns ``[{model_name, display_name}]``
|
||||||
|
for each model entry. Raises 503 if the server is unreachable, 404 if
|
||||||
|
the LOCAL provider is not configured.
|
||||||
|
"""
|
||||||
|
require_pm_or_above(agent.role, "list self-hosted models")
|
||||||
|
provider_svc = get_provider_service(db)
|
||||||
|
providers = await provider_svc.list_providers(include_disabled=True)
|
||||||
|
local = next(
|
||||||
|
(p for p in providers if p.type == ModelProvider.LOCAL),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if local is None or not local.base_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=(
|
||||||
|
"Self-hosted provider is not configured. "
|
||||||
|
"Set base_url via PUT /self-hosted first."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
model_names, error = await probe_ollama_tags(local.base_url)
|
||||||
|
if error is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Self-hosted server unreachable: {error}",
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
SelfHostedModelEntry(model_name=name, display_name=name) for name in model_names
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MODE (the three-way toggle)
|
# MODE (the three-way toggle)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -122,7 +295,7 @@ async def get_current_mode(
|
|||||||
mode = await routing.derive_mode()
|
mode = await routing.derive_mode()
|
||||||
assignments = await routing.list_assignments()
|
assignments = await routing.list_assignments()
|
||||||
return ModeResponse(
|
return ModeResponse(
|
||||||
mode=mode, # type: ignore[arg-type]
|
mode=mode,
|
||||||
assignments=[assignment_to_response(a) for a in assignments],
|
assignments=[assignment_to_response(a) for a in assignments],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -157,6 +330,6 @@ async def apply_mode(
|
|||||||
mode = await routing.derive_mode()
|
mode = await routing.derive_mode()
|
||||||
assignments = await routing.list_assignments()
|
assignments = await routing.list_assignments()
|
||||||
return ModeResponse(
|
return ModeResponse(
|
||||||
mode=mode, # type: ignore[arg-type]
|
mode=mode,
|
||||||
assignments=[assignment_to_response(a) for a in assignments],
|
assignments=[assignment_to_response(a) for a in assignments],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ Providers API Schemas
|
|||||||
Minimal surface that backs the Settings UI:
|
Minimal surface that backs the Settings UI:
|
||||||
- fetch the preset catalog of selectable models
|
- fetch the preset catalog of selectable models
|
||||||
- set / clear / check the single Ollama Cloud API key
|
- set / clear / check the single Ollama Cloud API key
|
||||||
|
- configure / test / discover the self-hosted (LOCAL) Ollama server
|
||||||
- read current routing assignments (so the UI renders Mix mode)
|
- read current routing assignments (so the UI renders Mix mode)
|
||||||
- apply a routing mode (anthropic | ollama | mix)
|
- apply a routing mode (anthropic | ollama | mix | self_hosted)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -57,6 +58,66 @@ class SetOllamaKeyRequest(BaseModel):
|
|||||||
api_key: str = Field(default="")
|
api_key: str = Field(default="")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SELF-HOSTED (LOCAL) OLLAMA SERVER
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SelfHostedConfigRequest(BaseModel):
|
||||||
|
"""Save the base URL (and optionally an auth token) for the self-hosted server.
|
||||||
|
|
||||||
|
`base_url` is the root URL of the Ollama instance, e.g.
|
||||||
|
``http://192.168.1.50:11434``. The Settings UI sends this on every
|
||||||
|
save; the service stores it on the LOCAL provider row.
|
||||||
|
|
||||||
|
`auth_token`, when present and non-empty, is Fernet-encrypted before
|
||||||
|
storing. Pass ``None`` or omit to leave any existing token unchanged.
|
||||||
|
Pass an empty string to clear the stored token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
base_url: str = Field(..., description="Root URL of the self-hosted Ollama server")
|
||||||
|
auth_token: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional bearer token for the Ollama server; omit to leave unchanged"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SelfHostedConfigResponse(BaseModel):
|
||||||
|
"""Current configuration state of the LOCAL provider row."""
|
||||||
|
|
||||||
|
base_url: str | None
|
||||||
|
has_token: bool
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SelfHostedTestResponse(BaseModel):
|
||||||
|
"""Result of a connectivity probe to the self-hosted server.
|
||||||
|
|
||||||
|
The endpoint always returns HTTP 200; reachability is indicated by
|
||||||
|
the `ok` field so the UI can display a human-readable error without
|
||||||
|
triggering its generic error handler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
model_count: int | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SelfHostedModelEntry(BaseModel):
|
||||||
|
"""One model available on the self-hosted Ollama server.
|
||||||
|
|
||||||
|
`model_name` is the raw Ollama tag identifier (e.g. ``llama3.1:8b``).
|
||||||
|
`display_name` is a human-readable label for the Settings UI dropdown;
|
||||||
|
for self-hosted models it mirrors `model_name` since Ollama's ``/api/tags``
|
||||||
|
does not return a separate display label.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_name: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MODEL ASSIGNMENTS (read-only for the UI)
|
# MODEL ASSIGNMENTS (read-only for the UI)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -99,10 +160,13 @@ class ApplyModeRequest(BaseModel):
|
|||||||
`default_model` (if omitted, the service picks a sensible default).
|
`default_model` (if omitted, the service picks a sensible default).
|
||||||
- mode="mix": clear existing per-agent pins; upsert the `per_agent`
|
- mode="mix": clear existing per-agent pins; upsert the `per_agent`
|
||||||
map verbatim. Role + GLOBAL rows are left untouched so the user can
|
map verbatim. Role + GLOBAL rows are left untouched so the user can
|
||||||
layer with an existing partial setup.
|
layer with an existing partial setup. Self-hosted model names in
|
||||||
|
`per_agent` are routed to the LOCAL provider automatically.
|
||||||
|
- mode="self_hosted": clear every assignment; enable LOCAL provider;
|
||||||
|
set GLOBAL default to `default_model` (a self-hosted model name).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
mode: Literal["anthropic", "ollama", "mix"]
|
mode: Literal["anthropic", "ollama", "mix", "self_hosted"]
|
||||||
default_model: str | None = None
|
default_model: str | None = None
|
||||||
per_agent: dict[str, str] | None = None
|
per_agent: dict[str, str] | None = None
|
||||||
|
|
||||||
@@ -110,5 +174,5 @@ class ApplyModeRequest(BaseModel):
|
|||||||
class ModeResponse(BaseModel):
|
class ModeResponse(BaseModel):
|
||||||
"""Server-side view of the current mode + a snapshot of active rules."""
|
"""Server-side view of the current mode + a snapshot of active rules."""
|
||||||
|
|
||||||
mode: Literal["anthropic", "ollama", "mix"]
|
mode: Literal["anthropic", "ollama", "mix", "self_hosted"]
|
||||||
assignments: list[AssignmentResponse]
|
assignments: list[AssignmentResponse]
|
||||||
|
|||||||
@@ -191,7 +191,10 @@ class ModelProvider(StrEnum):
|
|||||||
`ANTHROPIC` is the built-in default — routed via the mounted `~/.claude/`
|
`ANTHROPIC` is the built-in default — routed via the mounted `~/.claude/`
|
||||||
credentials inside each agent container. `OLLAMA_CLOUD` routes via
|
credentials inside each agent container. `OLLAMA_CLOUD` routes via
|
||||||
`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` env injection at spawn.
|
`ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` env injection at spawn.
|
||||||
OPENAI / LOCAL are historical placeholders (unused today).
|
`LOCAL` is the self-hosted Ollama provider: the operator configures its
|
||||||
|
base URL via PUT /api/providers/self-hosted (seeded by migration 028).
|
||||||
|
Agents assigned to LOCAL are routed to that server at spawn time.
|
||||||
|
`OPENAI` is reserved for future use.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ANTHROPIC = "anthropic"
|
ANTHROPIC = "anthropic"
|
||||||
|
|||||||
+218
-35
@@ -10,13 +10,26 @@ If none apply, falls back to the legacy `ROLE_MODEL_MAP` + implicit
|
|||||||
Anthropic provider so deployments with zero rows behave exactly as
|
Anthropic provider so deployments with zero rows behave exactly as
|
||||||
before. Decryption failures are contained: the service logs the error
|
before. Decryption failures are contained: the service logs the error
|
||||||
and downgrades to the legacy path rather than failing the spawn.
|
and downgrades to the legacy path rather than failing the spawn.
|
||||||
|
|
||||||
|
Self-hosted (LOCAL) provider support:
|
||||||
|
- derive_mode() returns 'self_hosted' when there is exactly one
|
||||||
|
GLOBAL assignment pointing to a LOCAL provider.
|
||||||
|
- apply_mode('self_hosted', ...) enables the LOCAL provider and
|
||||||
|
sets a GLOBAL assignment to the given model name.
|
||||||
|
- upsert_assignment() accepts model names not in the MODEL_CATALOG
|
||||||
|
when the target provider is LOCAL (self-hosted models are dynamic;
|
||||||
|
they bypass catalog validation).
|
||||||
|
- resolve_for_agent() checks reachability of the LOCAL base_url
|
||||||
|
and falls back to Anthropic if the server is unreachable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, cast
|
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
from sqlalchemy import delete as sa_delete
|
from sqlalchemy import delete as sa_delete
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -39,6 +52,46 @@ if TYPE_CHECKING:
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level HTTP helper — decoupled from the service so tests can patch it.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_OLLAMA_TAGS_TIMEOUT = 5.0 # seconds
|
||||||
|
_log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_ollama_tags(base_url: str) -> tuple[list[str], str | None]:
|
||||||
|
"""Fetch the model list from a running Ollama server.
|
||||||
|
|
||||||
|
Hits ``{base_url}/api/tags`` and returns ``(model_names, None)`` on
|
||||||
|
success or ``([], error_message)`` on any failure. Never raises.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (list_of_model_name_strings, error_string_or_None).
|
||||||
|
"""
|
||||||
|
url = base_url.rstrip("/") + "/api/tags"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=_OLLAMA_TAGS_TIMEOUT) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
models: list[str] = [m["name"] for m in data.get("models", [])]
|
||||||
|
return models, None
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return [], f"Connection to {base_url} timed out after {_OLLAMA_TAGS_TIMEOUT}s"
|
||||||
|
except httpx.ConnectError:
|
||||||
|
return [], f"Could not connect to {base_url} — server may be offline"
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
return [], f"Server at {base_url} returned HTTP {exc.response.status_code}"
|
||||||
|
except Exception as exc:
|
||||||
|
_log.error(
|
||||||
|
"Unexpected error probing Ollama server",
|
||||||
|
base_url=base_url,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return [], "An unexpected error occurred while probing the self-hosted server."
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AgentRoute:
|
class AgentRoute:
|
||||||
"""Resolved routing for a single agent spawn.
|
"""Resolved routing for a single agent spawn.
|
||||||
@@ -71,28 +124,76 @@ class ModelRoutingService(BaseService):
|
|||||||
async def resolve_for_agent(self, agent_slug: str) -> AgentRoute:
|
async def resolve_for_agent(self, agent_slug: str) -> AgentRoute:
|
||||||
"""Resolve routing for `agent_slug` using the precedence ladder.
|
"""Resolve routing for `agent_slug` using the precedence ladder.
|
||||||
|
|
||||||
Never raises for a normal agent — decrypt failures and missing
|
Never raises for a normal agent — decrypt failures, unreachable
|
||||||
agents both downgrade to the legacy Anthropic path, because a
|
self-hosted servers, and missing agents all downgrade to the
|
||||||
stalled spawn is worse than a routing miss.
|
legacy Anthropic path, because a stalled spawn is worse than a
|
||||||
|
routing miss.
|
||||||
"""
|
"""
|
||||||
role = get_agent_role(agent_slug) or ""
|
role = get_agent_role(agent_slug) or ""
|
||||||
|
resolved = await self._resolve_assignment(agent_slug, role)
|
||||||
|
if resolved is not None and resolved.provider.enabled:
|
||||||
|
route = await self._route_from_resolved(resolved, agent_slug)
|
||||||
|
if route is not None:
|
||||||
|
return route
|
||||||
|
return self._legacy_route(role)
|
||||||
|
|
||||||
# 1) agent override
|
async def _resolve_assignment(
|
||||||
|
self, agent_slug: str, role: str
|
||||||
|
) -> _ResolvedAssignment | None:
|
||||||
|
"""Walk the precedence ladder: agent override > role override > global."""
|
||||||
resolved = await self._find_assignment(
|
resolved = await self._find_assignment(
|
||||||
scope=AssignmentScope.AGENT_SLUG, scope_value=agent_slug
|
scope=AssignmentScope.AGENT_SLUG, scope_value=agent_slug
|
||||||
)
|
)
|
||||||
# 2) role override
|
|
||||||
if resolved is None and role:
|
if resolved is None and role:
|
||||||
resolved = await self._find_assignment(
|
resolved = await self._find_assignment(
|
||||||
scope=AssignmentScope.ROLE, scope_value=role
|
scope=AssignmentScope.ROLE, scope_value=role
|
||||||
)
|
)
|
||||||
# 3) global default
|
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
resolved = await self._find_assignment(
|
resolved = await self._find_assignment(
|
||||||
scope=AssignmentScope.GLOBAL, scope_value=None
|
scope=AssignmentScope.GLOBAL, scope_value=None
|
||||||
)
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
if resolved is not None and resolved.provider.enabled:
|
async def _route_from_resolved(
|
||||||
|
self, resolved: _ResolvedAssignment, agent_slug: str
|
||||||
|
) -> AgentRoute | None:
|
||||||
|
"""Build a route from a resolved+enabled assignment.
|
||||||
|
|
||||||
|
Returns ``None`` to signal the caller should fall through to the
|
||||||
|
legacy Anthropic path (unreachable self-hosted server, empty
|
||||||
|
base_url, or a token-decrypt failure).
|
||||||
|
"""
|
||||||
|
if resolved.provider.type == ModelProvider.LOCAL:
|
||||||
|
return await self._local_route_or_none(resolved, agent_slug)
|
||||||
|
return await self._decrypt_route_or_none(resolved, agent_slug)
|
||||||
|
|
||||||
|
async def _local_route_or_none(
|
||||||
|
self, resolved: _ResolvedAssignment, agent_slug: str
|
||||||
|
) -> AgentRoute | None:
|
||||||
|
"""Route to a LOCAL provider only if it is configured and reachable.
|
||||||
|
|
||||||
|
Probes ``{base_url}/api/tags`` first; if the server is down (or no
|
||||||
|
base_url is configured) returns ``None`` so the spawn falls back to
|
||||||
|
Anthropic — better a wrong provider than no spawn.
|
||||||
|
"""
|
||||||
|
base_url = resolved.provider.base_url or ""
|
||||||
|
if not base_url:
|
||||||
|
return None # unconfigured → fall through
|
||||||
|
_, error = await probe_ollama_tags(base_url)
|
||||||
|
if error is not None:
|
||||||
|
self.log.warning(
|
||||||
|
"Self-hosted server unreachable; falling back to Anthropic",
|
||||||
|
base_url=base_url,
|
||||||
|
error=error,
|
||||||
|
agent_slug=agent_slug,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return await self._decrypt_route_or_none(resolved, agent_slug)
|
||||||
|
|
||||||
|
async def _decrypt_route_or_none(
|
||||||
|
self, resolved: _ResolvedAssignment, agent_slug: str
|
||||||
|
) -> AgentRoute | None:
|
||||||
|
"""Build the route, downgrading to ``None`` on a token-decrypt failure."""
|
||||||
try:
|
try:
|
||||||
return await self._route_from_assignment(resolved)
|
return await self._route_from_assignment(resolved)
|
||||||
except EncryptionError:
|
except EncryptionError:
|
||||||
@@ -101,8 +202,10 @@ class ModelRoutingService(BaseService):
|
|||||||
provider_id=str(resolved.provider.id),
|
provider_id=str(resolved.provider.id),
|
||||||
agent_slug=agent_slug,
|
agent_slug=agent_slug,
|
||||||
)
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
# 4) legacy fallback: role-default short name through MODEL_MAP.
|
def _legacy_route(self, role: str) -> AgentRoute:
|
||||||
|
"""Legacy fallback: role-default short name through MODEL_MAP."""
|
||||||
short = ROLE_MODEL_MAP.get(role, "sonnet")
|
short = ROLE_MODEL_MAP.get(role, "sonnet")
|
||||||
return AgentRoute(
|
return AgentRoute(
|
||||||
provider_id=None,
|
provider_id=None,
|
||||||
@@ -141,21 +244,49 @@ class ModelRoutingService(BaseService):
|
|||||||
scope: AssignmentScope,
|
scope: AssignmentScope,
|
||||||
scope_value: str | None,
|
scope_value: str | None,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
|
provider_type_override: ModelProvider | None = None,
|
||||||
) -> ModelAssignmentTable:
|
) -> ModelAssignmentTable:
|
||||||
"""Insert-or-update (by unique (scope, scope_value)).
|
"""Insert-or-update (by unique (scope, scope_value)).
|
||||||
|
|
||||||
Provider is derived from `MODEL_CATALOG` — the UI never picks a
|
Provider is normally derived from `MODEL_CATALOG` — the UI never
|
||||||
provider separately, so the service looks up the pre-seeded
|
picks a provider separately, so the service looks up the pre-seeded
|
||||||
provider row for the catalog entry's type.
|
provider row for the catalog entry's type.
|
||||||
|
|
||||||
|
When `provider_type_override` is supplied (used internally by
|
||||||
|
`apply_mode('self_hosted', ...)` and mix mode for LOCAL models),
|
||||||
|
the catalog look-up is skipped and the named provider type is used
|
||||||
|
directly. This allows self-hosted model names (which are not in the
|
||||||
|
static catalog) to be assigned to the LOCAL provider.
|
||||||
"""
|
"""
|
||||||
self._validate_scope(scope, scope_value)
|
self._validate_scope(scope, scope_value)
|
||||||
|
|
||||||
|
if provider_type_override is not None:
|
||||||
|
provider = await self._get_seeded_provider(provider_type_override)
|
||||||
|
provider_type_for_log = provider_type_override
|
||||||
|
else:
|
||||||
entry = MODEL_CATALOG_BY_NAME.get(model_name)
|
entry = MODEL_CATALOG_BY_NAME.get(model_name)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
|
# Try to route to LOCAL if a LOCAL provider is seeded — this
|
||||||
|
# allows self-hosted model names in mix mode without an error.
|
||||||
|
local_provider = await self._find_local_provider()
|
||||||
|
if local_provider is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unknown model '{model_name}'. Use one from "
|
f"Unknown model '{model_name}'. Use one from "
|
||||||
"GET /api/providers/catalog."
|
"GET /api/providers/catalog."
|
||||||
)
|
)
|
||||||
|
provider = local_provider
|
||||||
|
provider_type_for_log = ModelProvider.LOCAL
|
||||||
|
else:
|
||||||
provider = await self._get_seeded_provider(entry.provider_type)
|
provider = await self._get_seeded_provider(entry.provider_type)
|
||||||
|
provider_type_for_log = entry.provider_type
|
||||||
|
|
||||||
|
# Whenever an assignment resolves to LOCAL, ensure the LOCAL provider
|
||||||
|
# row is enabled so resolve_for_agent() will actually use it.
|
||||||
|
if provider_type_for_log == ModelProvider.LOCAL:
|
||||||
|
provider_svc = ProviderService(self.session)
|
||||||
|
await provider_svc.update_provider(
|
||||||
|
require_uuid(provider.id), ProviderUpdate(enabled=True)
|
||||||
|
)
|
||||||
|
|
||||||
row = await self.get_assignment(scope=scope, scope_value=scope_value)
|
row = await self.get_assignment(scope=scope, scope_value=scope_value)
|
||||||
if row is None:
|
if row is None:
|
||||||
@@ -175,17 +306,18 @@ class ModelRoutingService(BaseService):
|
|||||||
"Assignment upserted",
|
"Assignment upserted",
|
||||||
scope=scope.value,
|
scope=scope.value,
|
||||||
scope_value=scope_value,
|
scope_value=scope_value,
|
||||||
provider_type=entry.provider_type.value,
|
provider_type=provider_type_for_log.value,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
async def derive_mode(self) -> str:
|
async def derive_mode(self) -> Literal["anthropic", "ollama", "mix", "self_hosted"]:
|
||||||
"""Return the current "mode" label for the Settings UI.
|
"""Return the current "mode" label for the Settings UI.
|
||||||
|
|
||||||
Decision tree matches what `apply_mode` writes:
|
Decision tree matches what `apply_mode` writes:
|
||||||
- no assignments at all → "anthropic"
|
- no assignments at all → "anthropic"
|
||||||
- only a global row, Ollama → "ollama"
|
- only a global row, Ollama Cloud → "ollama"
|
||||||
|
- only a global row, LOCAL → "self_hosted"
|
||||||
- anything else → "mix"
|
- anything else → "mix"
|
||||||
"""
|
"""
|
||||||
assignments = await self.list_assignments()
|
assignments = await self.list_assignments()
|
||||||
@@ -194,11 +326,11 @@ class ModelRoutingService(BaseService):
|
|||||||
only_global = (
|
only_global = (
|
||||||
len(assignments) == 1 and assignments[0].scope == AssignmentScope.GLOBAL
|
len(assignments) == 1 and assignments[0].scope == AssignmentScope.GLOBAL
|
||||||
)
|
)
|
||||||
is_ollama = (
|
if only_global:
|
||||||
only_global and assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD
|
if assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD:
|
||||||
)
|
|
||||||
if is_ollama:
|
|
||||||
return "ollama"
|
return "ollama"
|
||||||
|
if assignments[0].provider.type == ModelProvider.LOCAL:
|
||||||
|
return "self_hosted"
|
||||||
return "mix"
|
return "mix"
|
||||||
|
|
||||||
async def set_ollama_api_key(self, api_key: str) -> ProviderConfigTable:
|
async def set_ollama_api_key(self, api_key: str) -> ProviderConfigTable:
|
||||||
@@ -271,37 +403,81 @@ class ModelRoutingService(BaseService):
|
|||||||
- "anthropic": wipe all assignments so every spawn falls through
|
- "anthropic": wipe all assignments so every spawn falls through
|
||||||
to the legacy ROLE_MODEL_MAP + mounted ~/.claude path.
|
to the legacy ROLE_MODEL_MAP + mounted ~/.claude path.
|
||||||
- "ollama": wipe role/agent overrides, set GLOBAL to the given
|
- "ollama": wipe role/agent overrides, set GLOBAL to the given
|
||||||
Ollama model (default: Kimi K2.6). CEO-type pins can be layered
|
Ollama model (default: OLLAMA_DEFAULT_MODEL). CEO-type pins can be
|
||||||
back manually if the user wants them.
|
layered back manually if the user wants them.
|
||||||
|
- "self_hosted": wipe all assignments, enable the LOCAL provider,
|
||||||
|
and set the GLOBAL default to `default_model` (a self-hosted
|
||||||
|
model name — not validated against the static catalog).
|
||||||
- "mix": apply per-agent map verbatim. Any agent not in the
|
- "mix": apply per-agent map verbatim. Any agent not in the
|
||||||
map falls through to the GLOBAL default — which is whatever it
|
map falls through to the GLOBAL default — which is whatever it
|
||||||
was (preserves prior state).
|
was (preserves prior state). Self-hosted model names (not in the
|
||||||
|
catalog) are automatically routed to the LOCAL provider.
|
||||||
"""
|
"""
|
||||||
if mode == "anthropic":
|
if mode == "anthropic":
|
||||||
|
await self._apply_anthropic()
|
||||||
|
elif mode == "ollama":
|
||||||
|
await self._apply_ollama(default_model)
|
||||||
|
elif mode == "self_hosted":
|
||||||
|
await self._apply_self_hosted(default_model)
|
||||||
|
elif mode == "mix":
|
||||||
|
await self._apply_mix(per_agent)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown mode '{mode}'."
|
||||||
|
" Use 'anthropic', 'ollama', 'self_hosted', or 'mix'."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _apply_anthropic(self) -> None:
|
||||||
|
"""Wipe all assignments so every spawn uses the legacy Anthropic path."""
|
||||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
self.log.info("Mode applied: anthropic (all assignments cleared)")
|
self.log.info("Mode applied: anthropic (all assignments cleared)")
|
||||||
return
|
|
||||||
|
|
||||||
if mode == "ollama":
|
async def _apply_ollama(self, default_model: str | None) -> None:
|
||||||
|
"""Wipe assignments, set the GLOBAL default to an Ollama Cloud model."""
|
||||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
|
model_name = default_model or OLLAMA_DEFAULT_MODEL
|
||||||
await self.upsert_assignment(
|
await self.upsert_assignment(
|
||||||
scope=AssignmentScope.GLOBAL,
|
scope=AssignmentScope.GLOBAL,
|
||||||
scope_value=None,
|
scope_value=None,
|
||||||
model_name=default_model or OLLAMA_DEFAULT_MODEL,
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
self.log.info(
|
self.log.info("Mode applied: ollama", default_model=model_name)
|
||||||
"Mode applied: ollama",
|
|
||||||
default_model=default_model or OLLAMA_DEFAULT_MODEL,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if mode == "mix":
|
async def _apply_self_hosted(self, default_model: str | None) -> None:
|
||||||
|
"""Wipe assignments, enable the LOCAL provider, point GLOBAL at it."""
|
||||||
|
if not default_model:
|
||||||
|
raise ValueError(
|
||||||
|
"self_hosted mode requires a default_model (self-hosted model name)"
|
||||||
|
)
|
||||||
|
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||||
|
await self.session.flush()
|
||||||
|
# Enable the LOCAL provider row so resolve_for_agent() will use it.
|
||||||
|
local = await self._find_local_provider()
|
||||||
|
if local is None:
|
||||||
|
raise NotFoundError(
|
||||||
|
resource_type="Provider",
|
||||||
|
resource_id=f"type={ModelProvider.LOCAL.value}",
|
||||||
|
)
|
||||||
|
provider_svc = ProviderService(self.session)
|
||||||
|
await provider_svc.update_provider(
|
||||||
|
require_uuid(local.id),
|
||||||
|
ProviderUpdate(enabled=True),
|
||||||
|
)
|
||||||
|
await self.upsert_assignment(
|
||||||
|
scope=AssignmentScope.GLOBAL,
|
||||||
|
scope_value=None,
|
||||||
|
model_name=default_model,
|
||||||
|
provider_type_override=ModelProvider.LOCAL,
|
||||||
|
)
|
||||||
|
self.log.info("Mode applied: self_hosted", default_model=default_model)
|
||||||
|
|
||||||
|
async def _apply_mix(self, per_agent: dict[str, str] | None) -> None:
|
||||||
|
"""Apply a per-agent override map; leave role + global rows untouched."""
|
||||||
if not per_agent:
|
if not per_agent:
|
||||||
raise ValueError("mix mode requires a per_agent map")
|
raise ValueError("mix mode requires a per_agent map")
|
||||||
# Clear existing agent-slug overrides so the new map is
|
# Clear existing agent-slug overrides so the new map is authoritative.
|
||||||
# authoritative; leave role + global alone.
|
|
||||||
await self.session.execute(
|
await self.session.execute(
|
||||||
sa_delete(ModelAssignmentTable).where(
|
sa_delete(ModelAssignmentTable).where(
|
||||||
ModelAssignmentTable.scope == AssignmentScope.AGENT_SLUG
|
ModelAssignmentTable.scope == AssignmentScope.AGENT_SLUG
|
||||||
@@ -311,15 +487,13 @@ class ModelRoutingService(BaseService):
|
|||||||
for agent_slug, model_name in per_agent.items():
|
for agent_slug, model_name in per_agent.items():
|
||||||
if not model_name:
|
if not model_name:
|
||||||
continue
|
continue
|
||||||
|
# upsert_assignment will route to LOCAL for non-catalog names.
|
||||||
await self.upsert_assignment(
|
await self.upsert_assignment(
|
||||||
scope=AssignmentScope.AGENT_SLUG,
|
scope=AssignmentScope.AGENT_SLUG,
|
||||||
scope_value=agent_slug,
|
scope_value=agent_slug,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
)
|
)
|
||||||
self.log.info("Mode applied: mix", agents=len(per_agent))
|
self.log.info("Mode applied: mix", agents=len(per_agent))
|
||||||
return
|
|
||||||
|
|
||||||
raise ValueError(f"Unknown mode '{mode}'. Use 'anthropic', 'ollama', or 'mix'.")
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# INTERNAL
|
# INTERNAL
|
||||||
@@ -334,6 +508,15 @@ class ModelRoutingService(BaseService):
|
|||||||
# Relationship is lazy="joined" in the ORM so `.provider` is loaded.
|
# Relationship is lazy="joined" in the ORM so `.provider` is loaded.
|
||||||
return _ResolvedAssignment(provider=row.provider, model_name=row.model_name)
|
return _ResolvedAssignment(provider=row.provider, model_name=row.model_name)
|
||||||
|
|
||||||
|
async def _find_local_provider(self) -> ProviderConfigTable | None:
|
||||||
|
"""Return the LOCAL provider row, or None if not seeded."""
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(ProviderConfigTable).where(
|
||||||
|
ProviderConfigTable.type == ModelProvider.LOCAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def _route_from_assignment(self, resolved: _ResolvedAssignment) -> AgentRoute:
|
async def _route_from_assignment(self, resolved: _ResolvedAssignment) -> AgentRoute:
|
||||||
provider = resolved.provider
|
provider = resolved.provider
|
||||||
# Decrypt only when the provider has a stored token (ollama_cloud).
|
# Decrypt only when the provider has a stored token (ollama_cloud).
|
||||||
|
|||||||
@@ -347,3 +347,246 @@ async def test_resolve_for_agent_falls_back_on_decrypt_error(
|
|||||||
route = await svc.resolve_for_agent("be-dev-1")
|
route = await svc.resolve_for_agent("be-dev-1")
|
||||||
# Falls back to legacy ANTHROPIC route.
|
# Falls back to legacy ANTHROPIC route.
|
||||||
assert route.provider_type == ModelProvider.ANTHROPIC
|
assert route.provider_type == ModelProvider.ANTHROPIC
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Self-hosted (LOCAL) provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def llm_setup_with_local(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> AsyncIterator[dict]:
|
||||||
|
"""Seed Anthropic, Ollama Cloud, and LOCAL provider rows."""
|
||||||
|
anthropic = ProviderConfigTable(
|
||||||
|
name="anthropic-test-local",
|
||||||
|
type=ModelProvider.ANTHROPIC,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
ollama = ProviderConfigTable(
|
||||||
|
name="ollama-test-local",
|
||||||
|
type=ModelProvider.OLLAMA_CLOUD,
|
||||||
|
enabled=True,
|
||||||
|
base_url="https://ollama.example.com",
|
||||||
|
)
|
||||||
|
local = ProviderConfigTable(
|
||||||
|
name="self-hosted-test",
|
||||||
|
type=ModelProvider.LOCAL,
|
||||||
|
enabled=True,
|
||||||
|
base_url="http://localhost:11434",
|
||||||
|
)
|
||||||
|
db_session.add_all([anthropic, ollama, local])
|
||||||
|
await db_session.flush()
|
||||||
|
yield {"svc": ModelRoutingService(db_session), "local": local}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_derive_mode_self_hosted_when_only_local_global(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Single GLOBAL assignment pointing to LOCAL → 'self_hosted' mode."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.GLOBAL,
|
||||||
|
scope_value=None,
|
||||||
|
model_name="llama3.1:8b",
|
||||||
|
provider_type_override=ModelProvider.LOCAL,
|
||||||
|
)
|
||||||
|
assert await svc.derive_mode() == "self_hosted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_mode_self_hosted_sets_global_local(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""apply_mode('self_hosted') clears assignments, enables LOCAL, inserts GLOBAL."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
await svc.apply_mode(mode="self_hosted", default_model="llama3.1:8b")
|
||||||
|
assignments = await svc.list_assignments()
|
||||||
|
assert len(assignments) == 1
|
||||||
|
assert assignments[0].scope == AssignmentScope.GLOBAL
|
||||||
|
assert assignments[0].provider.type == ModelProvider.LOCAL
|
||||||
|
assert assignments[0].model_name == "llama3.1:8b"
|
||||||
|
# Verify derive_mode reflects the new state.
|
||||||
|
assert await svc.derive_mode() == "self_hosted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_mode_self_hosted_requires_default_model(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""apply_mode('self_hosted') without default_model raises ValueError."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
with pytest.raises(ValueError, match="requires a default_model"):
|
||||||
|
await svc.apply_mode(mode="self_hosted")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_mode_self_hosted_clears_prior_assignments(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""apply_mode('self_hosted') clears ALL prior assignments."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC)
|
||||||
|
await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.AGENT_SLUG,
|
||||||
|
scope_value="be-dev-1",
|
||||||
|
model_name=anthropic_model,
|
||||||
|
)
|
||||||
|
await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.GLOBAL,
|
||||||
|
scope_value=None,
|
||||||
|
model_name=anthropic_model,
|
||||||
|
)
|
||||||
|
assert len(await svc.list_assignments()) == 2 # noqa: PLR2004
|
||||||
|
await svc.apply_mode(mode="self_hosted", default_model="gemma2:9b")
|
||||||
|
assignments = await svc.list_assignments()
|
||||||
|
assert len(assignments) == 1 # Only the new GLOBAL row.
|
||||||
|
assert assignments[0].provider.type == ModelProvider.LOCAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_assignment_routes_unknown_model_to_local(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Non-catalog model names are silently routed to LOCAL if seeded."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
row = await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.AGENT_SLUG,
|
||||||
|
scope_value="be-dev-1",
|
||||||
|
model_name="my-custom-model:latest",
|
||||||
|
)
|
||||||
|
assert row.provider.type == ModelProvider.LOCAL
|
||||||
|
assert row.model_name == "my-custom-model:latest"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mix_mode_with_self_hosted_models(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""mix mode accepts self-hosted model names without raising ValueError."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
anthropic_model = _first_model_for_type(ModelProvider.ANTHROPIC)
|
||||||
|
await svc.apply_mode(
|
||||||
|
mode="mix",
|
||||||
|
per_agent={
|
||||||
|
"be-dev-1": anthropic_model, # catalog model
|
||||||
|
"be-dev-2": "self-hosted-model:7b", # non-catalog → routed to LOCAL
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = await svc.list_assignments()
|
||||||
|
by_slug = {r.scope_value: r for r in rows if r.scope == AssignmentScope.AGENT_SLUG}
|
||||||
|
assert by_slug["be-dev-1"].provider.type == ModelProvider.ANTHROPIC
|
||||||
|
assert by_slug["be-dev-2"].provider.type == ModelProvider.LOCAL
|
||||||
|
assert by_slug["be-dev-2"].model_name == "self-hosted-model:7b"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_for_agent_self_hosted_returns_base_url(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""When LOCAL assignment is reachable, route has base_url from provider."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.GLOBAL,
|
||||||
|
scope_value=None,
|
||||||
|
model_name="llama3.1:8b",
|
||||||
|
provider_type_override=ModelProvider.LOCAL,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"roboco.services.llm.probe_ollama_tags",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(["llama3.1:8b"], None),
|
||||||
|
):
|
||||||
|
route = await svc.resolve_for_agent("be-dev-1")
|
||||||
|
assert route.provider_type == ModelProvider.LOCAL
|
||||||
|
assert route.base_url == "http://localhost:11434"
|
||||||
|
assert route.base_url is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolve_for_agent_falls_back_when_self_hosted_unreachable(
|
||||||
|
llm_setup_with_local: dict,
|
||||||
|
) -> None:
|
||||||
|
"""When LOCAL provider is unreachable, falls back to Anthropic default."""
|
||||||
|
svc = llm_setup_with_local["svc"]
|
||||||
|
await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.GLOBAL,
|
||||||
|
scope_value=None,
|
||||||
|
model_name="llama3.1:8b",
|
||||||
|
provider_type_override=ModelProvider.LOCAL,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"roboco.services.llm.probe_ollama_tags",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=([], "Could not connect to http://localhost:11434"),
|
||||||
|
):
|
||||||
|
route = await svc.resolve_for_agent("be-dev-1")
|
||||||
|
assert route.provider_type == ModelProvider.ANTHROPIC
|
||||||
|
assert route.base_url is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_assignment_unknown_model_without_local_raises(
|
||||||
|
llm_setup: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Without LOCAL provider seeded, non-catalog models raise ValueError."""
|
||||||
|
svc = llm_setup["svc"]
|
||||||
|
with pytest.raises(ValueError, match="Unknown model"):
|
||||||
|
await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.AGENT_SLUG,
|
||||||
|
scope_value="be-dev-1",
|
||||||
|
model_name="ghost-model:latest",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upsert_assignment_enables_local_when_disabled(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""upsert_assignment transitions LOCAL provider from enabled=False to enabled=True.
|
||||||
|
|
||||||
|
AC4: proves that mix-mode assignment of a non-catalog model name resolves
|
||||||
|
to provider_type LOCAL and LOCAL.enabled is True afterward — even when the
|
||||||
|
LOCAL provider starts with enabled=False (the seeded state from migration 028
|
||||||
|
before the operator configures a base_url via PUT /providers/self-hosted).
|
||||||
|
"""
|
||||||
|
# Arrange: Anthropic provider (required by ModelRoutingService internals)
|
||||||
|
# and LOCAL provider starting with enabled=False.
|
||||||
|
anthropic = ProviderConfigTable(
|
||||||
|
name="anthropic-ac4-test",
|
||||||
|
type=ModelProvider.ANTHROPIC,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
local = ProviderConfigTable(
|
||||||
|
name="self-hosted-ac4-test",
|
||||||
|
type=ModelProvider.LOCAL,
|
||||||
|
enabled=False, # Starts DISABLED — this is the state to be transitioned.
|
||||||
|
base_url="http://localhost:11434",
|
||||||
|
)
|
||||||
|
db_session.add_all([anthropic, local])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Pre-condition: LOCAL is disabled before the call.
|
||||||
|
assert local.enabled is False
|
||||||
|
|
||||||
|
# Act: upsert a non-catalog model name → resolves to LOCAL →
|
||||||
|
# calls ProviderService.update_provider(enabled=True) on the LOCAL row.
|
||||||
|
svc = ModelRoutingService(db_session)
|
||||||
|
row = await svc.upsert_assignment(
|
||||||
|
scope=AssignmentScope.AGENT_SLUG,
|
||||||
|
scope_value="test-agent-ac4",
|
||||||
|
model_name="non-catalog-model:7b",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh local from DB so the in-memory object reflects the DB write.
|
||||||
|
await db_session.refresh(local)
|
||||||
|
|
||||||
|
# Assert: assignment resolved to LOCAL and LOCAL provider is now enabled.
|
||||||
|
assert row.provider.type == ModelProvider.LOCAL
|
||||||
|
assert row.model_name == "non-catalog-model:7b"
|
||||||
|
assert local.enabled is True, (
|
||||||
|
"upsert_assignment must call update_provider(enabled=True) on LOCAL "
|
||||||
|
"whenever it routes a non-catalog model to the LOCAL provider"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Migration 028 tests — seed_self_hosted_provider.
|
||||||
|
|
||||||
|
Verifies the post-upgrade state and exercises the downgrade SQL ordering
|
||||||
|
to prove the FK-safe delete sequence works.
|
||||||
|
|
||||||
|
NOT a real alembic round-trip — the suite builds the test DB via
|
||||||
|
Base.metadata.create_all (see conftest). Migration 028's upgrade()/downgrade()
|
||||||
|
bodies are reviewed here; the tests guard the resulting DB-level contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
|
||||||
|
from roboco.models.base import AssignmentScope, ModelProvider
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_028_upgrade_insert_contract(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""The upgrade INSERT SQL seeds the correct LOCAL provider row and is idempotent.
|
||||||
|
|
||||||
|
Executes the INSERT ... ON CONFLICT DO NOTHING SQL from migration 028's
|
||||||
|
upgrade() directly in the test session, verifying:
|
||||||
|
- name='Self-Hosted (Ollama)', type='local', enabled=False on the row.
|
||||||
|
- Running the same INSERT a second time leaves exactly one row (idempotency).
|
||||||
|
"""
|
||||||
|
_insert_sql = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO provider_configs
|
||||||
|
(id, name, type, base_url, auth_token_encrypted, enabled, created_at)
|
||||||
|
VALUES
|
||||||
|
(
|
||||||
|
gen_random_uuid(),
|
||||||
|
'Self-Hosted (Ollama)',
|
||||||
|
'local',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
false,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (name) DO NOTHING
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- First run: the row should be inserted.
|
||||||
|
await db_session.execute(_insert_sql)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Verify the field contract on the newly-inserted row.
|
||||||
|
result = await db_session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT name, type, enabled "
|
||||||
|
"FROM provider_configs "
|
||||||
|
"WHERE name = 'Self-Hosted (Ollama)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = list(result)
|
||||||
|
assert len(rows) == 1
|
||||||
|
name, ptype, enabled = rows[0]
|
||||||
|
assert name == "Self-Hosted (Ollama)"
|
||||||
|
assert ptype == "local"
|
||||||
|
assert enabled is False # starts disabled; user configures via PUT /self-hosted
|
||||||
|
|
||||||
|
# --- Second run: ON CONFLICT DO NOTHING must not create a duplicate.
|
||||||
|
await db_session.execute(_insert_sql)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT id FROM provider_configs WHERE name = 'Self-Hosted (Ollama)'")
|
||||||
|
)
|
||||||
|
assert len(list(result)) == 1, (
|
||||||
|
"Expected exactly one 'Self-Hosted (Ollama)' row after two INSERT "
|
||||||
|
"executions; ON CONFLICT DO NOTHING must prevent duplicates."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_migration_028_downgrade_deletes_assignments_before_config(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""Downgrade SQL deletes model_assignments before provider_configs.
|
||||||
|
|
||||||
|
Simulates the downgrade() logic from migration 028:
|
||||||
|
1. DELETE FROM model_assignments WHERE provider_config_id IN (SELECT id ...)
|
||||||
|
2. DELETE FROM provider_configs WHERE name = 'Self-Hosted (Ollama)'
|
||||||
|
|
||||||
|
A FK RESTRICT constraint on model_assignments.provider_config_id means that
|
||||||
|
executing step 2 before step 1 would raise an IntegrityError. This test
|
||||||
|
proves that doing them in the correct order succeeds without violation.
|
||||||
|
"""
|
||||||
|
# --- Arrange: insert a fresh LOCAL provider row and a referencing assignment.
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
local = ProviderConfigTable(
|
||||||
|
name=f"Self-Hosted (Ollama)-test-{suffix}",
|
||||||
|
type=ModelProvider.LOCAL,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
db_session.add(local)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
assignment = ModelAssignmentTable(
|
||||||
|
scope=AssignmentScope.AGENT_SLUG,
|
||||||
|
scope_value=f"test-agent-{suffix}",
|
||||||
|
provider_config_id=local.id,
|
||||||
|
model_name="llama3.1:8b",
|
||||||
|
)
|
||||||
|
db_session.add(assignment)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# Verify both rows exist before we run the downgrade SQL.
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT id FROM provider_configs WHERE name = :name").bindparams(
|
||||||
|
name=local.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams(
|
||||||
|
sv=assignment.scope_value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
# --- Act: execute downgrade SQL in the correct FK-safe order.
|
||||||
|
# Step 1: delete referencing model_assignments first.
|
||||||
|
await db_session.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM model_assignments "
|
||||||
|
"WHERE provider_config_id IN ("
|
||||||
|
" SELECT id FROM provider_configs WHERE name = :name"
|
||||||
|
")"
|
||||||
|
).bindparams(name=local.name)
|
||||||
|
)
|
||||||
|
# Step 2: now safe to delete the provider row.
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM provider_configs WHERE name = :name").bindparams(
|
||||||
|
name=local.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Assert: both rows are gone, no IntegrityError was raised.
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT id FROM provider_configs WHERE name = :name").bindparams(
|
||||||
|
name=local.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None, (
|
||||||
|
"provider_configs row should be deleted by downgrade"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams(
|
||||||
|
sv=assignment.scope_value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert result.scalar_one_or_none() is None, (
|
||||||
|
"model_assignments row should be deleted before provider_configs"
|
||||||
|
)
|
||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -12,7 +13,7 @@ from fastapi import FastAPI
|
|||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from roboco.api.deps import get_agent_context, get_db
|
from roboco.api.deps import get_agent_context, get_db
|
||||||
from roboco.api.routes.provider import router as provider_router
|
from roboco.api.routes.provider import router as provider_router
|
||||||
from roboco.db.tables import ProviderConfigTable
|
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
|
||||||
from roboco.models import AgentRole, Team
|
from roboco.models import AgentRole, Team
|
||||||
from roboco.models.base import ModelProvider
|
from roboco.models.base import ModelProvider
|
||||||
from roboco.models.permissions import AgentContext
|
from roboco.models.permissions import AgentContext
|
||||||
@@ -77,12 +78,54 @@ async def app_client(
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def app_client_with_ollama(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> AsyncIterator[AsyncClient]:
|
||||||
|
"""App client pre-seeded with Anthropic and Ollama Cloud providers.
|
||||||
|
|
||||||
|
Begins with a DELETE-before-seed isolation step: deletes all rows from
|
||||||
|
ModelAssignmentTable (FK-safe) then ProviderConfigTable before adding
|
||||||
|
fresh ANTHROPIC + OLLAMA_CLOUD rows. This ensures tests are
|
||||||
|
order-independent regardless of what prior tests committed.
|
||||||
|
"""
|
||||||
|
app = _make_app(db_session)
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
# FK-safe cleanup: model_assignments.provider_config_id references
|
||||||
|
# provider_configs.id, so assignments must be deleted first.
|
||||||
|
await db_session.execute(delete(ModelAssignmentTable))
|
||||||
|
await db_session.execute(delete(ProviderConfigTable))
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.add(
|
||||||
|
ProviderConfigTable(
|
||||||
|
name=f"anthropic-test-{suffix}",
|
||||||
|
type=ModelProvider.ANTHROPIC,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.add(
|
||||||
|
ProviderConfigTable(
|
||||||
|
name=f"ollama-test-{suffix}",
|
||||||
|
type=ModelProvider.OLLAMA_CLOUD,
|
||||||
|
enabled=False,
|
||||||
|
base_url="https://ollama.example.com",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
_HDR_PM = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
|
_HDR_PM = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_catalog(app_client: AsyncClient) -> None:
|
async def test_get_catalog(app_client_with_ollama: AsyncClient) -> None:
|
||||||
response = await app_client.get("/api/providers/catalog", headers=_HDR_PM)
|
response = await app_client_with_ollama.get(
|
||||||
|
"/api/providers/catalog", headers=_HDR_PM
|
||||||
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
assert isinstance(response.json(), list)
|
assert isinstance(response.json(), list)
|
||||||
|
|
||||||
@@ -103,8 +146,10 @@ async def test_get_catalog_forbidden_for_developer(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_ollama_key_status(app_client: AsyncClient) -> None:
|
async def test_get_ollama_key_status(app_client_with_ollama: AsyncClient) -> None:
|
||||||
response = await app_client.get("/api/providers/ollama-key", headers=_HDR_PM)
|
response = await app_client_with_ollama.get(
|
||||||
|
"/api/providers/ollama-key", headers=_HDR_PM
|
||||||
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert "has_key" in body
|
assert "has_key" in body
|
||||||
@@ -112,8 +157,8 @@ async def test_get_ollama_key_status(app_client: AsyncClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_set_ollama_key(app_client: AsyncClient) -> None:
|
async def test_set_ollama_key(app_client_with_ollama: AsyncClient) -> None:
|
||||||
response = await app_client.put(
|
response = await app_client_with_ollama.put(
|
||||||
"/api/providers/ollama-key",
|
"/api/providers/ollama-key",
|
||||||
json={"api_key": "secret-key-123"},
|
json={"api_key": "secret-key-123"},
|
||||||
headers=_HDR_PM,
|
headers=_HDR_PM,
|
||||||
@@ -124,8 +169,8 @@ async def test_set_ollama_key(app_client: AsyncClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_current_mode(app_client: AsyncClient) -> None:
|
async def test_get_current_mode(app_client_with_ollama: AsyncClient) -> None:
|
||||||
response = await app_client.get("/api/providers", headers=_HDR_PM)
|
response = await app_client_with_ollama.get("/api/providers", headers=_HDR_PM)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["mode"] in {"anthropic", "ollama", "mix"}
|
assert body["mode"] in {"anthropic", "ollama", "mix"}
|
||||||
@@ -133,9 +178,9 @@ async def test_get_current_mode(app_client: AsyncClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_mode_anthropic_clears_assignments(
|
async def test_apply_mode_anthropic_clears_assignments(
|
||||||
app_client: AsyncClient,
|
app_client_with_ollama: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
response = await app_client.post(
|
response = await app_client_with_ollama.post(
|
||||||
"/api/providers", json={"mode": "anthropic"}, headers=_HDR_PM
|
"/api/providers", json={"mode": "anthropic"}, headers=_HDR_PM
|
||||||
)
|
)
|
||||||
assert response.status_code == HTTPStatus.OK
|
assert response.status_code == HTTPStatus.OK
|
||||||
@@ -144,9 +189,11 @@ async def test_apply_mode_anthropic_clears_assignments(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_mode_unknown_returns_4xx(app_client: AsyncClient) -> None:
|
async def test_apply_mode_unknown_returns_4xx(
|
||||||
|
app_client_with_ollama: AsyncClient,
|
||||||
|
) -> None:
|
||||||
"""Unknown mode is rejected — Pydantic 422 at schema layer or 400 at service."""
|
"""Unknown mode is rejected — Pydantic 422 at schema layer or 400 at service."""
|
||||||
response = await app_client.post(
|
response = await app_client_with_ollama.post(
|
||||||
"/api/providers", json={"mode": "quantum"}, headers=_HDR_PM
|
"/api/providers", json={"mode": "quantum"}, headers=_HDR_PM
|
||||||
)
|
)
|
||||||
assert response.status_code in (
|
assert response.status_code in (
|
||||||
@@ -229,10 +276,10 @@ async def test_get_mode_developer_forbidden(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_mode_mix_without_per_agent_returns_400(
|
async def test_apply_mode_mix_without_per_agent_returns_400(
|
||||||
app_client: AsyncClient,
|
app_client_with_ollama: AsyncClient,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply 'mix' mode without per_agent triggers ValueError → 400 (lines 149-152)."""
|
"""Apply 'mix' mode without per_agent triggers ValueError → 400 (lines 149-152)."""
|
||||||
response = await app_client.post(
|
response = await app_client_with_ollama.post(
|
||||||
"/api/providers",
|
"/api/providers",
|
||||||
json={"mode": "mix"},
|
json={"mode": "mix"},
|
||||||
headers=_HDR_PM,
|
headers=_HDR_PM,
|
||||||
@@ -259,4 +306,318 @@ async def test_apply_mode_ollama_without_provider_returns_404(
|
|||||||
"/api/providers", json={"mode": "ollama"}, headers=_HDR_PM
|
"/api/providers", json={"mode": "ollama"}, headers=_HDR_PM
|
||||||
)
|
)
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.OK)
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Self-hosted endpoints
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def app_client_with_local(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> AsyncIterator[AsyncClient]:
|
||||||
|
"""App client pre-seeded with Anthropic, Ollama Cloud, and LOCAL providers.
|
||||||
|
|
||||||
|
Begins with a DELETE-before-seed isolation step: deletes all rows from
|
||||||
|
ModelAssignmentTable (FK-safe) then ProviderConfigTable before adding
|
||||||
|
fresh rows. This ensures tests are order-independent regardless of what
|
||||||
|
prior tests committed.
|
||||||
|
"""
|
||||||
|
app = _make_app(db_session)
|
||||||
|
suffix = uuid4().hex[:8]
|
||||||
|
# FK-safe cleanup: model_assignments.provider_config_id references
|
||||||
|
# provider_configs.id, so assignments must be deleted first.
|
||||||
|
await db_session.execute(delete(ModelAssignmentTable))
|
||||||
|
await db_session.execute(delete(ProviderConfigTable))
|
||||||
|
await db_session.flush()
|
||||||
|
db_session.add(
|
||||||
|
ProviderConfigTable(
|
||||||
|
name=f"anthropic-local-{suffix}",
|
||||||
|
type=ModelProvider.ANTHROPIC,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.add(
|
||||||
|
ProviderConfigTable(
|
||||||
|
name=f"ollama-local-{suffix}",
|
||||||
|
type=ModelProvider.OLLAMA_CLOUD,
|
||||||
|
enabled=False,
|
||||||
|
base_url="https://ollama.example.com",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_session.add(
|
||||||
|
ProviderConfigTable(
|
||||||
|
name=f"self-hosted-local-{suffix}",
|
||||||
|
type=ModelProvider.LOCAL,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
yield client
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_self_hosted_saves_base_url(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /self-hosted saves base_url and enables the LOCAL provider."""
|
||||||
|
response = await app_client_with_local.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://192.168.1.10:11434"},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
body = response.json()
|
||||||
|
assert body["base_url"] == "http://192.168.1.10:11434"
|
||||||
|
assert body["enabled"] is True
|
||||||
|
assert body["has_token"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_self_hosted_with_token_stores_encrypted(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /self-hosted with auth_token stores Fernet-encrypted token."""
|
||||||
|
response = await app_client_with_local.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={
|
||||||
|
"base_url": "http://192.168.1.10:11434",
|
||||||
|
"auth_token": "secret-ollama-key",
|
||||||
|
},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
body = response.json()
|
||||||
|
assert body["has_token"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_self_hosted_not_seeded_returns_404(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /self-hosted when LOCAL provider not seeded returns 404."""
|
||||||
|
await db_session.execute(
|
||||||
|
delete(ProviderConfigTable).where(
|
||||||
|
ProviderConfigTable.type == ModelProvider.LOCAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
app = _make_app(db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://localhost:11434"},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_put_self_hosted_developer_forbidden(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""PUT /self-hosted is forbidden for developer role."""
|
||||||
|
app = _make_app(db_session, role=AgentRole.DEVELOPER, team=Team.BACKEND)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://localhost:11434"},
|
||||||
|
headers={"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"},
|
||||||
|
)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_test_self_hosted_when_reachable(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""POST /self-hosted/test returns {ok: true, model_count: N} when reachable."""
|
||||||
|
# First configure the base_url.
|
||||||
|
await app_client_with_local.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://192.168.1.10:11434"},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"roboco.api.routes.provider.probe_ollama_tags",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(["llama3.1:8b", "gemma2:9b"], None),
|
||||||
|
):
|
||||||
|
response = await app_client_with_local.post(
|
||||||
|
"/api/providers/self-hosted/test",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
body = response.json()
|
||||||
|
# Contract: field names and types for the test response schema.
|
||||||
|
assert "ok" in body
|
||||||
|
assert "model_count" in body
|
||||||
|
assert "error" in body
|
||||||
|
assert isinstance(body["ok"], bool)
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert body["model_count"] == 2 # noqa: PLR2004
|
||||||
|
assert body["error"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_test_self_hosted_when_unreachable(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""POST /self-hosted/test returns {ok: false, error: '...'} when unreachable."""
|
||||||
|
await app_client_with_local.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://192.168.1.10:11434"},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"roboco.api.routes.provider.probe_ollama_tags",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=([], "Could not connect to http://192.168.1.10:11434"),
|
||||||
|
):
|
||||||
|
response = await app_client_with_local.post(
|
||||||
|
"/api/providers/self-hosted/test",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
# Must be 200 with ok=false, NOT 500.
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is False
|
||||||
|
assert body["error"] is not None
|
||||||
|
assert body["model_count"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_test_self_hosted_not_configured(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""POST /self-hosted/test when no base_url returns {ok: false} without 500."""
|
||||||
|
# LOCAL provider seeded but no base_url configured.
|
||||||
|
response = await app_client_with_local.post(
|
||||||
|
"/api/providers/self-hosted/test",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is False
|
||||||
|
assert body["error"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_self_hosted_config_returns_200(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""GET /self-hosted returns {base_url, has_token, enabled} when LOCAL is seeded."""
|
||||||
|
response = await app_client_with_local.get(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
body = response.json()
|
||||||
|
# Contract: field names and types must match the schema.
|
||||||
|
assert "base_url" in body
|
||||||
|
assert "has_token" in body
|
||||||
|
assert "enabled" in body
|
||||||
|
assert isinstance(body["has_token"], bool)
|
||||||
|
assert isinstance(body["enabled"], bool)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_self_hosted_config_not_seeded_returns_404(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
"""GET /self-hosted when LOCAL provider not seeded returns 404."""
|
||||||
|
await db_session.execute(
|
||||||
|
delete(ProviderConfigTable).where(
|
||||||
|
ProviderConfigTable.type == ModelProvider.LOCAL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
app = _make_app(db_session)
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_self_hosted_models_returns_list(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""GET /self-hosted/models returns [{model_name, display_name}] objects."""
|
||||||
|
await app_client_with_local.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://192.168.1.10:11434"},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"roboco.api.routes.provider.probe_ollama_tags",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(["llama3.1:8b", "gemma2:9b", "qwen2.5:14b"], None),
|
||||||
|
):
|
||||||
|
response = await app_client_with_local.get(
|
||||||
|
"/api/providers/self-hosted/models",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.OK
|
||||||
|
models = response.json()
|
||||||
|
assert isinstance(models, list)
|
||||||
|
assert len(models) == 3 # noqa: PLR2004
|
||||||
|
# Contract: each entry must be an object with model_name and display_name.
|
||||||
|
first = models[0]
|
||||||
|
assert isinstance(first, dict)
|
||||||
|
assert "model_name" in first
|
||||||
|
assert "display_name" in first
|
||||||
|
assert isinstance(first["model_name"], str)
|
||||||
|
assert isinstance(first["display_name"], str)
|
||||||
|
# Verify specific entry present.
|
||||||
|
names = [m["model_name"] for m in models]
|
||||||
|
assert "llama3.1:8b" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_self_hosted_models_not_configured_returns_404(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""GET /self-hosted/models when no base_url configured returns 404."""
|
||||||
|
response = await app_client_with_local.get(
|
||||||
|
"/api/providers/self-hosted/models",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_self_hosted_models_unreachable_returns_503(
|
||||||
|
app_client_with_local: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""GET /self-hosted/models when server unreachable returns 503."""
|
||||||
|
await app_client_with_local.put(
|
||||||
|
"/api/providers/self-hosted",
|
||||||
|
json={"base_url": "http://192.168.1.10:11434"},
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"roboco.api.routes.provider.probe_ollama_tags",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=([], "Could not connect"),
|
||||||
|
):
|
||||||
|
response = await app_client_with_local.get(
|
||||||
|
"/api/providers/self-hosted/models",
|
||||||
|
headers=_HDR_PM,
|
||||||
|
)
|
||||||
|
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Unit tests for roboco.services.llm.probe_ollama_tags.
|
||||||
|
|
||||||
|
Covers all five branches of the function:
|
||||||
|
1. Successful JSON parse → returns model names list
|
||||||
|
2. httpx.TimeoutException → returns ([], timeout message)
|
||||||
|
3. httpx.ConnectError → returns ([], connect-error message)
|
||||||
|
4. httpx.HTTPStatusError → returns ([], http-status message)
|
||||||
|
5. Generic Exception → logs server-side and returns ([], generic error string)
|
||||||
|
|
||||||
|
All tests mock `httpx.AsyncClient` so they require no network or DB.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from roboco.services.llm import probe_ollama_tags
|
||||||
|
|
||||||
|
_BASE_URL = "http://localhost:11434"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_ollama_tags_success() -> None:
|
||||||
|
"""Successful /api/tags response returns list of model name strings."""
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status = MagicMock()
|
||||||
|
mock_resp.json.return_value = {
|
||||||
|
"models": [
|
||||||
|
{"name": "llama3.1:8b"},
|
||||||
|
{"name": "gemma2:9b"},
|
||||||
|
{"name": "qwen2.5:14b"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("roboco.services.llm.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
models, error = await probe_ollama_tags(_BASE_URL)
|
||||||
|
|
||||||
|
assert error is None
|
||||||
|
assert models == ["llama3.1:8b", "gemma2:9b", "qwen2.5:14b"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_ollama_tags_timeout() -> None:
|
||||||
|
"""httpx.TimeoutException returns ([], message) mentioning the timeout."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timed out"))
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("roboco.services.llm.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
models, error = await probe_ollama_tags(_BASE_URL)
|
||||||
|
|
||||||
|
assert models == []
|
||||||
|
assert error is not None
|
||||||
|
assert "timed out" in error.lower() or "timeout" in error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_ollama_tags_connect_error() -> None:
|
||||||
|
"""httpx.ConnectError returns ([], message) mentioning connection failure."""
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("roboco.services.llm.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
models, error = await probe_ollama_tags(_BASE_URL)
|
||||||
|
|
||||||
|
assert models == []
|
||||||
|
assert error is not None
|
||||||
|
assert "connect" in error.lower() or "offline" in error.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_ollama_tags_http_status_error() -> None:
|
||||||
|
"""httpx.HTTPStatusError returns ([], message) containing the HTTP status code."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 503
|
||||||
|
|
||||||
|
http_err = httpx.HTTPStatusError(
|
||||||
|
"service unavailable",
|
||||||
|
request=MagicMock(),
|
||||||
|
response=mock_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(side_effect=http_err)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("roboco.services.llm.httpx.AsyncClient", return_value=mock_client):
|
||||||
|
models, error = await probe_ollama_tags(_BASE_URL)
|
||||||
|
|
||||||
|
assert models == []
|
||||||
|
assert error is not None
|
||||||
|
assert "503" in error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_ollama_tags_generic_exception_logs_and_returns_generic() -> None:
|
||||||
|
"""Generic Exception logs the error server-side and returns a generic string.
|
||||||
|
|
||||||
|
The raw exception message must NOT appear in the returned error string
|
||||||
|
(to avoid leaking internal server details in HTTP responses).
|
||||||
|
"""
|
||||||
|
secret_detail = "internal secret detail that must not leak"
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.get = AsyncMock(side_effect=RuntimeError(secret_detail))
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("roboco.services.llm.httpx.AsyncClient", return_value=mock_client),
|
||||||
|
patch("roboco.services.llm._log") as mock_log,
|
||||||
|
):
|
||||||
|
models, error = await probe_ollama_tags(_BASE_URL)
|
||||||
|
|
||||||
|
assert models == []
|
||||||
|
assert error is not None
|
||||||
|
# The returned error string must NOT contain the raw exception text.
|
||||||
|
assert secret_detail not in error
|
||||||
|
# The logger must have been called to record the exception server-side.
|
||||||
|
mock_log.error.assert_called_once()
|
||||||
Reference in New Issue
Block a user