[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:
Renzo F
2026-06-13 08:38:31 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Backend Developer 1 Renn F
parent 0daef044d2
commit 73b7c16211
13 changed files with 2201 additions and 120 deletions
+243
View File
@@ -347,3 +347,246 @@ async def test_resolve_for_agent_falls_back_on_decrypt_error(
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"
)
@@ -0,0 +1,168 @@
"""Migration 028 tests — seed_self_hosted_provider.
Verifies the post-upgrade state and exercises the downgrade SQL ordering
to prove the FK-safe delete sequence works.
NOT a real alembic round-trip — the suite builds the test DB via
Base.metadata.create_all (see conftest). Migration 028's upgrade()/downgrade()
bodies are reviewed here; the tests guard the resulting DB-level contract.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from uuid import uuid4
import pytest
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
from roboco.models.base import AssignmentScope, ModelProvider
from sqlalchemy import text
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@pytest.mark.asyncio
async def test_migration_028_upgrade_insert_contract(
db_session: AsyncSession,
) -> None:
"""The upgrade INSERT SQL seeds the correct LOCAL provider row and is idempotent.
Executes the INSERT ... ON CONFLICT DO NOTHING SQL from migration 028's
upgrade() directly in the test session, verifying:
- name='Self-Hosted (Ollama)', type='local', enabled=False on the row.
- Running the same INSERT a second time leaves exactly one row (idempotency).
"""
_insert_sql = text(
"""
INSERT INTO provider_configs
(id, name, type, base_url, auth_token_encrypted, enabled, created_at)
VALUES
(
gen_random_uuid(),
'Self-Hosted (Ollama)',
'local',
NULL,
NULL,
false,
now()
)
ON CONFLICT (name) DO NOTHING
"""
)
# --- First run: the row should be inserted.
await db_session.execute(_insert_sql)
await db_session.flush()
# Verify the field contract on the newly-inserted row.
result = await db_session.execute(
text(
"SELECT name, type, enabled "
"FROM provider_configs "
"WHERE name = 'Self-Hosted (Ollama)'"
)
)
rows = list(result)
assert len(rows) == 1
name, ptype, enabled = rows[0]
assert name == "Self-Hosted (Ollama)"
assert ptype == "local"
assert enabled is False # starts disabled; user configures via PUT /self-hosted
# --- Second run: ON CONFLICT DO NOTHING must not create a duplicate.
await db_session.execute(_insert_sql)
await db_session.flush()
result = await db_session.execute(
text("SELECT id FROM provider_configs WHERE name = 'Self-Hosted (Ollama)'")
)
assert len(list(result)) == 1, (
"Expected exactly one 'Self-Hosted (Ollama)' row after two INSERT "
"executions; ON CONFLICT DO NOTHING must prevent duplicates."
)
@pytest.mark.asyncio
async def test_migration_028_downgrade_deletes_assignments_before_config(
db_session: AsyncSession,
) -> None:
"""Downgrade SQL deletes model_assignments before provider_configs.
Simulates the downgrade() logic from migration 028:
1. DELETE FROM model_assignments WHERE provider_config_id IN (SELECT id ...)
2. DELETE FROM provider_configs WHERE name = 'Self-Hosted (Ollama)'
A FK RESTRICT constraint on model_assignments.provider_config_id means that
executing step 2 before step 1 would raise an IntegrityError. This test
proves that doing them in the correct order succeeds without violation.
"""
# --- Arrange: insert a fresh LOCAL provider row and a referencing assignment.
suffix = uuid4().hex[:8]
local = ProviderConfigTable(
name=f"Self-Hosted (Ollama)-test-{suffix}",
type=ModelProvider.LOCAL,
enabled=False,
)
db_session.add(local)
await db_session.flush()
assignment = ModelAssignmentTable(
scope=AssignmentScope.AGENT_SLUG,
scope_value=f"test-agent-{suffix}",
provider_config_id=local.id,
model_name="llama3.1:8b",
)
db_session.add(assignment)
await db_session.flush()
# Verify both rows exist before we run the downgrade SQL.
result = await db_session.execute(
text("SELECT id FROM provider_configs WHERE name = :name").bindparams(
name=local.name
)
)
assert result.scalar_one_or_none() is not None
result = await db_session.execute(
text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams(
sv=assignment.scope_value
)
)
assert result.scalar_one_or_none() is not None
# --- Act: execute downgrade SQL in the correct FK-safe order.
# Step 1: delete referencing model_assignments first.
await db_session.execute(
text(
"DELETE FROM model_assignments "
"WHERE provider_config_id IN ("
" SELECT id FROM provider_configs WHERE name = :name"
")"
).bindparams(name=local.name)
)
# Step 2: now safe to delete the provider row.
await db_session.execute(
text("DELETE FROM provider_configs WHERE name = :name").bindparams(
name=local.name
)
)
# --- Assert: both rows are gone, no IntegrityError was raised.
result = await db_session.execute(
text("SELECT id FROM provider_configs WHERE name = :name").bindparams(
name=local.name
)
)
assert result.scalar_one_or_none() is None, (
"provider_configs row should be deleted by downgrade"
)
result = await db_session.execute(
text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams(
sv=assignment.scope_value
)
)
assert result.scalar_one_or_none() is None, (
"model_assignments row should be deleted before provider_configs"
)
+377 -16
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
@@ -12,7 +13,7 @@ from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.provider import router as provider_router
from roboco.db.tables import ProviderConfigTable
from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable
from roboco.models import AgentRole, Team
from roboco.models.base import ModelProvider
from roboco.models.permissions import AgentContext
@@ -77,12 +78,54 @@ async def app_client(
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def app_client_with_ollama(
db_session: AsyncSession,
) -> AsyncIterator[AsyncClient]:
"""App client pre-seeded with Anthropic and Ollama Cloud providers.
Begins with a DELETE-before-seed isolation step: deletes all rows from
ModelAssignmentTable (FK-safe) then ProviderConfigTable before adding
fresh ANTHROPIC + OLLAMA_CLOUD rows. This ensures tests are
order-independent regardless of what prior tests committed.
"""
app = _make_app(db_session)
suffix = uuid4().hex[:8]
# FK-safe cleanup: model_assignments.provider_config_id references
# provider_configs.id, so assignments must be deleted first.
await db_session.execute(delete(ModelAssignmentTable))
await db_session.execute(delete(ProviderConfigTable))
await db_session.flush()
db_session.add(
ProviderConfigTable(
name=f"anthropic-test-{suffix}",
type=ModelProvider.ANTHROPIC,
enabled=True,
)
)
db_session.add(
ProviderConfigTable(
name=f"ollama-test-{suffix}",
type=ModelProvider.OLLAMA_CLOUD,
enabled=False,
base_url="https://ollama.example.com",
)
)
await db_session.flush()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
_HDR_PM = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
@pytest.mark.asyncio
async def test_get_catalog(app_client: AsyncClient) -> None:
response = await app_client.get("/api/providers/catalog", headers=_HDR_PM)
async def test_get_catalog(app_client_with_ollama: AsyncClient) -> None:
response = await app_client_with_ollama.get(
"/api/providers/catalog", headers=_HDR_PM
)
assert response.status_code == HTTPStatus.OK
assert isinstance(response.json(), list)
@@ -103,8 +146,10 @@ async def test_get_catalog_forbidden_for_developer(
@pytest.mark.asyncio
async def test_get_ollama_key_status(app_client: AsyncClient) -> None:
response = await app_client.get("/api/providers/ollama-key", headers=_HDR_PM)
async def test_get_ollama_key_status(app_client_with_ollama: AsyncClient) -> None:
response = await app_client_with_ollama.get(
"/api/providers/ollama-key", headers=_HDR_PM
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert "has_key" in body
@@ -112,8 +157,8 @@ async def test_get_ollama_key_status(app_client: AsyncClient) -> None:
@pytest.mark.asyncio
async def test_set_ollama_key(app_client: AsyncClient) -> None:
response = await app_client.put(
async def test_set_ollama_key(app_client_with_ollama: AsyncClient) -> None:
response = await app_client_with_ollama.put(
"/api/providers/ollama-key",
json={"api_key": "secret-key-123"},
headers=_HDR_PM,
@@ -124,8 +169,8 @@ async def test_set_ollama_key(app_client: AsyncClient) -> None:
@pytest.mark.asyncio
async def test_get_current_mode(app_client: AsyncClient) -> None:
response = await app_client.get("/api/providers", headers=_HDR_PM)
async def test_get_current_mode(app_client_with_ollama: AsyncClient) -> None:
response = await app_client_with_ollama.get("/api/providers", headers=_HDR_PM)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["mode"] in {"anthropic", "ollama", "mix"}
@@ -133,9 +178,9 @@ async def test_get_current_mode(app_client: AsyncClient) -> None:
@pytest.mark.asyncio
async def test_apply_mode_anthropic_clears_assignments(
app_client: AsyncClient,
app_client_with_ollama: AsyncClient,
) -> None:
response = await app_client.post(
response = await app_client_with_ollama.post(
"/api/providers", json={"mode": "anthropic"}, headers=_HDR_PM
)
assert response.status_code == HTTPStatus.OK
@@ -144,9 +189,11 @@ async def test_apply_mode_anthropic_clears_assignments(
@pytest.mark.asyncio
async def test_apply_mode_unknown_returns_4xx(app_client: AsyncClient) -> None:
async def test_apply_mode_unknown_returns_4xx(
app_client_with_ollama: AsyncClient,
) -> None:
"""Unknown mode is rejected — Pydantic 422 at schema layer or 400 at service."""
response = await app_client.post(
response = await app_client_with_ollama.post(
"/api/providers", json={"mode": "quantum"}, headers=_HDR_PM
)
assert response.status_code in (
@@ -229,10 +276,10 @@ async def test_get_mode_developer_forbidden(
@pytest.mark.asyncio
async def test_apply_mode_mix_without_per_agent_returns_400(
app_client: AsyncClient,
app_client_with_ollama: AsyncClient,
) -> None:
"""Apply 'mix' mode without per_agent triggers ValueError → 400 (lines 149-152)."""
response = await app_client.post(
response = await app_client_with_ollama.post(
"/api/providers",
json={"mode": "mix"},
headers=_HDR_PM,
@@ -259,4 +306,318 @@ async def test_apply_mode_ollama_without_provider_returns_404(
"/api/providers", json={"mode": "ollama"}, headers=_HDR_PM
)
app.dependency_overrides.clear()
assert response.status_code in (HTTPStatus.NOT_FOUND, HTTPStatus.OK)
assert response.status_code == HTTPStatus.NOT_FOUND
# =============================================================================
# Self-hosted endpoints
# =============================================================================
@pytest_asyncio.fixture
async def app_client_with_local(
db_session: AsyncSession,
) -> AsyncIterator[AsyncClient]:
"""App client pre-seeded with Anthropic, Ollama Cloud, and LOCAL providers.
Begins with a DELETE-before-seed isolation step: deletes all rows from
ModelAssignmentTable (FK-safe) then ProviderConfigTable before adding
fresh rows. This ensures tests are order-independent regardless of what
prior tests committed.
"""
app = _make_app(db_session)
suffix = uuid4().hex[:8]
# FK-safe cleanup: model_assignments.provider_config_id references
# provider_configs.id, so assignments must be deleted first.
await db_session.execute(delete(ModelAssignmentTable))
await db_session.execute(delete(ProviderConfigTable))
await db_session.flush()
db_session.add(
ProviderConfigTable(
name=f"anthropic-local-{suffix}",
type=ModelProvider.ANTHROPIC,
enabled=True,
)
)
db_session.add(
ProviderConfigTable(
name=f"ollama-local-{suffix}",
type=ModelProvider.OLLAMA_CLOUD,
enabled=False,
base_url="https://ollama.example.com",
)
)
db_session.add(
ProviderConfigTable(
name=f"self-hosted-local-{suffix}",
type=ModelProvider.LOCAL,
enabled=False,
)
)
await db_session.flush()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_put_self_hosted_saves_base_url(
app_client_with_local: AsyncClient,
) -> None:
"""PUT /self-hosted saves base_url and enables the LOCAL provider."""
response = await app_client_with_local.put(
"/api/providers/self-hosted",
json={"base_url": "http://192.168.1.10:11434"},
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["base_url"] == "http://192.168.1.10:11434"
assert body["enabled"] is True
assert body["has_token"] is False
@pytest.mark.asyncio
async def test_put_self_hosted_with_token_stores_encrypted(
app_client_with_local: AsyncClient,
) -> None:
"""PUT /self-hosted with auth_token stores Fernet-encrypted token."""
response = await app_client_with_local.put(
"/api/providers/self-hosted",
json={
"base_url": "http://192.168.1.10:11434",
"auth_token": "secret-ollama-key",
},
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["has_token"] is True
@pytest.mark.asyncio
async def test_put_self_hosted_not_seeded_returns_404(
db_session: AsyncSession,
) -> None:
"""PUT /self-hosted when LOCAL provider not seeded returns 404."""
await db_session.execute(
delete(ProviderConfigTable).where(
ProviderConfigTable.type == ModelProvider.LOCAL
)
)
await db_session.flush()
app = _make_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put(
"/api/providers/self-hosted",
json={"base_url": "http://localhost:11434"},
headers=_HDR_PM,
)
app.dependency_overrides.clear()
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_put_self_hosted_developer_forbidden(
db_session: AsyncSession,
) -> None:
"""PUT /self-hosted is forbidden for developer role."""
app = _make_app(db_session, role=AgentRole.DEVELOPER, team=Team.BACKEND)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.put(
"/api/providers/self-hosted",
json={"base_url": "http://localhost:11434"},
headers={"X-Agent-ID": str(uuid4()), "X-Agent-Role": "developer"},
)
app.dependency_overrides.clear()
assert response.status_code == HTTPStatus.FORBIDDEN
@pytest.mark.asyncio
async def test_post_test_self_hosted_when_reachable(
app_client_with_local: AsyncClient,
) -> None:
"""POST /self-hosted/test returns {ok: true, model_count: N} when reachable."""
# First configure the base_url.
await app_client_with_local.put(
"/api/providers/self-hosted",
json={"base_url": "http://192.168.1.10:11434"},
headers=_HDR_PM,
)
with patch(
"roboco.api.routes.provider.probe_ollama_tags",
new_callable=AsyncMock,
return_value=(["llama3.1:8b", "gemma2:9b"], None),
):
response = await app_client_with_local.post(
"/api/providers/self-hosted/test",
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
# Contract: field names and types for the test response schema.
assert "ok" in body
assert "model_count" in body
assert "error" in body
assert isinstance(body["ok"], bool)
assert body["ok"] is True
assert body["model_count"] == 2 # noqa: PLR2004
assert body["error"] is None
@pytest.mark.asyncio
async def test_post_test_self_hosted_when_unreachable(
app_client_with_local: AsyncClient,
) -> None:
"""POST /self-hosted/test returns {ok: false, error: '...'} when unreachable."""
await app_client_with_local.put(
"/api/providers/self-hosted",
json={"base_url": "http://192.168.1.10:11434"},
headers=_HDR_PM,
)
with patch(
"roboco.api.routes.provider.probe_ollama_tags",
new_callable=AsyncMock,
return_value=([], "Could not connect to http://192.168.1.10:11434"),
):
response = await app_client_with_local.post(
"/api/providers/self-hosted/test",
headers=_HDR_PM,
)
# Must be 200 with ok=false, NOT 500.
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["ok"] is False
assert body["error"] is not None
assert body["model_count"] is None
@pytest.mark.asyncio
async def test_post_test_self_hosted_not_configured(
app_client_with_local: AsyncClient,
) -> None:
"""POST /self-hosted/test when no base_url returns {ok: false} without 500."""
# LOCAL provider seeded but no base_url configured.
response = await app_client_with_local.post(
"/api/providers/self-hosted/test",
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
assert body["ok"] is False
assert body["error"] is not None
@pytest.mark.asyncio
async def test_get_self_hosted_config_returns_200(
app_client_with_local: AsyncClient,
) -> None:
"""GET /self-hosted returns {base_url, has_token, enabled} when LOCAL is seeded."""
response = await app_client_with_local.get(
"/api/providers/self-hosted",
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.OK
body = response.json()
# Contract: field names and types must match the schema.
assert "base_url" in body
assert "has_token" in body
assert "enabled" in body
assert isinstance(body["has_token"], bool)
assert isinstance(body["enabled"], bool)
@pytest.mark.asyncio
async def test_get_self_hosted_config_not_seeded_returns_404(
db_session: AsyncSession,
) -> None:
"""GET /self-hosted when LOCAL provider not seeded returns 404."""
await db_session.execute(
delete(ProviderConfigTable).where(
ProviderConfigTable.type == ModelProvider.LOCAL
)
)
await db_session.flush()
app = _make_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(
"/api/providers/self-hosted",
headers=_HDR_PM,
)
app.dependency_overrides.clear()
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_get_self_hosted_models_returns_list(
app_client_with_local: AsyncClient,
) -> None:
"""GET /self-hosted/models returns [{model_name, display_name}] objects."""
await app_client_with_local.put(
"/api/providers/self-hosted",
json={"base_url": "http://192.168.1.10:11434"},
headers=_HDR_PM,
)
with patch(
"roboco.api.routes.provider.probe_ollama_tags",
new_callable=AsyncMock,
return_value=(["llama3.1:8b", "gemma2:9b", "qwen2.5:14b"], None),
):
response = await app_client_with_local.get(
"/api/providers/self-hosted/models",
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.OK
models = response.json()
assert isinstance(models, list)
assert len(models) == 3 # noqa: PLR2004
# Contract: each entry must be an object with model_name and display_name.
first = models[0]
assert isinstance(first, dict)
assert "model_name" in first
assert "display_name" in first
assert isinstance(first["model_name"], str)
assert isinstance(first["display_name"], str)
# Verify specific entry present.
names = [m["model_name"] for m in models]
assert "llama3.1:8b" in names
@pytest.mark.asyncio
async def test_get_self_hosted_models_not_configured_returns_404(
app_client_with_local: AsyncClient,
) -> None:
"""GET /self-hosted/models when no base_url configured returns 404."""
response = await app_client_with_local.get(
"/api/providers/self-hosted/models",
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_get_self_hosted_models_unreachable_returns_503(
app_client_with_local: AsyncClient,
) -> None:
"""GET /self-hosted/models when server unreachable returns 503."""
await app_client_with_local.put(
"/api/providers/self-hosted",
json={"base_url": "http://192.168.1.10:11434"},
headers=_HDR_PM,
)
with patch(
"roboco.api.routes.provider.probe_ollama_tags",
new_callable=AsyncMock,
return_value=([], "Could not connect"),
):
response = await app_client_with_local.get(
"/api/providers/self-hosted/models",
headers=_HDR_PM,
)
assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE