mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [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>
553 lines
22 KiB
Python
553 lines
22 KiB
Python
"""
|
|
Model Routing Service
|
|
|
|
Resolves (provider, model) for a given agent at spawn time using the
|
|
scoped rows in `model_assignments`:
|
|
|
|
AGENT_SLUG override > ROLE override > GLOBAL default
|
|
|
|
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, Literal, cast
|
|
|
|
import httpx
|
|
import structlog
|
|
from sqlalchemy import delete as sa_delete
|
|
from sqlalchemy import select
|
|
|
|
from roboco.agents_config import get_agent_role
|
|
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
|
|
from roboco.models.base import AssignmentScope, ModelProvider
|
|
from roboco.models.llm_catalog import (
|
|
MODEL_CATALOG_BY_NAME,
|
|
OLLAMA_DEFAULT_MODEL,
|
|
)
|
|
from roboco.models.runtime import MODEL_MAP, ROLE_MODEL_MAP
|
|
from roboco.services.base import BaseService, NotFoundError
|
|
from roboco.services.provider import ProviderService, ProviderUpdate
|
|
from roboco.utils.converters import require_uuid
|
|
from roboco.utils.crypto import EncryptionError
|
|
|
|
if TYPE_CHECKING:
|
|
from uuid import UUID
|
|
|
|
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.
|
|
|
|
`base_url` / `auth_token` being `None` means "Anthropic default":
|
|
orchestrator injects no `ANTHROPIC_*` env vars and the container
|
|
uses its mounted `~/.claude` auth (legacy behaviour).
|
|
"""
|
|
|
|
provider_id: UUID | None
|
|
provider_type: ModelProvider
|
|
base_url: str | None
|
|
auth_token: str | None
|
|
model_name: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ResolvedAssignment:
|
|
"""Internal — one resolved `model_assignments` row joined to provider."""
|
|
|
|
provider: ProviderConfigTable
|
|
model_name: str
|
|
|
|
|
|
class ModelRoutingService(BaseService):
|
|
"""Resolves per-agent routes from `model_assignments` + legacy fallback."""
|
|
|
|
service_name: ClassVar[str] = "model_routing"
|
|
|
|
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, 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)
|
|
|
|
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
|
|
)
|
|
if resolved is None and role:
|
|
resolved = await self._find_assignment(
|
|
scope=AssignmentScope.ROLE, scope_value=role
|
|
)
|
|
if resolved is None:
|
|
resolved = await self._find_assignment(
|
|
scope=AssignmentScope.GLOBAL, scope_value=None
|
|
)
|
|
return resolved
|
|
|
|
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:
|
|
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,
|
|
provider_type=ModelProvider.ANTHROPIC,
|
|
base_url=None,
|
|
auth_token=None,
|
|
model_name=MODEL_MAP.get(short, short),
|
|
)
|
|
|
|
# =========================================================================
|
|
# ASSIGNMENT CRUD (consumed by api/routes/provider.py)
|
|
# =========================================================================
|
|
|
|
async def list_assignments(self) -> list[ModelAssignmentTable]:
|
|
result = await self.session.execute(
|
|
select(ModelAssignmentTable).order_by(
|
|
ModelAssignmentTable.scope, ModelAssignmentTable.scope_value
|
|
)
|
|
)
|
|
return list(result.scalars().all())
|
|
|
|
async def get_assignment(
|
|
self, *, scope: AssignmentScope, scope_value: str | None
|
|
) -> ModelAssignmentTable | None:
|
|
query = select(ModelAssignmentTable).where(ModelAssignmentTable.scope == scope)
|
|
if scope_value is None:
|
|
query = query.where(ModelAssignmentTable.scope_value.is_(None))
|
|
else:
|
|
query = query.where(ModelAssignmentTable.scope_value == scope_value)
|
|
result = await self.session.execute(query)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def upsert_assignment(
|
|
self,
|
|
*,
|
|
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 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)
|
|
|
|
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)
|
|
)
|
|
|
|
row = await self.get_assignment(scope=scope, scope_value=scope_value)
|
|
if row is None:
|
|
row = ModelAssignmentTable(
|
|
scope=scope,
|
|
scope_value=scope_value,
|
|
provider_config_id=provider.id,
|
|
model_name=model_name,
|
|
)
|
|
self.session.add(row)
|
|
else:
|
|
row.provider_config_id = cast("Any", provider.id)
|
|
row.model_name = model_name
|
|
|
|
await self.session.flush()
|
|
self.log.info(
|
|
"Assignment upserted",
|
|
scope=scope.value,
|
|
scope_value=scope_value,
|
|
provider_type=provider_type_for_log.value,
|
|
model_name=model_name,
|
|
)
|
|
return row
|
|
|
|
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 Cloud → "ollama"
|
|
- only a global row, LOCAL → "self_hosted"
|
|
- anything else → "mix"
|
|
"""
|
|
assignments = await self.list_assignments()
|
|
if not assignments:
|
|
return "anthropic"
|
|
only_global = (
|
|
len(assignments) == 1 and assignments[0].scope == AssignmentScope.GLOBAL
|
|
)
|
|
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:
|
|
"""Set / clear the Ollama Cloud provider's API key.
|
|
|
|
Empty string clears + disables; a real key encrypts + enables.
|
|
Operates on the single pre-seeded Ollama row — no provider
|
|
creation happens here.
|
|
"""
|
|
provider = await self._get_seeded_provider(ModelProvider.OLLAMA_CLOUD)
|
|
provider_svc = ProviderService(self.session)
|
|
await provider_svc.update_provider(
|
|
require_uuid(provider.id),
|
|
ProviderUpdate(
|
|
auth_token=api_key if api_key else None,
|
|
clear_auth_token=not api_key,
|
|
enabled=bool(api_key),
|
|
),
|
|
)
|
|
# Re-fetch for the caller.
|
|
return await self._get_seeded_provider(ModelProvider.OLLAMA_CLOUD)
|
|
|
|
async def _get_seeded_provider(
|
|
self, provider_type: ModelProvider
|
|
) -> ProviderConfigTable:
|
|
"""Find the single seeded provider row for `provider_type`.
|
|
|
|
Migration `004_provider_routing` seeds exactly one row per type —
|
|
we just look it up. Raises NotFoundError if the seed is missing
|
|
(e.g., migration hasn't been applied).
|
|
"""
|
|
result = await self.session.execute(
|
|
select(ProviderConfigTable).where(ProviderConfigTable.type == provider_type)
|
|
)
|
|
row = result.scalar_one_or_none()
|
|
if row is None:
|
|
raise NotFoundError(
|
|
resource_type="Provider",
|
|
resource_id=f"type={provider_type.value}",
|
|
)
|
|
return row
|
|
|
|
async def delete_assignment(
|
|
self, *, scope: AssignmentScope, scope_value: str | None
|
|
) -> None:
|
|
row = await self.get_assignment(scope=scope, scope_value=scope_value)
|
|
if row is None:
|
|
raise NotFoundError(
|
|
resource_type="ModelAssignment",
|
|
resource_id=f"{scope.value}:{scope_value or '-'}",
|
|
)
|
|
await self.session.delete(row)
|
|
await self.session.flush()
|
|
self.log.info(
|
|
"Assignment deleted",
|
|
scope=scope.value,
|
|
scope_value=scope_value,
|
|
)
|
|
|
|
async def apply_mode(
|
|
self,
|
|
*,
|
|
mode: str,
|
|
default_model: str | None = None,
|
|
per_agent: dict[str, str] | None = None,
|
|
) -> None:
|
|
"""Apply a routing "mode" in a single transactional call.
|
|
|
|
Modes:
|
|
- "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: 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). Self-hosted model names (not in the
|
|
catalog) are automatically routed to the LOCAL provider.
|
|
"""
|
|
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.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.AGENT_SLUG,
|
|
scope_value=agent_slug,
|
|
model_name=model_name,
|
|
)
|
|
self.log.info("Mode applied: mix", agents=len(per_agent))
|
|
|
|
# =========================================================================
|
|
# INTERNAL
|
|
# =========================================================================
|
|
|
|
async def _find_assignment(
|
|
self, *, scope: AssignmentScope, scope_value: str | None
|
|
) -> _ResolvedAssignment | None:
|
|
row = await self.get_assignment(scope=scope, scope_value=scope_value)
|
|
if row is None:
|
|
return None
|
|
# 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).
|
|
# Anthropic providers have `auth_token_encrypted=NULL` and use the
|
|
# container's mounted credentials — no env injection needed.
|
|
provider_uuid = require_uuid(provider.id)
|
|
auth_token: str | None = None
|
|
if provider.auth_token_encrypted:
|
|
provider_svc = ProviderService(self.session)
|
|
auth_token = await provider_svc.get_decrypted_token(provider_uuid)
|
|
|
|
return AgentRoute(
|
|
provider_id=provider_uuid,
|
|
provider_type=provider.type,
|
|
base_url=provider.base_url,
|
|
auth_token=auth_token,
|
|
model_name=resolved.model_name,
|
|
)
|
|
|
|
@staticmethod
|
|
def _validate_scope(scope: AssignmentScope, scope_value: str | None) -> None:
|
|
if scope == AssignmentScope.GLOBAL and scope_value is not None:
|
|
raise ValueError("global scope must have scope_value=None")
|
|
if (
|
|
scope in (AssignmentScope.ROLE, AssignmentScope.AGENT_SLUG)
|
|
and not scope_value
|
|
):
|
|
raise ValueError(f"{scope.value} scope requires a non-empty scope_value")
|
|
|
|
|
|
def get_model_routing_service(session: AsyncSession) -> ModelRoutingService:
|
|
"""Get a ModelRoutingService instance."""
|
|
return ModelRoutingService(session)
|