mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[3cc1729c] Add self-hosted LLM provider with dynamic model discovery (#128)
* [684dace4] Self-hosted LLM provider: API layer, hooks, UI section, routing mode button, and Mix mode grouping (#124) (#126) * [684dace4] feat(providers): add self-hosted LLM API types, endpoints, and React Query hooks - Add ModelProvider.SELF_HOSTED enum value to types/index.ts - Extend RoutingMode to include 'self_hosted' in lib/api/providers.ts - Add SelfHostedConfig, SelfHostedTestResult, SelfHostedModel interfaces - Add SelfHostedConfigPayload for PUT requests - Add 5 providersApi methods: getSelfHostedConfig, saveSelfHostedConfig, testSelfHosted, getSelfHostedModels, refreshSelfHostedModels - Add 5 React Query hooks: useSelfHostedConfig, useSetSelfHostedConfig, useTestSelfHosted, useSelfHostedModels, useRefreshSelfHostedModels - Cache keys follow existing providerKeys pattern with proper invalidation * [684dace4] feat(settings): create SelfHostedSection component with full self-hosted LLM UI - Base URL text input with placeholder showing saved URL when set - Optional auth token field (type='password') with Eye/EyeOff toggle button - Save button that calls useSetSelfHostedConfig mutation - Test Connection button disabled until a URL is saved; shows inline green 'Connected — N models' badge on success or red error badge on fail - Three empty states: no URL configured (CTA), error state (last-checked + Retry), connected with 0 models (pull-guidance) - Model list with auto-discovered chip, Refresh Models button, and Last refreshed relative timestamp when test_status === 'connected' - Token field shows masked placeholder when has_auth_token is true (consistent with Ollama Cloud key field pattern) * [684dace4] feat(settings): add Self-Hosted mode button, model picker, and Mix mode provider grouping - Wire SelfHostedSection into AIRoutingCard with testResult state tracking - Expand routing mode grid from 3 to 4 buttons (2×2 on mobile, 4-col on md+) - 4th 'Self-Hosted' mode button disabled until test_status === 'connected' - Self-hosted model picker appears below mode grid when mode === 'self_hosted' - flipToSelfHosted handler sends mode='self_hosted' with optional default_model - Mix mode per-agent dropdown now groups entries under SelectGroup/SelectLabel headings: Anthropic, Ollama Cloud, Self-Hosted with colored ProviderBadge pill - saveMix validates self-hosted model selection requires a successful test - ProviderBadge helper renders blue/violet/purple pills for each provider type - pnpm typecheck and pnpm lint pass with zero errors --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [2897ce90] Implement self-hosted LLM provider API, routing, and discovery (#125) (#127) * [2897ce90] feat(provider): add self-hosted LLM provider API, routing, and discovery - Add migration 027 to seed Self-Hosted (Ollama) LOCAL provider row - Add probe_ollama_tags() helper for Ollama /api/tags connectivity checks - Extend ModelRoutingService: derive_mode returns 'self_hosted' for LOCAL GLOBAL assignments; apply_mode handles 'self_hosted' mode; upsert_assignment routes non-catalog model names to LOCAL provider; resolve_for_agent falls back to Anthropic when self-hosted server is unreachable - Add PUT /api/providers/self-hosted, POST /api/providers/self-hosted/test, GET /api/providers/self-hosted/models endpoints - Extend ApplyModeRequest and ModeResponse literals with 'self_hosted' - Add SelfHostedConfigRequest, SelfHostedConfigResponse, SelfHostedTestResponse schemas * [2897ce90] test(provider): add integration tests for self-hosted routing and route endpoints - Add llm_setup_with_local fixture that seeds LOCAL provider row - Test derive_mode returns 'self_hosted' for single GLOBAL LOCAL assignment - Test apply_mode('self_hosted') clears prior assignments, enables LOCAL, inserts GLOBAL - Test apply_mode('self_hosted') requires default_model argument - Test upsert_assignment routes non-catalog model names to LOCAL provider - Test mix mode accepts self-hosted model names without ValueError - Test resolve_for_agent returns base_url when LOCAL server is reachable - Test resolve_for_agent falls back to Anthropic when LOCAL server is unreachable - Test upsert_assignment raises ValueError when model unknown and no LOCAL provider - Add app_client_with_local fixture for route tests - Test PUT /self-hosted saves base_url and enables provider - Test PUT /self-hosted stores encrypted token when auth_token provided - Test PUT /self-hosted returns 404 when LOCAL provider not seeded - Test POST /self-hosted/test returns {ok:true,model_count:N} when reachable - Test POST /self-hosted/test returns {ok:false,error} (never 500) when unreachable - Test GET /self-hosted/models returns model name list - Test GET /self-hosted/models returns 404 when not configured - Test GET /self-hosted/models returns 503 when server unreachable - Rename migration from 027 to 028 to rebase on 027_system_settings * [2897ce90] chore(migration): remove superseded 027 migration, fix formatter changes to provider schemas --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [042462df] feat(providers): align self-hosted types, hooks, and UI to backend contract (#129) (#131) - SelfHostedConfig now has {base_url: string, has_token: boolean, enabled: boolean} - SelfHostedTestResult now has {ok: boolean, model_count: number | null, error: string | null} - Remove SelfHostedTestStatus type and refreshSelfHostedModels POST API function - Remove SELF_HOSTED from ModelProvider enum (LOCAL covers self-hosted semantics) - useRefreshSelfHostedModels now invalidates GET cache instead of calling POST - isSelfHostedConnected derived from testResult?.ok === true - Self-hosted model picker uses value='__clear__' sentinel (no empty-string SelectItem) - self-hosted-section.tsx reads result.ok/result.error and config?.has_token - pnpm typecheck passes with zero errors Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [f66d6d4d] Fix self-hosted API S1-S4/L1-L5: routes, schemas, services, migration 028, and tests (#130) (#132) * [f66d6d4d] fix(provider): self-hosted API S1-S4/L1-L5 - routes, schemas, services, migration 028, and tests AC1: Add GET /providers/self-hosted returning {base_url, has_token, enabled} AC2: GET /self-hosted/models now returns list[SelfHostedModelEntry] with model_name and display_name AC3: probe_ollama_tags generic except logs exception server-side and returns hardcoded generic string AC4: upsert_assignment calls ProviderService.update_provider(enabled=True) when routing to LOCAL AC5: derive_mode return annotation is Literal[...] — type:ignore comments removed AC6: All migration refs in routes/services say 028 (not 027) AC7: Migration 028 downgrade() deletes model_assignments before provider_configs AC8: PUT /self-hosted only passes enabled=True when data.base_url is non-empty AC9: ModelProvider.LOCAL docstring updated to describe self-hosted Ollama provider AC10: Direct unit tests for probe_ollama_tags (5 cases) in tests/unit/llm/ AC11: Contract tests added/updated for GET /providers/self-hosted, models, and test endpoints AC12: test_migration_028_seed_self_hosted.py with upgrade and FK-safe downgrade tests AC13: test_apply_mode_ollama_without_provider_returns_404 asserts exactly HTTPStatus.NOT_FOUND AC14: ruff and mypy pass with zero errors * [f66d6d4d] fix(tests): add AC4 test proving LOCAL.enabled transitions False->True in upsert_assignment The existing tests (test_upsert_assignment_routes_unknown_model_to_local and test_mix_mode_with_self_hosted_models) both use llm_setup_with_local which seeds LOCAL with enabled=True, making the AC4 assertion vacuous. New test test_upsert_assignment_enables_local_when_disabled: - Creates LOCAL ProviderConfigTable row with enabled=False - Asserts pre-condition: local.enabled is False - Calls upsert_assignment with a non-catalog model name ('non-catalog-model:7b') - Refreshes LOCAL row via db_session.refresh(local) - Asserts row.provider.type == ModelProvider.LOCAL and local.enabled is True This proves the state transition from False->True, not merely that the already-enabled state is preserved. ruff and mypy still pass with zero errors. --------- Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [7cd6ae6e] fix(providers): type SelfHostedConfig.base_url as string | null to match backend contract (#133) (#136) Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> * [46ee9104] test(migration_028): replace upgrade test with self-seeding contract test (#134) (#135) Remove test_migration_028_upgrade_local_row_inserted which relied on alembic upgrade head having run (and thus the Self-Hosted Ollama row being present). Replace it with test_migration_028_upgrade_insert_contract that: - Executes the exact INSERT SQL from migration 028 upgrade() directly - Asserts name='Self-Hosted (Ollama)', type='local', enabled=False - Runs the INSERT a second time and asserts exactly one row (ON CONFLICT DO NOTHING idempotency) The downgrade test is left byte-for-byte unchanged. Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * [f0d19f30] test(provider): add DELETE-before-seed isolation and app_client_with_ollama fixture (#137) (#138) - Add ModelAssignmentTable import to test_provider_routes.py - Fix app_client_with_local: execute DELETE on ModelAssignmentTable then DELETE on ProviderConfigTable (FK-safe order) and flush before seeding - Add new app_client_with_ollama fixture with same isolation pattern, seeding only ANTHROPIC + OLLAMA_CLOUD rows - Update 7 tests to use app_client_with_ollama instead of app_client: test_get_catalog, test_get_ollama_key_status, test_set_ollama_key, test_get_current_mode, test_apply_mode_anthropic_clears_assignments, test_apply_mode_unknown_returns_4xx, test_apply_mode_mix_without_per_agent_returns_400 Fixes order-dependent failures in test_get_self_hosted_models_not_configured_returns_404: routes call db.commit() which persists rows across test sessions; without DELETE-before-seed, stale LOCAL provider rows with base_url set from prior runs cause the test to see 503 instead of 404. Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> * refactor(llm): split resolve_for_agent and apply_mode to clear xenon rank C resolve_for_agent and apply_mode were cyclomatic rank C, failing the xenon gate (--max-absolute B). Extract behavior-preserving helpers: - resolve_for_agent -> _resolve_assignment (precedence ladder), _route_from_resolved / _local_route_or_none / _decrypt_route_or_none (None signals fall-through to legacy), _legacy_route. - apply_mode -> _apply_anthropic / _apply_ollama / _apply_self_hosted / _apply_mix dispatched from a thin if/elif. No behavior change. Also correct the stale 'default: Kimi K2.6' docstring (OLLAMA_DEFAULT_MODEL is minimax-m3:cloud). --------- Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev> Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev> Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
co-authored by
Frontend Developer 1
Backend Developer 1
Renn F
parent
0daef044d2
commit
73b7c16211
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user