mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[3cc1729c] Add self-hosted LLM provider with dynamic model discovery (#128)
* [684dace4] Self-hosted LLM provider: API layer, hooks, UI section, routing mode button, and Mix mode grouping (#124) (#126) * [684dace4] feat(providers): add self-hosted LLM API types, endpoints, and React Query hooks - Add ModelProvider.SELF_HOSTED enum value to types/index.ts - Extend RoutingMode to include 'self_hosted' in lib/api/providers.ts - Add SelfHostedConfig, SelfHostedTestResult, SelfHostedModel interfaces - Add SelfHostedConfigPayload for PUT requests - Add 5 providersApi methods: getSelfHostedConfig, saveSelfHostedConfig, testSelfHosted, getSelfHostedModels, refreshSelfHostedModels - Add 5 React Query hooks: useSelfHostedConfig, useSetSelfHostedConfig, useTestSelfHosted, useSelfHostedModels, useRefreshSelfHostedModels - Cache keys follow existing providerKeys pattern with proper invalidation * [684dace4] feat(settings): create SelfHostedSection component with full self-hosted LLM UI - Base URL text input with placeholder showing saved URL when set - Optional auth token field (type='password') with Eye/EyeOff toggle button - Save button that calls useSetSelfHostedConfig mutation - Test Connection button disabled until a URL is saved; shows inline green 'Connected — N models' badge on success or red error badge on fail - Three empty states: no URL configured (CTA), error state (last-checked + Retry), connected with 0 models (pull-guidance) - Model list with auto-discovered chip, Refresh Models button, and Last refreshed relative timestamp when test_status === 'connected' - Token field shows masked placeholder when has_auth_token is true (consistent with Ollama Cloud key field pattern) * [684dace4] feat(settings): add Self-Hosted mode button, model picker, and Mix mode provider grouping - Wire SelfHostedSection into AIRoutingCard with testResult state tracking - Expand routing mode grid from 3 to 4 buttons (2×2 on mobile, 4-col on md+) - 4th 'Self-Hosted' mode button disabled until test_status === 'connected' - Self-hosted model picker appears below mode grid when mode === 'self_hosted' - flipToSelfHosted handler sends mode='self_hosted' with optional default_model - Mix mode per-agent dropdown now groups entries under SelectGroup/SelectLabel headings: Anthropic, Ollama Cloud, Self-Hosted with colored ProviderBadge pill - saveMix validates self-hosted model selection requires a successful test - ProviderBadge helper renders blue/violet/purple pills for each provider type - pnpm typecheck and pnpm lint pass with zero errors --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [2897ce90] Implement self-hosted LLM provider API, routing, and discovery (#125) (#127) * [2897ce90] feat(provider): add self-hosted LLM provider API, routing, and discovery - Add migration 027 to seed Self-Hosted (Ollama) LOCAL provider row - Add probe_ollama_tags() helper for Ollama /api/tags connectivity checks - Extend ModelRoutingService: derive_mode returns 'self_hosted' for LOCAL GLOBAL assignments; apply_mode handles 'self_hosted' mode; upsert_assignment routes non-catalog model names to LOCAL provider; resolve_for_agent falls back to Anthropic when self-hosted server is unreachable - Add PUT /api/providers/self-hosted, POST /api/providers/self-hosted/test, GET /api/providers/self-hosted/models endpoints - Extend ApplyModeRequest and ModeResponse literals with 'self_hosted' - Add SelfHostedConfigRequest, SelfHostedConfigResponse, SelfHostedTestResponse schemas * [2897ce90] test(provider): add integration tests for self-hosted routing and route endpoints - Add llm_setup_with_local fixture that seeds LOCAL provider row - Test derive_mode returns 'self_hosted' for single GLOBAL LOCAL assignment - Test apply_mode('self_hosted') clears prior assignments, enables LOCAL, inserts GLOBAL - Test apply_mode('self_hosted') requires default_model argument - Test upsert_assignment routes non-catalog model names to LOCAL provider - Test mix mode accepts self-hosted model names without ValueError - Test resolve_for_agent returns base_url when LOCAL server is reachable - Test resolve_for_agent falls back to Anthropic when LOCAL server is unreachable - Test upsert_assignment raises ValueError when model unknown and no LOCAL provider - Add app_client_with_local fixture for route tests - Test PUT /self-hosted saves base_url and enables provider - Test PUT /self-hosted stores encrypted token when auth_token provided - Test PUT /self-hosted returns 404 when LOCAL provider not seeded - Test POST /self-hosted/test returns {ok:true,model_count:N} when reachable - Test POST /self-hosted/test returns {ok:false,error} (never 500) when unreachable - Test GET /self-hosted/models returns model name list - Test GET /self-hosted/models returns 404 when not configured - Test GET /self-hosted/models returns 503 when server unreachable - Rename migration from 027 to 028 to rebase on 027_system_settings * [2897ce90] chore(migration): remove superseded 027 migration, fix formatter changes to provider schemas --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [042462df] feat(providers): align self-hosted types, hooks, and UI to backend contract (#129) (#131) - SelfHostedConfig now has {base_url: string, has_token: boolean, enabled: boolean} - SelfHostedTestResult now has {ok: boolean, model_count: number | null, error: string | null} - Remove SelfHostedTestStatus type and refreshSelfHostedModels POST API function - Remove SELF_HOSTED from ModelProvider enum (LOCAL covers self-hosted semantics) - useRefreshSelfHostedModels now invalidates GET cache instead of calling POST - isSelfHostedConnected derived from testResult?.ok === true - Self-hosted model picker uses value='__clear__' sentinel (no empty-string SelectItem) - self-hosted-section.tsx reads result.ok/result.error and config?.has_token - pnpm typecheck passes with zero errors Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [f66d6d4d] Fix self-hosted API S1-S4/L1-L5: routes, schemas, services, migration 028, and tests (#130) (#132) * [f66d6d4d] fix(provider): self-hosted API S1-S4/L1-L5 - routes, schemas, services, migration 028, and tests AC1: Add GET /providers/self-hosted returning {base_url, has_token, enabled} AC2: GET /self-hosted/models now returns list[SelfHostedModelEntry] with model_name and display_name AC3: probe_ollama_tags generic except logs exception server-side and returns hardcoded generic string AC4: upsert_assignment calls ProviderService.update_provider(enabled=True) when routing to LOCAL AC5: derive_mode return annotation is Literal[...] — type:ignore comments removed AC6: All migration refs in routes/services say 028 (not 027) AC7: Migration 028 downgrade() deletes model_assignments before provider_configs AC8: PUT /self-hosted only passes enabled=True when data.base_url is non-empty AC9: ModelProvider.LOCAL docstring updated to describe self-hosted Ollama provider AC10: Direct unit tests for probe_ollama_tags (5 cases) in tests/unit/llm/ AC11: Contract tests added/updated for GET /providers/self-hosted, models, and test endpoints AC12: test_migration_028_seed_self_hosted.py with upgrade and FK-safe downgrade tests AC13: test_apply_mode_ollama_without_provider_returns_404 asserts exactly HTTPStatus.NOT_FOUND AC14: ruff and mypy pass with zero errors * [f66d6d4d] fix(tests): add AC4 test proving LOCAL.enabled transitions False->True in upsert_assignment The existing tests (test_upsert_assignment_routes_unknown_model_to_local and test_mix_mode_with_self_hosted_models) both use llm_setup_with_local which seeds LOCAL with enabled=True, making the AC4 assertion vacuous. New test test_upsert_assignment_enables_local_when_disabled: - Creates LOCAL ProviderConfigTable row with enabled=False - Asserts pre-condition: local.enabled is False - Calls upsert_assignment with a non-catalog model name ('non-catalog-model:7b') - Refreshes LOCAL row via db_session.refresh(local) - Asserts row.provider.type == ModelProvider.LOCAL and local.enabled is True This proves the state transition from False->True, not merely that the already-enabled state is preserved. ruff and mypy still pass with zero errors. --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [7cd6ae6e] fix(providers): type SelfHostedConfig.base_url as string | null to match backend contract (#133) (#136) Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [46ee9104] test(migration_028): replace upgrade test with self-seeding contract test (#134) (#135) Remove test_migration_028_upgrade_local_row_inserted which relied on alembic upgrade head having run (and thus the Self-Hosted Ollama row being present). Replace it with test_migration_028_upgrade_insert_contract that: - Executes the exact INSERT SQL from migration 028 upgrade() directly - Asserts name='Self-Hosted (Ollama)', type='local', enabled=False - Runs the INSERT a second time and asserts exactly one row (ON CONFLICT DO NOTHING idempotency) The downgrade test is left byte-for-byte unchanged. Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [f0d19f30] test(provider): add DELETE-before-seed isolation and app_client_with_ollama fixture (#137) (#138) - Add ModelAssignmentTable import to test_provider_routes.py - Fix app_client_with_local: execute DELETE on ModelAssignmentTable then DELETE on ProviderConfigTable (FK-safe order) and flush before seeding - Add new app_client_with_ollama fixture with same isolation pattern, seeding only ANTHROPIC + OLLAMA_CLOUD rows - Update 7 tests to use app_client_with_ollama instead of app_client: test_get_catalog, test_get_ollama_key_status, test_set_ollama_key, test_get_current_mode, test_apply_mode_anthropic_clears_assignments, test_apply_mode_unknown_returns_4xx, test_apply_mode_mix_without_per_agent_returns_400 Fixes order-dependent failures in test_get_self_hosted_models_not_configured_returns_404: routes call db.commit() which persists rows across test sessions; without DELETE-before-seed, stale LOCAL provider rows with base_url set from prior runs cause the test to see 503 instead of 404. Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * refactor(llm): split resolve_for_agent and apply_mode to clear xenon rank C resolve_for_agent and apply_mode were cyclomatic rank C, failing the xenon gate (--max-absolute B). Extract behavior-preserving helpers: - resolve_for_agent -> _resolve_assignment (precedence ladder), _route_from_resolved / _local_route_or_none / _decrypt_route_or_none (None signals fall-through to legacy), _legacy_route. - apply_mode -> _apply_anthropic / _apply_ollama / _apply_self_hosted / _apply_mix dispatched from a thin if/elif. No behavior change. Also correct the stale 'default: Kimi K2.6' docstring (OLLAMA_DEFAULT_MODEL is minimax-m3:cloud). --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Frontend Developer 1
Backend Developer 1
Renn F
parent
0daef044d2
commit
73b7c16211
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Provider Routes
|
||||
|
||||
Thin HTTP plumbing for the Settings UI's AI-routing panel. Four endpoints
|
||||
cover the whole UX: fetch the catalog, get / set the Ollama key, fetch the
|
||||
current mode + assignments, apply a mode change. No provider CRUD — the
|
||||
two providers (Anthropic + Ollama Cloud) are pre-seeded by migration 004.
|
||||
Thin HTTP plumbing for the Settings UI's AI-routing panel. Endpoints
|
||||
cover the whole UX: fetch the catalog, get / set the Ollama key,
|
||||
configure / test / discover the self-hosted server, fetch the current
|
||||
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
|
||||
@@ -15,14 +17,19 @@ from roboco.api.schemas.provider import (
|
||||
CatalogEntryResponse,
|
||||
ModeResponse,
|
||||
OllamaKeyStatus,
|
||||
SelfHostedConfigRequest,
|
||||
SelfHostedConfigResponse,
|
||||
SelfHostedModelEntry,
|
||||
SelfHostedTestResponse,
|
||||
SetOllamaKeyRequest,
|
||||
assignment_to_response,
|
||||
)
|
||||
from roboco.models.base import ModelProvider
|
||||
from roboco.models.llm_catalog import MODEL_CATALOG
|
||||
from roboco.services.base import NotFoundError
|
||||
from roboco.services.llm import get_model_routing_service
|
||||
from roboco.services.provider import get_provider_service
|
||||
from roboco.services.llm import get_model_routing_service, probe_ollama_tags
|
||||
from roboco.services.provider import ProviderUpdate, get_provider_service
|
||||
from roboco.utils.converters import require_uuid
|
||||
|
||||
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)
|
||||
# =============================================================================
|
||||
@@ -122,7 +295,7 @@ async def get_current_mode(
|
||||
mode = await routing.derive_mode()
|
||||
assignments = await routing.list_assignments()
|
||||
return ModeResponse(
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
mode=mode,
|
||||
assignments=[assignment_to_response(a) for a in assignments],
|
||||
)
|
||||
|
||||
@@ -157,6 +330,6 @@ async def apply_mode(
|
||||
mode = await routing.derive_mode()
|
||||
assignments = await routing.list_assignments()
|
||||
return ModeResponse(
|
||||
mode=mode, # type: ignore[arg-type]
|
||||
mode=mode,
|
||||
assignments=[assignment_to_response(a) for a in assignments],
|
||||
)
|
||||
|
||||
@@ -4,8 +4,9 @@ Providers API Schemas
|
||||
Minimal surface that backs the Settings UI:
|
||||
- fetch the preset catalog of selectable models
|
||||
- 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)
|
||||
- apply a routing mode (anthropic | ollama | mix)
|
||||
- apply a routing mode (anthropic | ollama | mix | self_hosted)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -57,6 +58,66 @@ class SetOllamaKeyRequest(BaseModel):
|
||||
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)
|
||||
# =============================================================================
|
||||
@@ -99,10 +160,13 @@ class ApplyModeRequest(BaseModel):
|
||||
`default_model` (if omitted, the service picks a sensible default).
|
||||
- mode="mix": clear existing per-agent pins; upsert the `per_agent`
|
||||
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
|
||||
per_agent: dict[str, str] | None = None
|
||||
|
||||
@@ -110,5 +174,5 @@ class ApplyModeRequest(BaseModel):
|
||||
class ModeResponse(BaseModel):
|
||||
"""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]
|
||||
|
||||
@@ -191,7 +191,10 @@ class ModelProvider(StrEnum):
|
||||
`ANTHROPIC` is the built-in default — routed via the mounted `~/.claude/`
|
||||
credentials inside each agent container. `OLLAMA_CLOUD` routes via
|
||||
`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"
|
||||
|
||||
+263
-80
@@ -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
|
||||
before. Decryption failures are contained: the service logs the error
|
||||
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 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 select
|
||||
|
||||
@@ -39,6 +52,46 @@ if TYPE_CHECKING:
|
||||
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)
|
||||
class AgentRoute:
|
||||
"""Resolved routing for a single agent spawn.
|
||||
@@ -71,38 +124,88 @@ class ModelRoutingService(BaseService):
|
||||
async def resolve_for_agent(self, agent_slug: str) -> AgentRoute:
|
||||
"""Resolve routing for `agent_slug` using the precedence ladder.
|
||||
|
||||
Never raises for a normal agent — decrypt failures and missing
|
||||
agents both downgrade to the legacy Anthropic path, because a
|
||||
stalled spawn is worse than a routing miss.
|
||||
Never raises for a normal agent — decrypt failures, unreachable
|
||||
self-hosted servers, and missing agents all downgrade to the
|
||||
legacy Anthropic path, because a stalled spawn is worse than a
|
||||
routing miss.
|
||||
"""
|
||||
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(
|
||||
scope=AssignmentScope.AGENT_SLUG, scope_value=agent_slug
|
||||
)
|
||||
# 2) role override
|
||||
if resolved is None and role:
|
||||
resolved = await self._find_assignment(
|
||||
scope=AssignmentScope.ROLE, scope_value=role
|
||||
)
|
||||
# 3) global default
|
||||
if resolved is None:
|
||||
resolved = await self._find_assignment(
|
||||
scope=AssignmentScope.GLOBAL, scope_value=None
|
||||
)
|
||||
return resolved
|
||||
|
||||
if resolved is not None and resolved.provider.enabled:
|
||||
try:
|
||||
return await self._route_from_assignment(resolved)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Provider token decrypt failed; falling back to legacy path",
|
||||
provider_id=str(resolved.provider.id),
|
||||
agent_slug=agent_slug,
|
||||
)
|
||||
async def _route_from_resolved(
|
||||
self, resolved: _ResolvedAssignment, agent_slug: str
|
||||
) -> AgentRoute | None:
|
||||
"""Build a route from a resolved+enabled assignment.
|
||||
|
||||
# 4) legacy fallback: role-default short name through MODEL_MAP.
|
||||
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:
|
||||
return await self._route_from_assignment(resolved)
|
||||
except EncryptionError:
|
||||
self.log.error(
|
||||
"Provider token decrypt failed; falling back to legacy path",
|
||||
provider_id=str(resolved.provider.id),
|
||||
agent_slug=agent_slug,
|
||||
)
|
||||
return None
|
||||
|
||||
def _legacy_route(self, role: str) -> AgentRoute:
|
||||
"""Legacy fallback: role-default short name through MODEL_MAP."""
|
||||
short = ROLE_MODEL_MAP.get(role, "sonnet")
|
||||
return AgentRoute(
|
||||
provider_id=None,
|
||||
@@ -141,21 +244,49 @@ class ModelRoutingService(BaseService):
|
||||
scope: AssignmentScope,
|
||||
scope_value: str | None,
|
||||
model_name: str,
|
||||
provider_type_override: ModelProvider | None = None,
|
||||
) -> ModelAssignmentTable:
|
||||
"""Insert-or-update (by unique (scope, scope_value)).
|
||||
|
||||
Provider is derived from `MODEL_CATALOG` — the UI never picks a
|
||||
provider separately, so the service looks up the pre-seeded
|
||||
Provider is normally derived from `MODEL_CATALOG` — the UI never
|
||||
picks a provider separately, so the service looks up the pre-seeded
|
||||
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)
|
||||
entry = MODEL_CATALOG_BY_NAME.get(model_name)
|
||||
if entry is None:
|
||||
raise ValueError(
|
||||
f"Unknown model '{model_name}'. Use one from "
|
||||
"GET /api/providers/catalog."
|
||||
|
||||
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)
|
||||
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(
|
||||
f"Unknown model '{model_name}'. Use one from "
|
||||
"GET /api/providers/catalog."
|
||||
)
|
||||
provider = local_provider
|
||||
provider_type_for_log = ModelProvider.LOCAL
|
||||
else:
|
||||
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)
|
||||
)
|
||||
provider = await self._get_seeded_provider(entry.provider_type)
|
||||
|
||||
row = await self.get_assignment(scope=scope, scope_value=scope_value)
|
||||
if row is None:
|
||||
@@ -175,18 +306,19 @@ class ModelRoutingService(BaseService):
|
||||
"Assignment upserted",
|
||||
scope=scope.value,
|
||||
scope_value=scope_value,
|
||||
provider_type=entry.provider_type.value,
|
||||
provider_type=provider_type_for_log.value,
|
||||
model_name=model_name,
|
||||
)
|
||||
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.
|
||||
|
||||
Decision tree matches what `apply_mode` writes:
|
||||
- no assignments at all → "anthropic"
|
||||
- only a global row, Ollama → "ollama"
|
||||
- anything else → "mix"
|
||||
- no assignments at all → "anthropic"
|
||||
- only a global row, Ollama Cloud → "ollama"
|
||||
- only a global row, LOCAL → "self_hosted"
|
||||
- anything else → "mix"
|
||||
"""
|
||||
assignments = await self.list_assignments()
|
||||
if not assignments:
|
||||
@@ -194,11 +326,11 @@ class ModelRoutingService(BaseService):
|
||||
only_global = (
|
||||
len(assignments) == 1 and assignments[0].scope == AssignmentScope.GLOBAL
|
||||
)
|
||||
is_ollama = (
|
||||
only_global and assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD
|
||||
)
|
||||
if is_ollama:
|
||||
return "ollama"
|
||||
if only_global:
|
||||
if assignments[0].provider.type == ModelProvider.OLLAMA_CLOUD:
|
||||
return "ollama"
|
||||
if assignments[0].provider.type == ModelProvider.LOCAL:
|
||||
return "self_hosted"
|
||||
return "mix"
|
||||
|
||||
async def set_ollama_api_key(self, api_key: str) -> ProviderConfigTable:
|
||||
@@ -268,58 +400,100 @@ class ModelRoutingService(BaseService):
|
||||
"""Apply a routing "mode" in a single transactional call.
|
||||
|
||||
Modes:
|
||||
- "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.
|
||||
- "ollama": wipe role/agent overrides, set GLOBAL to the given
|
||||
Ollama model (default: Kimi K2.6). CEO-type pins can be layered
|
||||
back manually if the user wants them.
|
||||
- "mix": apply per-agent map verbatim. Any agent not in the
|
||||
- "ollama": wipe role/agent overrides, set GLOBAL to the given
|
||||
Ollama model (default: OLLAMA_DEFAULT_MODEL). CEO-type pins can be
|
||||
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
|
||||
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":
|
||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||
await self.session.flush()
|
||||
self.log.info("Mode applied: anthropic (all assignments cleared)")
|
||||
return
|
||||
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'."
|
||||
)
|
||||
|
||||
if mode == "ollama":
|
||||
await self.session.execute(sa_delete(ModelAssignmentTable))
|
||||
await self.session.flush()
|
||||
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.flush()
|
||||
self.log.info("Mode applied: anthropic (all assignments cleared)")
|
||||
|
||||
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.flush()
|
||||
model_name = default_model or OLLAMA_DEFAULT_MODEL
|
||||
await self.upsert_assignment(
|
||||
scope=AssignmentScope.GLOBAL,
|
||||
scope_value=None,
|
||||
model_name=model_name,
|
||||
)
|
||||
self.log.info("Mode applied: ollama", default_model=model_name)
|
||||
|
||||
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:
|
||||
raise ValueError("mix mode requires a per_agent map")
|
||||
# Clear existing agent-slug overrides so the new map is authoritative.
|
||||
await self.session.execute(
|
||||
sa_delete(ModelAssignmentTable).where(
|
||||
ModelAssignmentTable.scope == AssignmentScope.AGENT_SLUG
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
for agent_slug, model_name in per_agent.items():
|
||||
if not model_name:
|
||||
continue
|
||||
# upsert_assignment will route to LOCAL for non-catalog names.
|
||||
await self.upsert_assignment(
|
||||
scope=AssignmentScope.GLOBAL,
|
||||
scope_value=None,
|
||||
model_name=default_model or OLLAMA_DEFAULT_MODEL,
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value=agent_slug,
|
||||
model_name=model_name,
|
||||
)
|
||||
self.log.info(
|
||||
"Mode applied: ollama",
|
||||
default_model=default_model or OLLAMA_DEFAULT_MODEL,
|
||||
)
|
||||
return
|
||||
|
||||
if mode == "mix":
|
||||
if not per_agent:
|
||||
raise ValueError("mix mode requires a per_agent map")
|
||||
# Clear existing agent-slug overrides so the new map is
|
||||
# authoritative; leave role + global alone.
|
||||
await self.session.execute(
|
||||
sa_delete(ModelAssignmentTable).where(
|
||||
ModelAssignmentTable.scope == AssignmentScope.AGENT_SLUG
|
||||
)
|
||||
)
|
||||
await self.session.flush()
|
||||
for agent_slug, model_name in per_agent.items():
|
||||
if not model_name:
|
||||
continue
|
||||
await self.upsert_assignment(
|
||||
scope=AssignmentScope.AGENT_SLUG,
|
||||
scope_value=agent_slug,
|
||||
model_name=model_name,
|
||||
)
|
||||
self.log.info("Mode applied: mix", agents=len(per_agent))
|
||||
return
|
||||
|
||||
raise ValueError(f"Unknown mode '{mode}'. Use 'anthropic', 'ollama', or 'mix'.")
|
||||
self.log.info("Mode applied: mix", agents=len(per_agent))
|
||||
|
||||
# =========================================================================
|
||||
# INTERNAL
|
||||
@@ -334,6 +508,15 @@ class ModelRoutingService(BaseService):
|
||||
# Relationship is lazy="joined" in the ORM so `.provider` is loaded.
|
||||
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:
|
||||
provider = resolved.provider
|
||||
# Decrypt only when the provider has a stored token (ollama_cloud).
|
||||
|
||||
Reference in New Issue
Block a user