Files
roboco/tests/integration/test_llm_routing.py
T
73b7c16211 [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>
2026-06-13 08:38:31 +02:00

593 lines
20 KiB
Python

"""ModelRoutingService coverage — assignment CRUD + mode application + resolution."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from roboco.db.tables import ProviderConfigTable
from roboco.models.base import AssignmentScope, ModelProvider
from roboco.models.llm_catalog import MODEL_CATALOG
from roboco.services.base import NotFoundError
from roboco.services.llm import ModelRoutingService, get_model_routing_service
from roboco.utils.crypto import EncryptionError
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
def _first_model_for_type(provider_type: ModelProvider) -> str:
for entry in MODEL_CATALOG:
if entry.provider_type == provider_type:
return entry.model_name
raise RuntimeError(f"no catalog entry for {provider_type}")
@pytest_asyncio.fixture
async def llm_setup(
db_session: AsyncSession,
) -> AsyncIterator[dict]:
"""Seed the two provider rows the routing service expects."""
anthropic = ProviderConfigTable(
name="anthropic-test",
type=ModelProvider.ANTHROPIC,
enabled=True,
)
ollama = ProviderConfigTable(
name="ollama-test",
type=ModelProvider.OLLAMA_CLOUD,
enabled=True,
base_url="https://ollama.example.com",
)
db_session.add_all([anthropic, ollama])
await db_session.flush()
yield {"svc": ModelRoutingService(db_session)}
# ---------------------------------------------------------------------------
# Assignment CRUD
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_assignments_empty(llm_setup: dict) -> None:
svc = llm_setup["svc"]
assert await svc.list_assignments() == []
@pytest.mark.asyncio
async def test_upsert_global_assignment(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
row = await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=model
)
assert row.scope == AssignmentScope.GLOBAL
assert row.model_name == model
@pytest.mark.asyncio
async def test_upsert_role_assignment(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
row = await svc.upsert_assignment(
scope=AssignmentScope.ROLE, scope_value="developer", model_name=model
)
assert row.scope == AssignmentScope.ROLE
assert row.scope_value == "developer"
@pytest.mark.asyncio
async def test_upsert_replaces_existing(llm_setup: dict) -> None:
svc = llm_setup["svc"]
anth_model = _first_model_for_type(ModelProvider.ANTHROPIC)
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
a = await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=anth_model
)
b = await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=ollama_model
)
assert a.id == b.id # Updated, not duplicated.
assert b.model_name == ollama_model
@pytest.mark.asyncio
async def test_upsert_unknown_model_raises(llm_setup: dict) -> None:
svc = llm_setup["svc"]
with pytest.raises(ValueError, match="Unknown model"):
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL,
scope_value=None,
model_name="ghost-model",
)
@pytest.mark.asyncio
async def test_upsert_invalid_global_scope_raises(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
with pytest.raises(ValueError, match="global scope must"):
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value="not-none", model_name=model
)
@pytest.mark.asyncio
async def test_upsert_role_requires_value(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
with pytest.raises(ValueError, match="requires a non-empty"):
await svc.upsert_assignment(
scope=AssignmentScope.ROLE, scope_value=None, model_name=model
)
@pytest.mark.asyncio
async def test_get_assignment_returns_none_when_missing(llm_setup: dict) -> None:
svc = llm_setup["svc"]
assert (
await svc.get_assignment(scope=AssignmentScope.AGENT_SLUG, scope_value="ghost")
is None
)
@pytest.mark.asyncio
async def test_delete_assignment(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=model
)
await svc.delete_assignment(scope=AssignmentScope.GLOBAL, scope_value=None)
assert (
await svc.get_assignment(scope=AssignmentScope.GLOBAL, scope_value=None) is None
)
@pytest.mark.asyncio
async def test_delete_assignment_raises_when_missing(llm_setup: dict) -> None:
svc = llm_setup["svc"]
with pytest.raises(NotFoundError):
await svc.delete_assignment(scope=AssignmentScope.GLOBAL, scope_value=None)
# ---------------------------------------------------------------------------
# derive_mode
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_derive_mode_anthropic_when_no_assignments(llm_setup: dict) -> None:
svc = llm_setup["svc"]
assert await svc.derive_mode() == "anthropic"
@pytest.mark.asyncio
async def test_derive_mode_ollama_when_only_ollama_global(llm_setup: dict) -> None:
svc = llm_setup["svc"]
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=ollama_model
)
assert await svc.derive_mode() == "ollama"
@pytest.mark.asyncio
async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
await svc.upsert_assignment(
scope=AssignmentScope.AGENT_SLUG,
scope_value="be-dev-1",
model_name=model,
)
assert await svc.derive_mode() == "mix"
# ---------------------------------------------------------------------------
# apply_mode
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_apply_mode_anthropic_clears_all(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=model
)
await svc.apply_mode(mode="anthropic")
assert await svc.list_assignments() == []
@pytest.mark.asyncio
async def test_apply_mode_ollama_sets_global(llm_setup: dict) -> None:
svc = llm_setup["svc"]
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
await svc.apply_mode(mode="ollama", default_model=ollama_model)
assignments = await svc.list_assignments()
assert len(assignments) == 1
assert assignments[0].scope == AssignmentScope.GLOBAL
@pytest.mark.asyncio
async def test_apply_mode_mix_requires_per_agent(llm_setup: dict) -> None:
svc = llm_setup["svc"]
with pytest.raises(ValueError, match="requires a per_agent"):
await svc.apply_mode(mode="mix")
@pytest.mark.asyncio
async def test_apply_mode_mix_writes_overrides(llm_setup: dict) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
await svc.apply_mode(
mode="mix",
per_agent={"be-dev-1": model, "be-dev-2": model, "skip-me": ""},
)
rows = await svc.list_assignments()
slugs = {r.scope_value for r in rows if r.scope == AssignmentScope.AGENT_SLUG}
assert "be-dev-1" in slugs
assert "be-dev-2" in slugs
# Empty model_name skipped.
assert "skip-me" not in slugs
@pytest.mark.asyncio
async def test_apply_mode_unknown_raises(llm_setup: dict) -> None:
svc = llm_setup["svc"]
with pytest.raises(ValueError, match="Unknown mode"):
await svc.apply_mode(mode="quantum")
# ---------------------------------------------------------------------------
# Ollama API key
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_set_ollama_api_key_encrypts(llm_setup: dict) -> None:
svc = llm_setup["svc"]
provider = await svc.set_ollama_api_key("secret-key-123")
assert provider.auth_token_encrypted is not None
assert provider.enabled is True
@pytest.mark.asyncio
async def test_set_ollama_api_key_clears(llm_setup: dict) -> None:
svc = llm_setup["svc"]
await svc.set_ollama_api_key("secret-key-123")
cleared = await svc.set_ollama_api_key("")
assert cleared.auth_token_encrypted is None
assert cleared.enabled is False
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resolve_for_agent_legacy_fallback_when_no_assignments(
llm_setup: dict,
) -> None:
"""No assignments → Anthropic with a model from MODEL_MAP, no auth_token."""
svc = llm_setup["svc"]
route = await svc.resolve_for_agent("be-dev-1")
assert route.provider_type == ModelProvider.ANTHROPIC
assert route.auth_token is None # Container uses mounted creds.
assert route.model_name # Always resolved.
@pytest.mark.asyncio
async def test_resolve_for_agent_uses_global_assignment(
llm_setup: dict,
) -> None:
svc = llm_setup["svc"]
model = _first_model_for_type(ModelProvider.ANTHROPIC)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=model
)
route = await svc.resolve_for_agent("be-dev-1")
assert route.model_name == model
@pytest.mark.asyncio
async def test_resolve_for_agent_uses_provider_token(llm_setup: dict) -> None:
"""When provider has auth_token_encrypted, it's decrypted (lines 345-346)."""
svc = llm_setup["svc"]
await svc.set_ollama_api_key("test-secret-key")
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=ollama_model
)
route = await svc.resolve_for_agent("be-dev-1")
assert route.auth_token == "test-secret-key"
@pytest.mark.asyncio
async def test_get_seeded_provider_unknown_raises(
db_session: AsyncSession,
) -> None:
"""When the requested provider type isn't seeded, raise NotFoundError (line 238)."""
svc = ModelRoutingService(db_session)
with pytest.raises(NotFoundError):
await svc._get_seeded_provider(ModelProvider.ANTHROPIC)
def test_get_model_routing_service_factory(db_session: AsyncSession) -> None:
"""Factory wraps ModelRoutingService with the given session (line 369)."""
svc = get_model_routing_service(db_session)
assert isinstance(svc, ModelRoutingService)
@pytest.mark.asyncio
async def test_resolve_for_agent_falls_back_on_decrypt_error(
llm_setup: dict,
) -> None:
"""EncryptionError on decrypt → falls back to legacy path (lines 98-99)."""
svc = llm_setup["svc"]
await svc.set_ollama_api_key("garbage-token")
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
await svc.upsert_assignment(
scope=AssignmentScope.GLOBAL, scope_value=None, model_name=ollama_model
)
with patch(
"roboco.services.llm.ProviderService.get_decrypted_token",
side_effect=EncryptionError("decrypt"),
new_callable=AsyncMock,
):
route = await svc.resolve_for_agent("be-dev-1")
# Falls back to legacy ANTHROPIC route.
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"
)