feat(cloud): manage LLM providers in database
Tests / Test passed: 664

This commit is contained in:
2026-07-14 00:31:47 +08:00
parent b613a315ff
commit a166ffd8a4
35 changed files with 2432 additions and 204 deletions
+18
View File
@@ -31,6 +31,7 @@ from cloud.control_config import (
)
from cloud.database import CloudDatabase
from cloud.internal_api.api import create_internal_router
from cloud.llm_providers import LlmProviderService
from cloud.plugins import PluginRegistry
from cloud.observability import (
CORRELATION_HEADER,
@@ -44,6 +45,7 @@ from cloud.scheduler import TaskScheduler
from cloud.schema import require_current_schema
from cloud.sdk.api import create_cloud_router
from cloud.sdk.governance_api import create_governance_router
from cloud.sdk.llm_provider_api import create_llm_provider_router
from cloud.sdk.user_api import create_user_auth_router
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
from core.models import utc_now
@@ -77,6 +79,7 @@ class CloudApplicationServices:
plugin_registry: PluginRegistry
auth_provider: Any
user_auth_service: UserAuthService
llm_provider_service: LlmProviderService
class SpaStaticFiles(StaticFiles):
@@ -126,6 +129,7 @@ def create_app(
cookie_secure=control_config.session_cookie_secure,
),
)
llm_provider_service = LlmProviderService(repository)
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
@@ -146,6 +150,7 @@ def create_app(
plugin_registry=plugin_registry,
auth_provider=auth_provider,
user_auth_service=user_auth_service,
llm_provider_service=llm_provider_service,
)
@asynccontextmanager
@@ -295,6 +300,18 @@ def create_app(
auth_provider=auth_provider,
)
)
app.include_router(
create_llm_provider_router(
service=llm_provider_service,
repository=repository,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_internal_router(
pool=pool,
@@ -307,6 +324,7 @@ def create_app(
planner_token_reservation_ttl_seconds=(
control_config.planner_token_reservation_ttl_seconds
),
planner_provider_service=llm_provider_service,
)
)
@@ -1,80 +1,51 @@
from __future__ import annotations
from cloud.planner_config import (
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_PROVIDER,
DEFAULT_TIMEOUT_SECONDS,
CloudPlannerConfig,
build_cloud_planner_client,
load_cloud_planner_config,
)
from datetime import UTC, datetime
from cloud.llm_providers import LlmProviderProfile, ResolvedLlmProviderProfile
from cloud.planner_config import build_cloud_planner_client
from runtime.tool_calling_client import (
AnthropicToolCallingClient,
OpenAIToolCallingClient,
)
_NO_RELEVANT_VARS = {"UNRELATED": "1"}
def test_load_cloud_planner_config_defaults_when_unset() -> None:
config = load_cloud_planner_config(_NO_RELEVANT_VARS)
assert config == CloudPlannerConfig(
provider=DEFAULT_PROVIDER,
model="",
timeout=DEFAULT_TIMEOUT_SECONDS,
def _resolved_profile(
provider_type: str, *, base_url: str | None = None
) -> ResolvedLlmProviderProfile:
now = datetime.now(UTC)
profile = LlmProviderProfile(
id="provider-1",
name="Managed provider",
name_normalized="managed provider",
provider_type=provider_type, # type: ignore[arg-type]
model="test-model",
base_url=base_url,
timeout_seconds=15,
api_key_ciphertext="ciphertext",
key_last_rotated_at=now,
enabled=True,
revision=1,
created_at=now,
updated_at=now,
)
assert config.resolved_model() == DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER]
return ResolvedLlmProviderProfile(profile=profile, api_key="managed-api-key")
def test_load_cloud_planner_config_selects_provider_and_resolves_default_model() -> (
None
):
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "openai"})
assert config.provider == "openai"
assert config.resolved_model() == "gpt-5.6"
def test_load_cloud_planner_config_falls_back_to_default_provider_when_unsupported() -> (
None
):
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "not-a-real-provider"})
assert config.provider == DEFAULT_PROVIDER
def test_load_cloud_planner_config_model_override_wins_regardless_of_provider() -> None:
config = load_cloud_planner_config(
{"AI_PLANNER_PROVIDER": "openai", "AI_PLANNER_MODEL": "custom-model"}
)
assert config.resolved_model() == "custom-model"
def test_load_cloud_planner_config_parses_valid_timeout() -> None:
config = load_cloud_planner_config({"AI_PLANNER_TIMEOUT_SECONDS": "12.5"})
assert config.timeout == 12.5
def test_load_cloud_planner_config_falls_back_to_default_timeout_when_invalid() -> None:
for value in ["not-a-number", "0", "-5"]:
config = load_cloud_planner_config(
{"AI_PLANNER_TIMEOUT_SECONDS": value, **_NO_RELEVANT_VARS}
)
assert config.timeout == DEFAULT_TIMEOUT_SECONDS
def test_build_cloud_planner_client_selects_anthropic_by_default() -> None:
client = build_cloud_planner_client(CloudPlannerConfig())
def test_build_cloud_planner_client_uses_managed_anthropic_key() -> None:
client = build_cloud_planner_client(_resolved_profile("anthropic"))
assert isinstance(client, AnthropicToolCallingClient)
assert client.model == DEFAULT_MODEL_BY_PROVIDER["anthropic"]
assert client.model == "test-model"
assert client._api_key == "managed-api-key"
def test_build_cloud_planner_client_selects_openai() -> None:
client = build_cloud_planner_client(CloudPlannerConfig(provider="openai"))
def test_build_cloud_planner_client_uses_openai_compatible_base_url() -> None:
client = build_cloud_planner_client(
_resolved_profile("openai-compatible", base_url="https://compat.example/v1")
)
assert isinstance(client, OpenAIToolCallingClient)
assert client.model == DEFAULT_MODEL_BY_PROVIDER["openai"]
assert client.model == "test-model"
assert client._api_key == "managed-api-key"
assert client._base_url == "https://compat.example/v1"
@@ -0,0 +1,151 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from cloud.control_config import CloudControlConfig
from cloud_api.app import create_app
def _create_admin(client: TestClient) -> None:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
response = client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 200
def _csrf_headers(client: TestClient) -> dict[str, str]:
token = client.cookies.get("amcp_csrf")
assert token is not None
return {"X-CSRF-Token": token}
def _profile_payload(**overrides: object) -> dict[str, object]:
payload = {
"name": "OpenAI Compatible",
"provider_type": "openai-compatible",
"model": "gpt-compatible",
"base_url": "https://compat.example/v1",
"timeout_seconds": 20,
"api_key": "provider-secret-value",
}
payload.update(overrides)
return payload
def test_provider_profiles_are_encrypted_redacted_and_activated(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
headers = _csrf_headers(client)
missing_csrf = client.post("/v1/planner/providers", json=_profile_payload())
assert missing_csrf.status_code == 403
empty_key = client.post(
"/v1/planner/providers",
headers=headers,
json=_profile_payload(api_key=""),
)
assert empty_key.status_code == 422
created = client.post(
"/v1/planner/providers", headers=headers, json=_profile_payload()
)
assert created.status_code == 201, created.text
profile = created.json()
assert profile["has_api_key"] is True
assert "api_key" not in profile
assert "ciphertext" not in profile
repository = client.app.state.cloud_services.repository
stored = repository.get_llm_provider_profile(profile["id"])
assert stored is not None
assert stored.api_key_ciphertext != "provider-secret-value"
assert "provider-secret-value" not in stored.api_key_ciphertext
listed = client.get("/v1/planner/providers")
assert listed.status_code == 200
assert listed.json()["settings"]["active_profile_id"] is None
assert "provider-secret-value" not in listed.text
assert stored.api_key_ciphertext not in listed.text
activated = client.post(
f"/v1/planner/providers/{profile['id']}/activate",
headers=headers,
json={"expected_settings_revision": 0},
)
assert activated.status_code == 200, activated.text
assert activated.json()["active_profile_id"] == profile["id"]
disable_active = client.patch(
f"/v1/planner/providers/{profile['id']}",
headers=headers,
json={"enabled": False, "expected_revision": profile["revision"]},
)
assert disable_active.status_code == 409
def test_provider_profile_requires_admin_scope(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
service = client.app.state.cloud_services.user_auth_service
service.create_user(
username="operator",
display_name="Operator",
role="operator",
password="correct-horse-battery-staple",
must_change_password=False,
)
assert (
client.post(
"/v1/auth/login",
json={
"username": "operator",
"password": "correct-horse-battery-staple",
},
).status_code
== 200
)
assert client.get("/v1/planner/providers").status_code == 403
assert (
client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(),
).status_code
== 403
)
def test_profile_write_requires_encryption_key(monkeypatch) -> None:
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
response = client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(),
)
assert response.status_code == 503
assert "provider-secret-value" not in response.text
@@ -0,0 +1,155 @@
from __future__ import annotations
from fastapi.testclient import TestClient
import cloud.internal_api.api as internal_api
from cloud.control_config import CloudControlConfig
from runtime.tool_calling_client import ToolCallDecision
from cloud_api.app import create_app
class _FakePlannerClient:
def __init__(self) -> None:
self.calls = 0
def decide(self, **_kwargs) -> ToolCallDecision:
self.calls += 1
return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
def _login_admin(client: TestClient) -> dict[str, str]:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
assert (
client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
).status_code
== 200
)
csrf = client.cookies.get("amcp_csrf")
assert csrf is not None
return {"X-CSRF-Token": csrf}
def _create_profile(
client: TestClient, headers: dict[str, str], *, name: str, model: str
) -> dict:
response = client.post(
"/v1/planner/providers",
headers=headers,
json={
"name": name,
"provider_type": "openai-compatible",
"model": model,
"base_url": "https://compat.example/v1",
"timeout_seconds": 30,
"api_key": f"key-for-{name}",
},
)
assert response.status_code == 201, response.text
return response.json()
def _enroll_host(client: TestClient) -> tuple[str, dict[str, str]]:
enrollment = client.post(
"/internal/v1/enrollments",
json={"agent_instance_id": "agent-a", "host_token": "host-token-" + ("a" * 40)},
)
assert enrollment.status_code == 201, enrollment.text
return enrollment.json()["host_id"], {
"Authorization": "Bearer host-token-" + ("a" * 40)
}
def _decision_payload(host_id: str) -> dict:
return {
"host_id": host_id,
"system_prompt": "system",
"user_prompt": "user",
"tools": [{"name": "tap", "description": "tap", "parameters": {}}],
"timeout_seconds": 10,
}
def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
resolved_profiles = []
fake = _FakePlannerClient()
def build(resolved):
resolved_profiles.append(resolved)
return fake
monkeypatch.setattr(internal_api, "build_cloud_planner_client", build)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
admin_headers = _login_admin(client)
first = _create_profile(
client, admin_headers, name="First", model="first-model"
)
assert (
client.post(
f"/v1/planner/providers/{first['id']}/activate",
headers=admin_headers,
json={"expected_settings_revision": 0},
).status_code
== 200
)
host_id, host_headers = _enroll_host(client)
first_decision = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert first_decision.status_code == 200, first_decision.text
assert resolved_profiles[-1].profile.model == "first-model"
assert resolved_profiles[-1].api_key == "key-for-First"
second = _create_profile(
client, admin_headers, name="Second", model="second-model"
)
settings = client.get("/v1/planner/providers").json()["settings"]
activated = client.post(
f"/v1/planner/providers/{second['id']}/activate",
headers=admin_headers,
json={"expected_settings_revision": settings["revision"]},
)
assert activated.status_code == 200, activated.text
second_decision = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert second_decision.status_code == 200, second_decision.text
assert resolved_profiles[-1].profile.model == "second-model"
assert fake.calls == 2
def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None:
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
host_id, host_headers = _enroll_host(client)
response = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert response.status_code == 502
assert response.json() == {
"code": "planner_unavailable",
"detail": "no active database Provider profile",
}