diff --git a/apps/cloud-api/cloud_api/app.py b/apps/cloud-api/cloud_api/app.py index 51bd6fc..ad9ae0f 100644 --- a/apps/cloud-api/cloud_api/app.py +++ b/apps/cloud-api/cloud_api/app.py @@ -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, ) ) diff --git a/apps/cloud-api/tests/test_cloud_planner_config.py b/apps/cloud-api/tests/test_cloud_planner_config.py index ff62a67..0ddc1ac 100644 --- a/apps/cloud-api/tests/test_cloud_planner_config.py +++ b/apps/cloud-api/tests/test_cloud_planner_config.py @@ -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" diff --git a/apps/cloud-api/tests/test_llm_provider_management.py b/apps/cloud-api/tests/test_llm_provider_management.py new file mode 100644 index 0000000..bd4d126 --- /dev/null +++ b/apps/cloud-api/tests/test_llm_provider_management.py @@ -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 diff --git a/apps/cloud-api/tests/test_llm_provider_planner_resolution.py b/apps/cloud-api/tests/test_llm_provider_planner_resolution.py new file mode 100644 index 0000000..120e212 --- /dev/null +++ b/apps/cloud-api/tests/test_llm_provider_planner_resolution.py @@ -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", + } diff --git a/cloud-console/src/App.vue b/cloud-console/src/App.vue index d9ddc0d..d8526ad 100644 --- a/cloud-console/src/App.vue +++ b/cloud-console/src/App.vue @@ -7,6 +7,7 @@ import { LogOut, MonitorSmartphone, Puzzle, + SlidersHorizontal, UsersRound, } from "@lucide/vue"; import { @@ -14,6 +15,7 @@ import { getCurrentUser, logout, } from "./api"; +import { hasScope } from "./permissions"; import type { CloudUser } from "./types"; import LoginScreen from "./views/LoginScreen.vue"; import PasswordChangeScreen from "./views/PasswordChangeScreen.vue"; @@ -21,8 +23,9 @@ import TasksView from "./views/TasksView.vue"; import DevicesView from "./views/DevicesView.vue"; import PluginsView from "./views/PluginsView.vue"; import UsersView from "./views/UsersView.vue"; +import LlmProvidersView from "./views/LlmProvidersView.vue"; -type ViewId = "tasks" | "devices" | "plugins" | "users"; +type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers"; const activeView = ref("tasks"); const currentUser = ref(null); @@ -49,6 +52,7 @@ const canAdminGovernance = computed( currentUser.value?.scopes.includes("*") || currentUser.value?.scopes.includes("governance:admin")), ); +const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin")); const isAuthenticated = computed(() => currentUser.value !== null); const currentUserLabel = computed(() => currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "", @@ -63,6 +67,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() = if (canAdminUsers.value || canAdminGovernance.value) { items.push({ id: "users", label: "Users & limits", icon: UsersRound }); } + if (canAdminProviders.value) { + items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal }); + } return items; }); @@ -119,6 +126,8 @@ const activeComponent = computed(() => { return PluginsView; case "users": return UsersView; + case "providers": + return LlmProvidersView; default: return TasksView; } @@ -155,6 +164,7 @@ const activeComponent = computed(() => { :can-admin-users="canAdminUsers" :can-admin-governance="canAdminGovernance" /> + diff --git a/cloud-console/src/api.test.ts b/cloud-console/src/api.test.ts index 68d969b..5f760cc 100644 --- a/cloud-console/src/api.test.ts +++ b/cloud-console/src/api.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_INVALID_EVENT, + createLlmProviderProfile, listDevices, login, registerPlugin, @@ -74,6 +75,44 @@ describe("Cloud Console API authentication", () => { ); }); + it("sends a Provider profile write through the CSRF-protected management API", async () => { + document.cookie = "amcp_csrf=csrf-value; path=/"; + vi.mocked(fetch).mockResolvedValueOnce( + response({ + id: "provider-a", + name: "OpenAI compatible", + provider_type: "openai-compatible", + model: "model-a", + base_url: "https://compat.example/v1", + timeout_seconds: 30, + enabled: true, + revision: 1, + has_api_key: true, + key_last_rotated_at: "2026-01-01T00:00:00+00:00", + created_at: "2026-01-01T00:00:00+00:00", + updated_at: "2026-01-01T00:00:00+00:00", + active: false, + }), + ); + + await createLlmProviderProfile({ + name: "OpenAI compatible", + provider_type: "openai-compatible", + model: "model-a", + base_url: "https://compat.example/v1", + timeout_seconds: 30, + api_key: "secret-value", + }); + + expect(fetch).toHaveBeenCalledWith( + expect.stringMatching(/\/v1\/planner\/providers$/), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ "X-CSRF-Token": "csrf-value" }), + }), + ); + }); + it("invalidates the session only on 401", async () => { const invalidated = vi.fn(); window.addEventListener(AUTH_INVALID_EVENT, invalidated); diff --git a/cloud-console/src/api.ts b/cloud-console/src/api.ts index bafb6d2..1e7a121 100644 --- a/cloud-console/src/api.ts +++ b/cloud-console/src/api.ts @@ -3,6 +3,10 @@ import type { DeviceRecord, HostGovernancePolicy, HostTokenUsageSummary, + LlmProviderProfile, + LlmProviderProfileListResponse, + LlmProviderSettings, + LlmProviderType, HostRecord, PluginRecord, PluginRegistrationPayload, @@ -253,3 +257,63 @@ export function registerPlugin(payload: PluginRegistrationPayload): Promise { + return request("/v1/planner/providers"); +} + +export function createLlmProviderProfile(payload: { + name: string; + provider_type: LlmProviderType; + model: string; + base_url?: string | null; + timeout_seconds: number; + api_key: string; +}): Promise { + return request("/v1/planner/providers", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function updateLlmProviderProfile( + profileId: string, + payload: { + name?: string; + provider_type?: LlmProviderType; + model?: string; + base_url?: string | null; + timeout_seconds?: number; + enabled?: boolean; + api_key?: string; + expected_revision?: number; + }, +): Promise { + return request(`/v1/planner/providers/${encodeURIComponent(profileId)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export function activateLlmProviderProfile( + profileId: string, + expectedSettingsRevision: number, +): Promise { + return request( + `/v1/planner/providers/${encodeURIComponent(profileId)}/activate`, + { + method: "POST", + body: JSON.stringify({ expected_settings_revision: expectedSettingsRevision }), + }, + ); +} + +export function deleteLlmProviderProfile( + profileId: string, + expectedRevision: number, +): Promise { + return request( + `/v1/planner/providers/${encodeURIComponent(profileId)}?expected_revision=${expectedRevision}`, + { method: "DELETE" }, + ); +} diff --git a/cloud-console/src/permissions.test.ts b/cloud-console/src/permissions.test.ts new file mode 100644 index 0000000..bcf84b2 --- /dev/null +++ b/cloud-console/src/permissions.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { hasScope } from "./permissions"; +import type { CloudUser } from "./types"; + +const baseUser: Omit = { + id: "user-a", + username: "operator", + display_name: "Operator", + role: "operator", + enabled: true, + must_change_password: false, + created_at: "2026-01-01T00:00:00+00:00", + updated_at: "2026-01-01T00:00:00+00:00", + last_login_at: null, +}; + +describe("Provider navigation permission", () => { + it("requires the Provider administration scope", () => { + expect(hasScope({ ...baseUser, scopes: ["tasks:read"] }, "llm-providers:admin")).toBe(false); + expect(hasScope({ ...baseUser, scopes: ["llm-providers:admin"] }, "llm-providers:admin")).toBe(true); + expect(hasScope({ ...baseUser, scopes: ["*"] }, "llm-providers:admin")).toBe(true); + }); +}); diff --git a/cloud-console/src/permissions.ts b/cloud-console/src/permissions.ts new file mode 100644 index 0000000..9b72815 --- /dev/null +++ b/cloud-console/src/permissions.ts @@ -0,0 +1,5 @@ +import type { CloudUser } from "./types"; + +export function hasScope(user: CloudUser | null, scope: string): boolean { + return Boolean(user?.scopes.includes("*") || user?.scopes.includes(scope)); +} diff --git a/cloud-console/src/style.css b/cloud-console/src/style.css index b9a15fb..5e5744a 100644 --- a/cloud-console/src/style.css +++ b/cloud-console/src/style.css @@ -65,6 +65,20 @@ button.primary:hover:not(:disabled) { background: var(--accent-hover); } +button.icon-button { + width: 30px; + height: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +button.danger:hover:not(:disabled) { + background: var(--danger-bg); + border-color: var(--danger); +} + input, select, textarea { @@ -356,6 +370,13 @@ tr.row-selected { color: var(--text-muted); } +.provider-actions { + display: flex; + justify-content: flex-end; + gap: 6px; + white-space: nowrap; +} + .loader { display: inline-block; animation: spin 1.2s linear infinite; diff --git a/cloud-console/src/types.ts b/cloud-console/src/types.ts index a2be7bd..ce83a46 100644 --- a/cloud-console/src/types.ts +++ b/cloud-console/src/types.ts @@ -149,3 +149,32 @@ export interface TokenUsageEvent { total_tokens: number; occurred_at: string; } + +export type LlmProviderType = "anthropic" | "openai-compatible"; + +export interface LlmProviderProfile { + id: string; + name: string; + provider_type: LlmProviderType; + model: string; + base_url: string | null; + timeout_seconds: number; + enabled: boolean; + revision: number; + has_api_key: boolean; + key_last_rotated_at: string; + created_at: string; + updated_at: string; + active: boolean; +} + +export interface LlmProviderSettings { + active_profile_id: string | null; + revision: number; + updated_at: string | null; +} + +export interface LlmProviderProfileListResponse { + settings: LlmProviderSettings; + items: LlmProviderProfile[]; +} diff --git a/cloud-console/src/views/LlmProvidersView.vue b/cloud-console/src/views/LlmProvidersView.vue new file mode 100644 index 0000000..127e299 --- /dev/null +++ b/cloud-console/src/views/LlmProvidersView.vue @@ -0,0 +1,235 @@ + + + diff --git a/docs/CLOUD_DEPLOYMENT.md b/docs/CLOUD_DEPLOYMENT.md index 4b93bb5..c106cd9 100644 --- a/docs/CLOUD_DEPLOYMENT.md +++ b/docs/CLOUD_DEPLOYMENT.md @@ -379,10 +379,22 @@ planning decision through the Cloud API's host-scoped bearer credential used for heartbeat/claim/renew/result). In this mode: -- **Credentials move to the Cloud API.** Configure `AI_PLANNER_PROVIDER`, - `AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`, and - `ANTHROPIC_API_KEY`/`OPENAI_API_KEY` on the Cloud API process instead of the - Host Agent -- edge hosts no longer need provider keys at all. +- **Provider configuration lives in the Cloud database.** Set + `CLOUD_LLM_PROVIDER_ENCRYPTION_KEY` from the deployment secret manager, then + sign in to `/console/` as an administrator and create an active entry under + **LLM providers**. Provider API keys are encrypted in the database and are + never returned by the API or Console. Edge Hosts do not hold Provider keys. + The Cloud API does not read `AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`, + `AI_PLANNER_TIMEOUT_SECONDS`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`. +- **Profile types:** choose **Anthropic** for native Anthropic tool use, or + **OpenAI-compatible** for the OpenAI Chat Completions tool-calling protocol. + Leave its Base URL blank for official OpenAI, or provide the compatible + provider's absolute HTTP(S) `/v1` endpoint. Providers requiring another + request schema or custom authentication are not supported by this path. +- **Activation is immediate:** a newly activated enabled profile becomes the + Provider/model for the next Cloud-proxy planner decision. A Cloud-planner + request fails closed until one enabled profile is active; it never falls back + to a Cloud API environment credential. - **Trade-offs to accept before enabling:** - *Latency*: every planning step now makes a round trip to the Cloud API in addition to the LLM provider call. @@ -420,11 +432,16 @@ Hosts reporting `AI_PLANNER_TRANSPORT=direct` are explicitly shown as **unmetered**. Cloud cannot enforce or verify their provider token use. Do not interpret an unmetered Host's absence of usage events as budget compliance. -Roll out in this order: migrate the Cloud database, deploy the Cloud API, -switch a pilot Host to Cloud transport, configure a budget above the reservation -ceiling, then review its usage events before enabling budgets fleet-wide. A -rollback to direct transport requires valid provider credentials on that Host; -preserve usage and policy rows rather than deleting accounting history. +Roll out in this order: migrate the Cloud database, provision +`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY`, deploy the Cloud API, create and activate +a Provider profile in `/console/`, then switch a pilot Host to Cloud transport. +Configure a budget above the reservation ceiling and review usage events before +enabling budgets fleet-wide. Rotate a Provider API key by editing that profile; +the encryption master key is deployment-managed and must be preserved with the +database backups. A rollback to an older Cloud API requires restoring its +legacy Provider environment configuration, while a rollback to `direct` +transport requires valid provider credentials on that Host; preserve usage and +policy rows rather than deleting accounting history. ## Operational Limitations diff --git a/openspec/changes/database-llm-provider-management/.openspec.yaml b/openspec/changes/database-llm-provider-management/.openspec.yaml new file mode 100644 index 0000000..b119b63 --- /dev/null +++ b/openspec/changes/database-llm-provider-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/database-llm-provider-management/design.md b/openspec/changes/database-llm-provider-management/design.md new file mode 100644 index 0000000..b0da9a9 --- /dev/null +++ b/openspec/changes/database-llm-provider-management/design.md @@ -0,0 +1,178 @@ +## Context + +`cloud-planner-proxy` centralizes an LLM call for Hosts that opt into +`AI_PLANNER_TRANSPORT=cloud`, but `cloud.planner_config` currently resolves +provider, model, and credentials from Cloud API environment variables. The +proxy route builds a client for every decision request, so it already has a +natural per-request configuration boundary. The Cloud Control Plane also has a +durable SQLAlchemy repository, Alembic migrations, session-authenticated +administrator Console, CSRF protection, and auth audit records that this +change can reuse. + +The existing runtime clients instantiate provider SDKs with no arguments and +therefore read keys from their environment. Database-backed credentials and +an OpenAI-compatible base URL require those clients to accept explicit values +while preserving the unchanged direct-transport default. + +## Goals / Non-Goals + +**Goals:** +- Let administrators manage a Cloud-wide catalog of `anthropic` and + `openai-compatible` planner profiles from the Cloud Console. +- Encrypt every stored Provider API key and never return or log its plaintext. +- Switch the provider/model used by all cloud-transport Host Agents without a + Host configuration change, Cloud API restart, or local credential. +- Support OpenAI-compatible endpoints through a configurable base URL and + API key using the existing Chat Completions tool-calling implementation. +- Remove Cloud API environment-based planner Provider configuration so every + cloud-transport decision is governed by the active database profile. + +**Non-Goals:** +- Per-Host, per-user, per-task, weighted, or failover Provider selection. +- Provider usage billing, connection-test endpoints, model discovery, or + support for protocols other than Anthropic native tool use and OpenAI Chat + Completions tool calling. +- Arbitrary Anthropic-compatible endpoints, custom request headers, or + different OpenAI-compatible parameter dialects. +- Automatic encryption-master-key rotation. Provider API key rotation is + supported by updating a profile; master-key rotation remains an operational + migration until a key-ring design is separately proposed. + +## Decisions + +### D1: Persist a profile catalog and a singleton active-profile setting + +Add `cloud_llm_provider_profiles` with immutable id, unique normalized name, +provider type, model, optional base URL, timeout, encrypted API key, enabled +state, revision, and timestamps. Add a one-row +`cloud_llm_provider_settings` record with `database_management_enabled`, +`active_profile_id`, revision, and timestamp. The profile table represents +the operator's choices; the singleton holds the one Cloud-wide choice that is +active. + +Activating an enabled profile updates the singleton in the same transaction +and sets `database_management_enabled=true`. An active profile cannot be +disabled or deleted before another enabled profile is activated. This avoids +the ambiguity and cross-database portability problems of a partial unique +"one active row" index, and gives PostgreSQL and SQLite one repository +contract. + +Alternative considered: an `is_active` column on profiles. Rejected because +atomic replacement and uniqueness semantics differ between SQLite and +PostgreSQL, while a singleton pointer is explicit and makes initial +environment-fallback state representable. + +### D2: Resolve the active profile on every planner decision + +The internal planner endpoint resolves the active profile from the repository +at the start of each request, decrypts its API key, and builds the existing +tool-calling client with the resolved values. It uses that same resolved +profile for token-usage metadata, preventing a model switch during the call +from recording a mismatched provider/model. No application-level cache is +used; the one indexed settings lookup and symmetric decryption are negligible +relative to an LLM request and make a successful activation effective on the +next decision. + +The resolver fails a planner request with the existing structured +`planner_unavailable` response when the active profile is missing, disabled, +corrupt, or undecryptable. It never reads legacy `AI_PLANNER_*` or Provider +API-key environment variables. + +Alternative considered: move profile configuration into Host Agent heartbeat +responses. Rejected because it would replicate API keys to edge hosts, delay +changes until heartbeat, and duplicate the Cloud proxy's existing decision +boundary. + +### D3: Use Fernet encryption with an environment-held master key + +`cryptography` is added to `device-cloud-platform`. A small secret-box port +uses `cryptography.fernet.Fernet` with +`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY`; only Fernet ciphertext is stored in the +database. The key remains environment or secret-manager configuration and is +not stored in the database. Profile write requests accept a plaintext API key +only to encrypt it; profile read responses expose `has_api_key` and +`key_last_rotated_at` rather than a key value. + +The Cloud API can list profile metadata without the encryption key, but +creating, rotating, activating, or resolving a profile without a valid key +fails with a controlled configuration error that excludes the supplied secret. +Provider mutation audit events retain profile id and action only. + +Alternative considered: plaintext database keys. Rejected because database +backup or read access would expose external-provider credentials. Alternative +considered: a database-held master key. Rejected because it does not create a +separate protection boundary. + +### D4: Treat OpenAI-compatible as a concrete wire-protocol contract + +Profiles have `provider_type` of `anthropic` or `openai-compatible`. An +`openai-compatible` profile may omit `base_url` to use the official OpenAI +endpoint, or supply an absolute HTTP(S) base URL for a compatible service. +The runtime `OpenAIToolCallingClient` gains optional `api_key` and `base_url` +constructor arguments and constructs `OpenAI` explicitly when supplied. +`AnthropicToolCallingClient` gains an optional explicit API key for cloud-held +credentials; absent optional arguments retain current SDK environment +behavior for direct transport. + +Compatibility means the endpoint accepts OpenAI Chat Completions requests +with the tool/function-calling fields emitted by the existing client and +returns a single compatible tool call. It does not claim compatibility with +providers requiring a different request schema or custom authentication. + +Alternative considered: create a new cloud-only HTTP adapter. Rejected because +it duplicates response parsing and would drift from the direct provider path. + +### D5: Expose a dedicated administrator API and Console view + +Add a public `/v1/planner/providers` router composed by the Cloud API. It +requires a new `llm-providers:admin` scope, which the existing admin role +receives via its wildcard. All writes use the existing session CSRF validator +and record non-secret auth audit events. The Console shows only for principals +with that scope and provides profile creation, editing/key rotation, +enable/disable, activation, and deletion of inactive profiles. Responses use +revisions for optimistic-concurrency conflicts rather than overwriting an +administrator's newer edit. + +Alternative considered: reuse a generic configuration endpoint. Rejected +because Provider records carry secrets and need substantially stricter output, +audit, and activation invariants. + +## Risks / Trade-offs + +- [Risk] The Cloud database and encryption key become part of the planner hot + path. -> Mitigation: resolver failures use the established fail-fast + response, and readiness/configuration documentation makes the required + active profile explicit. +- [Risk] An administrator can direct Cloud traffic to an arbitrary compatible + base URL. -> Mitigation: only `llm-providers:admin` can write a profile; + deployments must treat that role as privileged network-configuration access. +- [Risk] Losing the master key makes stored Provider credentials unusable. -> + Mitigation: document secret-manager backup and keep a legacy deployment + rollback path while adopting the feature. +- [Risk] Some services marketed as OpenAI-compatible diverge on parameters or + tools. -> Mitigation: document the exact Chat Completions tool-calling + contract and surface provider failures instead of retrying against another + profile. +- [Risk] Concurrent Console administrators can race to edit or activate a + profile. -> Mitigation: revision checks on profile/settings writes and a + transaction for activation. + +## Migration Plan + +1. Apply the migration and provision `CLOUD_LLM_PROVIDER_ENCRYPTION_KEY` from + the deployment secret manager before routing cloud-transport planner work + to the new Cloud API version. +2. Create and activate a profile through the administrator Console. Until an + active profile exists, cloud planner requests fail closed. +3. Verify profile metadata and token-usage provider/model labels, then remove + `AI_PLANNER_*`, `ANTHROPIC_API_KEY`, and `OPENAI_API_KEY` from the Cloud API + deployment configuration. +4. To roll back, restore a previous Cloud API version together with its legacy + environment configuration. Do not remove the encryption key or database + rows before a planned migration. + +## Open Questions + +- None for the initial Cloud-wide selection. Per-Host assignment, cost-aware + routing, and master-key rotation need separate designs because they change + the selection and operational contracts. diff --git a/openspec/changes/database-llm-provider-management/proposal.md b/openspec/changes/database-llm-provider-management/proposal.md new file mode 100644 index 0000000..730a912 --- /dev/null +++ b/openspec/changes/database-llm-provider-management/proposal.md @@ -0,0 +1,56 @@ +## Why + +The Cloud Control Plane proxy currently reads its LLM provider, model, and +credentials from process environment variables. Changing a model therefore +requires a deployment change and cannot be audited or operated from the Cloud +Console, even though every cloud-transport Host Agent already depends on the +control plane for its planner decisions. + +## What Changes + +- Add a durable, Cloud-wide catalog of planner Provider profiles. Each profile + records its supported provider kind (`anthropic` or `openai-compatible`), + model, timeout, optional OpenAI-compatible base URL, enabled state, and an + encrypted API key. +- Add an administrator-only Cloud API and Cloud Console view to create, list, + update, activate, disable, and retire Provider profiles. Read responses + never expose an API key; writes can rotate a key without returning it. +- Make the cloud planner-decision endpoint resolve the active profile from the + database for each request, so a newly activated profile applies to all + cloud-transport Host Agents without changing their configuration or + restarting them. +- Remove Cloud API environment-variable configuration for the planner + provider, model, timeout, and Provider API keys. An absent active database + profile fails closed instead of silently using a stale environment + credential. +- Add an encryption-key configuration for protecting Provider API keys at rest + and document deployment, rotation, and migration behavior. +- Extend the existing provider client construction so cloud-managed + OpenAI-compatible profiles pass their encrypted API key and configured base + URL explicitly to the OpenAI SDK, while Host Agent direct transport continues + to use its existing environment-based credentials. + +## Capabilities + +### New Capabilities +- `llm-provider-management`: Durable, administrator-managed Cloud-wide LLM + Provider profiles, encrypted credential handling, and Console/API operations. + +### Modified Capabilities +- `cloud-planner-proxy`: Cloud planner decisions select their provider, + model, timeout, and credential from the active database profile once + database management is enabled, rather than requiring a Cloud API restart + and environment-variable change. + +## Impact + +- `packages/cloud-platform/cloud`: SQLAlchemy models, repository contract and + implementation, Alembic migration, encrypted-secret service, and planner + client construction from a resolved profile. +- `apps/cloud-api` and `cloud.sdk`: an authenticated management router and + application composition for the new service. +- `cloud-console`: an administrator-only Provider management view, client API, + and types. +- Cloud deployment configuration: the encryption key remains deployment-held, + while planner Provider/model/timeout/API-key environment variables are + removed. diff --git a/openspec/changes/database-llm-provider-management/specs/cloud-planner-proxy/spec.md b/openspec/changes/database-llm-provider-management/specs/cloud-planner-proxy/spec.md new file mode 100644 index 0000000..70ea6af --- /dev/null +++ b/openspec/changes/database-llm-provider-management/specs/cloud-planner-proxy/spec.md @@ -0,0 +1,28 @@ +## MODIFIED Requirements + +### Requirement: Endpoint resolves exactly one tool-call decision using cloud-held provider configuration +The Cloud Control Plane SHALL use its own configured LLM provider, model, and +credentials -- not any value supplied by the requesting Host Agent -- to +resolve a planner-decision request to exactly one tool name and one arguments +object, within the request's timeout. The endpoint SHALL resolve the active +database-managed Provider profile for every request and use its provider, +model, timeout, OpenAI-compatible base URL when applicable, and encrypted +cloud-held credential. It SHALL NOT read Cloud API planner Provider/model/ +timeout/API-key environment variables. + +#### Scenario: Provider returns a usable decision +- **WHEN** the configured provider responds to a planner-decision request + with a tool call +- **THEN** the Cloud Control Plane returns exactly one resolved tool name + and arguments object to the requesting Host Agent + +#### Scenario: Configured provider is unreachable or misconfigured +- **WHEN** the Cloud Control Plane's configured provider call fails (for + example, invalid credentials, provider error, or timeout) +- **THEN** the endpoint returns a structured failure response rather than a + fabricated decision, and does not crash the Cloud Control Plane process + +#### Scenario: Database profile is the only planner configuration source +- **WHEN** the Cloud API process has legacy planner environment variables +- **THEN** subsequent planner-decision requests use only the active database + profile and do not read a legacy Provider credential for that decision diff --git a/openspec/changes/database-llm-provider-management/specs/llm-provider-management/spec.md b/openspec/changes/database-llm-provider-management/specs/llm-provider-management/spec.md new file mode 100644 index 0000000..31f8a79 --- /dev/null +++ b/openspec/changes/database-llm-provider-management/specs/llm-provider-management/spec.md @@ -0,0 +1,100 @@ +## ADDED Requirements + +### Requirement: Cloud-wide LLM Provider profiles are durable and validated +The Cloud Control Plane SHALL persist administrator-managed LLM Provider +profiles with a unique name, provider type, model, timeout, enabled state, +revision, and timestamps. A profile's provider type SHALL be either +`anthropic` or `openai-compatible`; an OpenAI-compatible profile MAY specify +an absolute HTTP(S) base URL and SHALL use the official OpenAI endpoint when +it does not. + +#### Scenario: Administrator creates an OpenAI-compatible profile +- **WHEN** an authorized administrator submits a unique profile name, + `openai-compatible` provider type, model, valid timeout, API key, and an + optional valid base URL +- **THEN** the Cloud Control Plane persists an enabled profile with a new + revision and returns its non-secret metadata + +#### Scenario: Invalid profile configuration is rejected +- **WHEN** an administrator submits an unsupported provider type, blank model, + non-positive timeout, duplicate name, or invalid base URL +- **THEN** the Cloud Control Plane rejects the write without creating or + changing a profile + +### Requirement: Provider API keys are encrypted and never disclosed +The Cloud Control Plane SHALL encrypt Provider API keys before persistence +using a deployment-held encryption key, and SHALL not expose plaintext keys in +read responses, validation errors, audit records, or application logs. + +#### Scenario: Provider profile is listed after creation +- **WHEN** an authorized administrator lists Provider profiles after creating + one with an API key +- **THEN** every response reports only key-presence and rotation metadata and + does not contain the submitted API key or its ciphertext + +#### Scenario: Encryption configuration is unavailable +- **WHEN** a database-managed profile is created, rotated, activated, or + resolved without a valid deployment encryption key +- **THEN** the operation fails with a controlled configuration error that does + not reveal an API key + +### Requirement: Administrators can manage and activate Provider profiles +The Cloud Control Plane SHALL expose session-CSRF-protected and scope-guarded +operations to list, create, update, rotate a key, enable, disable, activate, +and delete inactive LLM Provider profiles. Mutating operations SHALL require +the `llm-providers:admin` scope and record a non-secret audit event. + +#### Scenario: Non-administrator attempts to modify a profile +- **WHEN** a principal without `llm-providers:admin` invokes a Provider + mutation endpoint +- **THEN** the Cloud Control Plane rejects the request before decrypting or + modifying a Provider credential + +#### Scenario: Administrator switches the active profile +- **WHEN** an authorized administrator activates an enabled profile +- **THEN** the Cloud Control Plane atomically selects that profile as the one + Cloud-wide active profile and records the activation without retaining an + API key in the audit event + +#### Scenario: Administrator attempts to retire the active profile +- **WHEN** an administrator attempts to disable or delete the active profile + before activating a replacement +- **THEN** the Cloud Control Plane rejects the operation and preserves the + active profile selection + +### Requirement: Activation dynamically selects one Provider for cloud transport +The Cloud Control Plane SHALL resolve the active database-managed profile for +each Cloud planner decision and SHALL apply an activation to subsequent +requests without requiring a Cloud API restart or any Host Agent +reconfiguration. + +#### Scenario: A Host requests a decision after a model switch +- **WHEN** an administrator activates a different enabled profile and a + cloud-transport Host Agent submits its next planner-decision request +- **THEN** the Cloud Control Plane calls that profile's provider, model, + timeout, base URL, and API key while the Host Agent continues using the same + Cloud Control Plane endpoint + +#### Scenario: Active OpenAI-compatible profile is used +- **WHEN** the active profile is OpenAI-compatible and includes a base URL +- **THEN** the Cloud Control Plane makes the existing OpenAI Chat Completions + tool-calling request to that base URL using the profile's decrypted API key + and returns the resulting single tool-call decision + +### Requirement: Cloud planner Provider configuration is database-only +The Cloud Control Plane SHALL resolve Cloud planner Provider, model, timeout, +base URL, and API key from the active database profile and SHALL NOT read +planner Provider/model/timeout or Provider API-key environment variables. +When no usable active profile exists, it SHALL fail a planner request with a +structured unavailable response. + +#### Scenario: Active profile is unavailable +- **WHEN** the active database Provider profile is missing, disabled, or + cannot be decrypted +- **THEN** the planner decision fails without invoking a legacy + environment-configured Provider + +#### Scenario: Legacy environment variables are present +- **WHEN** the Cloud API process has legacy planner Provider or API-key + environment variables but an active database profile exists +- **THEN** the planner decision uses only the active database profile diff --git a/openspec/changes/database-llm-provider-management/tasks.md b/openspec/changes/database-llm-provider-management/tasks.md new file mode 100644 index 0000000..899409b --- /dev/null +++ b/openspec/changes/database-llm-provider-management/tasks.md @@ -0,0 +1,28 @@ +## 1. Secure persistence + +- [x] 1.1 Add the encryption dependency and a testable Cloud Provider secret-box configuration backed by `CLOUD_LLM_PROVIDER_ENCRYPTION_KEY`. +- [x] 1.2 Add SQLAlchemy profile/settings rows and an Alembic migration after the current Cloud schema revision. +- [x] 1.3 Extend the Cloud repository contract and SQL implementation with profile CRUD, atomic activation, revision checks, and active-profile resolution. + +## 2. Administrator management API + +- [x] 2.1 Add non-secret Pydantic SDK request/response models and a dedicated `llm-providers:admin` scope. +- [x] 2.2 Add the authenticated, CSRF-protected Provider profile router with audit records and compose it into the Cloud API. +- [x] 2.3 Add focused repository and API tests for validation, encryption redaction, authorization, CSRF, revisions, activation, and retirement invariants. + +## 3. Cloud planner resolution + +- [x] 3.1 Allow runtime Anthropic and OpenAI clients to receive explicit API keys and an OpenAI-compatible base URL without changing direct transport behavior. +- [x] 3.2 Resolve the active database profile per planner request, remove Cloud API planner environment configuration, and use the resolved metadata for token accounting. +- [x] 3.3 Add planner route and client tests for database activation, fail-closed resolution, and OpenAI-compatible client construction. + +## 4. Cloud Console + +- [x] 4.1 Add Provider profile types and CSRF-aware client methods to the Cloud Console API layer. +- [x] 4.2 Add an administrator-only Provider management view with create, edit/key rotation, enable/disable, activate, and inactive-profile deletion workflows. +- [x] 4.3 Add Console tests for Provider API methods and permission-gated navigation/view behavior. + +## 5. Documentation and validation + +- [x] 5.1 Document encryption-key provisioning, required active-profile cutover, OpenAI-compatible configuration, removed planner environment variables, and rollback in Cloud deployment documentation. +- [x] 5.2 Run relevant backend tests, Console tests/build, format/lint, compile checks, and strict OpenSpec validation; resolve failures. diff --git a/packages/cloud-platform/cloud/auth.py b/packages/cloud-platform/cloud/auth.py index ead18e9..1b58c85 100644 --- a/packages/cloud-platform/cloud/auth.py +++ b/packages/cloud-platform/cloud/auth.py @@ -18,6 +18,7 @@ PLUGINS_ADMIN_SCOPE = "plugins:admin" USERS_ADMIN_SCOPE = "users:admin" GOVERNANCE_READ_SCOPE = "governance:read" GOVERNANCE_ADMIN_SCOPE = "governance:admin" +LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin" @dataclass(frozen=True) diff --git a/packages/cloud-platform/cloud/db_models.py b/packages/cloud-platform/cloud/db_models.py index 1f84f27..bbae94d 100644 --- a/packages/cloud-platform/cloud/db_models.py +++ b/packages/cloud-platform/cloud/db_models.py @@ -285,3 +285,45 @@ class TokenUsageEventRow(Base): output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) total_tokens: Mapped[int] = mapped_column(Integer, nullable=False) occurred_at: Mapped[str] = mapped_column(String, nullable=False) + + +class LlmProviderProfileRow(Base): + __tablename__ = "cloud_llm_provider_profiles" + __table_args__ = ( + UniqueConstraint( + "name_normalized", + name="uq_cloud_llm_provider_profiles_name_normalized", + ), + Index("ix_cloud_llm_provider_profiles_enabled", "enabled"), + ) + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False) + name_normalized: Mapped[str] = mapped_column(String, nullable=False) + provider_type: Mapped[str] = mapped_column(String, nullable=False) + model: Mapped[str] = mapped_column(String, nullable=False) + base_url: Mapped[str | None] = mapped_column(String, nullable=True) + timeout_seconds: Mapped[float] = mapped_column(nullable=False) + api_key_ciphertext: Mapped[str] = mapped_column(Text, nullable=False) + key_last_rotated_at: Mapped[str] = mapped_column(String, nullable=False) + enabled: Mapped[int] = mapped_column( + Integer, nullable=False, default=1, server_default=text("1") + ) + revision: Mapped[int] = mapped_column( + Integer, nullable=False, default=1, server_default=text("1") + ) + created_at: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[str] = mapped_column(String, nullable=False) + + +class LlmProviderSettingsRow(Base): + __tablename__ = "cloud_llm_provider_settings" + + id: Mapped[str] = mapped_column(String, primary_key=True) + active_profile_id: Mapped[str | None] = mapped_column( + ForeignKey("cloud_llm_provider_profiles.id"), nullable=True + ) + revision: Mapped[int] = mapped_column( + Integer, nullable=False, default=1, server_default=text("1") + ) + updated_at: Mapped[str] = mapped_column(String, nullable=False) diff --git a/packages/cloud-platform/cloud/internal_api/api.py b/packages/cloud-platform/cloud/internal_api/api.py index f9bf374..ee4f5a4 100644 --- a/packages/cloud-platform/cloud/internal_api/api.py +++ b/packages/cloud-platform/cloud/internal_api/api.py @@ -39,7 +39,9 @@ from cloud.internal_api.models import ( TerminalResultRequest, TerminalResultResponse, ) -from cloud.planner_config import build_cloud_planner_client, load_cloud_planner_config +from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService +from cloud.planner_config import build_cloud_planner_client +from cloud.provider_secrets import ProviderSecretConfigurationError from cloud.repository import ( DeviceEnrollmentConflictError, HostEnrollmentConflictError, @@ -65,6 +67,7 @@ def create_internal_router( lease_duration_seconds: float = 60.0, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, planner_client_factory: Callable[[], ToolCallingClient] | None = None, + planner_provider_service: LlmProviderService | None = None, scheduler: TaskScheduler | None = None, planner_token_reservation_ceiling: int = 4096, planner_token_reservation_ttl_seconds: float = 300.0, @@ -78,8 +81,6 @@ def create_internal_router( if planner_token_reservation_ttl_seconds <= 0: raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero") router = APIRouter(prefix=version_prefix, tags=["host-agent"]) - build_planner_client = planner_client_factory or _default_planner_client_factory - def authorize_host(request: Request, host_id: str) -> None: principal = auth_provider.authenticate(request) if principal is None: @@ -395,6 +396,28 @@ def create_internal_router( for tool in payload.tools ] + try: + if planner_client_factory is not None: + client = planner_client_factory() + resolved_provider = "test" + resolved_model = "test" + elif planner_provider_service is not None: + resolved = planner_provider_service.resolve_active_profile() + client = build_cloud_planner_client(resolved) + resolved_provider = resolved.profile.provider_type + resolved_model = resolved.profile.model + else: + raise LlmProviderResolutionError("no database Provider resolver configured") + except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc: + logger.info( + "planner-decision request failed", + extra={"host_id": host_id, "error_class": type(exc).__name__}, + ) + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content=PlannerDecisionError(detail=str(exc)).model_dump(), + ) + now = utc_now() reservation = None try: @@ -415,7 +438,6 @@ def create_internal_router( ) started_at = monotonic() - client = build_planner_client() try: decision = client.decide( system_prompt=payload.system_prompt, @@ -439,12 +461,11 @@ def create_internal_router( ) usage = decision.usage if reservation is not None and usage is not None and usage.total_tokens is not None: - planner_config = load_cloud_planner_config() pool.store.settle_host_token_reservation( reservation_id=reservation.id, event_id=uuid4().hex, - provider=planner_config.provider, - model=planner_config.resolved_model(), + provider=resolved_provider, + model=resolved_model, input_tokens=usage.input_tokens, output_tokens=usage.output_tokens, total_tokens=usage.total_tokens, @@ -469,10 +490,6 @@ def create_internal_router( return router -def _default_planner_client_factory() -> ToolCallingClient: - return build_cloud_planner_client(load_cloud_planner_config()) - - def _validate_assignment_identity( *, host_id: str, diff --git a/packages/cloud-platform/cloud/llm_providers.py b/packages/cloud-platform/cloud/llm_providers.py new file mode 100644 index 0000000..bf321c0 --- /dev/null +++ b/packages/cloud-platform/cloud/llm_providers.py @@ -0,0 +1,274 @@ +"""Domain and service layer for Cloud-managed LLM Provider profiles.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal +from urllib.parse import urlparse +from uuid import uuid4 + +from cloud.provider_secrets import ProviderSecretBox, load_provider_secret_box + + +ProviderType = Literal["anthropic", "openai-compatible"] +SUPPORTED_PROVIDER_TYPES = frozenset({"anthropic", "openai-compatible"}) + + +class LlmProviderValidationError(ValueError): + pass + + +class LlmProviderConflictError(RuntimeError): + pass + + +class LlmProviderActiveConflictError(LlmProviderConflictError): + pass + + +class LlmProviderResolutionError(RuntimeError): + pass + + +@dataclass(frozen=True) +class LlmProviderProfile: + id: str + name: str + name_normalized: str + provider_type: ProviderType + model: str + base_url: str | None + timeout_seconds: float + api_key_ciphertext: str = field(repr=False) + key_last_rotated_at: datetime + enabled: bool + revision: int + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True) +class LlmProviderSettings: + active_profile_id: str | None + revision: int = 0 + updated_at: datetime | None = None + + +@dataclass(frozen=True) +class ResolvedLlmProviderProfile: + profile: LlmProviderProfile + api_key: str = field(repr=False) + + +@dataclass(frozen=True) +class LlmProviderProfileInput: + name: str + name_normalized: str + provider_type: ProviderType + model: str + base_url: str | None + timeout_seconds: float + + +def validate_profile_input( + *, + name: str, + provider_type: str, + model: str, + base_url: str | None, + timeout_seconds: float, +) -> LlmProviderProfileInput: + display_name = " ".join(name.split()) + if not display_name or len(display_name) > 120: + raise LlmProviderValidationError( + "Provider name must contain 1 to 120 characters" + ) + normalized_name = display_name.casefold() + if provider_type not in SUPPORTED_PROVIDER_TYPES: + raise LlmProviderValidationError("Provider type is not supported") + normalized_model = model.strip() + if not normalized_model or len(normalized_model) > 200: + raise LlmProviderValidationError("Model must contain 1 to 200 characters") + if timeout_seconds <= 0 or timeout_seconds > 120: + raise LlmProviderValidationError( + "Timeout must be greater than zero and at most 120" + ) + normalized_base_url = _normalize_base_url(base_url) + if provider_type == "anthropic" and normalized_base_url is not None: + raise LlmProviderValidationError( + "Anthropic profiles do not support a custom base URL" + ) + return LlmProviderProfileInput( + name=display_name, + name_normalized=normalized_name, + provider_type=provider_type, # type: ignore[arg-type] + model=normalized_model, + base_url=normalized_base_url, + timeout_seconds=timeout_seconds, + ) + + +class LlmProviderService: + """Applies secret and active-profile invariants above the repository port.""" + + def __init__( + self, + repository, + *, + secret_box_factory: Callable[[], ProviderSecretBox] = load_provider_secret_box, + ) -> None: + self._repository = repository + self._secret_box_factory = secret_box_factory + + def list_profiles(self) -> tuple[LlmProviderSettings, list[LlmProviderProfile]]: + return ( + self._repository.get_llm_provider_settings(), + self._repository.list_llm_provider_profiles(), + ) + + def create_profile( + self, + *, + name: str, + provider_type: str, + model: str, + base_url: str | None, + timeout_seconds: float, + api_key: str, + now: datetime, + ) -> LlmProviderProfile: + if not api_key: + raise LlmProviderValidationError("Provider API key must not be empty") + values = validate_profile_input( + name=name, + provider_type=provider_type, + model=model, + base_url=base_url, + timeout_seconds=timeout_seconds, + ) + return self._repository.create_llm_provider_profile( + LlmProviderProfile( + id=uuid4().hex, + name=values.name, + name_normalized=values.name_normalized, + provider_type=values.provider_type, + model=values.model, + base_url=values.base_url, + timeout_seconds=values.timeout_seconds, + api_key_ciphertext=self._secret_box_factory().encrypt(api_key), + key_last_rotated_at=now, + enabled=True, + revision=1, + created_at=now, + updated_at=now, + ) + ) + + def update_profile( + self, + profile_id: str, + *, + name: str | None, + provider_type: str | None, + model: str | None, + base_url: str | None, + base_url_supplied: bool, + timeout_seconds: float | None, + enabled: bool | None, + api_key: str | None, + expected_revision: int | None, + now: datetime, + ) -> LlmProviderProfile: + current = self._repository.get_llm_provider_profile(profile_id) + if current is None: + raise KeyError(profile_id) + values = validate_profile_input( + name=current.name if name is None else name, + provider_type=current.provider_type + if provider_type is None + else provider_type, + model=current.model if model is None else model, + base_url=current.base_url if not base_url_supplied else base_url, + timeout_seconds=( + current.timeout_seconds if timeout_seconds is None else timeout_seconds + ), + ) + ciphertext = current.api_key_ciphertext + key_last_rotated_at = current.key_last_rotated_at + if api_key is not None: + if not api_key: + raise LlmProviderValidationError("Provider API key must not be empty") + ciphertext = self._secret_box_factory().encrypt(api_key) + key_last_rotated_at = now + return self._repository.update_llm_provider_profile( + profile_id, + name=values.name, + name_normalized=values.name_normalized, + provider_type=values.provider_type, + model=values.model, + base_url=values.base_url, + timeout_seconds=values.timeout_seconds, + api_key_ciphertext=ciphertext, + key_last_rotated_at=key_last_rotated_at, + enabled=current.enabled if enabled is None else enabled, + expected_revision=expected_revision, + updated_at=now, + ) + + def activate_profile( + self, + profile_id: str, + *, + expected_settings_revision: int | None, + now: datetime, + ) -> LlmProviderSettings: + profile = self._repository.get_llm_provider_profile(profile_id) + if profile is None: + raise KeyError(profile_id) + # Fail before persisting the activation when the credential cannot be used. + self._secret_box_factory().decrypt(profile.api_key_ciphertext) + return self._repository.activate_llm_provider_profile( + profile_id, + expected_settings_revision=expected_settings_revision, + updated_at=now, + ) + + def delete_profile( + self, + profile_id: str, + *, + expected_revision: int | None, + ) -> None: + self._repository.delete_llm_provider_profile( + profile_id, + expected_revision=expected_revision, + ) + + def resolve_active_profile(self) -> ResolvedLlmProviderProfile: + settings = self._repository.get_llm_provider_settings() + if settings.active_profile_id is None: + raise LlmProviderResolutionError("no active database Provider profile") + profile = self._repository.get_llm_provider_profile(settings.active_profile_id) + if profile is None or not profile.enabled: + raise LlmProviderResolutionError( + "active database Provider profile is unavailable" + ) + try: + api_key = self._secret_box_factory().decrypt(profile.api_key_ciphertext) + except ValueError as exc: + raise LlmProviderResolutionError(str(exc)) from exc + return ResolvedLlmProviderProfile(profile=profile, api_key=api_key) + + +def _normalize_base_url(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().rstrip("/") + if not normalized: + return None + parsed = urlparse(normalized) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise LlmProviderValidationError("Base URL must be an absolute HTTP(S) URL") + return normalized diff --git a/packages/cloud-platform/cloud/migrations/versions/0007_llm_provider_management.py b/packages/cloud-platform/cloud/migrations/versions/0007_llm_provider_management.py new file mode 100644 index 0000000..cf7f557 --- /dev/null +++ b/packages/cloud-platform/cloud/migrations/versions/0007_llm_provider_management.py @@ -0,0 +1,65 @@ +"""Add durable, encrypted Cloud LLM Provider profiles.""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0007_llm_provider_management" +down_revision = "0006_host_planner_transport" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "cloud_llm_provider_profiles", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("name_normalized", sa.String(), nullable=False), + sa.Column("provider_type", sa.String(), nullable=False), + sa.Column("model", sa.String(), nullable=False), + sa.Column("base_url", sa.String(), nullable=True), + sa.Column("timeout_seconds", sa.Float(), nullable=False), + sa.Column("api_key_ciphertext", sa.Text(), nullable=False), + sa.Column("key_last_rotated_at", sa.String(), nullable=False), + sa.Column("enabled", sa.Integer(), nullable=False, server_default="1"), + sa.Column("revision", sa.Integer(), nullable=False, server_default="1"), + sa.Column("created_at", sa.String(), nullable=False), + sa.Column("updated_at", sa.String(), nullable=False), + sa.UniqueConstraint( + "name_normalized", name="uq_cloud_llm_provider_profiles_name_normalized" + ), + ) + op.create_index( + "ix_cloud_llm_provider_profiles_enabled", + "cloud_llm_provider_profiles", + ["enabled"], + ) + op.create_table( + "cloud_llm_provider_settings", + sa.Column("id", sa.String(), primary_key=True), + sa.Column( + "active_profile_id", + sa.String(), + sa.ForeignKey("cloud_llm_provider_profiles.id"), + nullable=True, + ), + sa.Column("revision", sa.Integer(), nullable=False, server_default="1"), + sa.Column("updated_at", sa.String(), nullable=False), + ) + op.execute( + "insert into cloud_llm_provider_settings " + "(id, active_profile_id, revision, updated_at) values " + "('global', null, 0, '1970-01-01T00:00:00+00:00')" + ) + + +def downgrade() -> None: + op.drop_table("cloud_llm_provider_settings") + op.drop_index( + "ix_cloud_llm_provider_profiles_enabled", + table_name="cloud_llm_provider_profiles", + ) + op.drop_table("cloud_llm_provider_profiles") diff --git a/packages/cloud-platform/cloud/planner_config.py b/packages/cloud-platform/cloud/planner_config.py index 52c87f0..a5740b0 100644 --- a/packages/cloud-platform/cloud/planner_config.py +++ b/packages/cloud-platform/cloud/planner_config.py @@ -1,86 +1,24 @@ -"""Cloud Control Plane's own AI Planner provider configuration. - -Analogous to ``runtime/planner_config.py``, but loaded from the Cloud API's -own process environment rather than a Host Agent's. There is no ``enabled`` -flag here: the planner-decision endpoint always exists once the Cloud API is -running, and simply fails a given request if the configured provider call -fails (see ``cloud.internal_api.api``). Provider API keys -(``ANTHROPIC_API_KEY``/``OPENAI_API_KEY``) are not modeled as fields here -- -like the Host Agent's direct-transport path, they are read implicitly by the -``anthropic``/``openai`` SDK clients from the process environment. -""" +"""Construct Cloud planner clients from resolved database Provider profiles.""" from __future__ import annotations -import os -from collections.abc import Mapping -from dataclasses import dataclass - +from cloud.llm_providers import ResolvedLlmProviderProfile from runtime.tool_calling_client import ( AnthropicToolCallingClient, OpenAIToolCallingClient, ToolCallingClient, ) -DEFAULT_PROVIDER = "anthropic" -DEFAULT_MODEL_BY_PROVIDER = { - "anthropic": "claude-sonnet-5", - "openai": "gpt-5.6", -} -DEFAULT_TIMEOUT_SECONDS = 30.0 -PROVIDER_ENV = "AI_PLANNER_PROVIDER" -MODEL_ENV = "AI_PLANNER_MODEL" -TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS" - -SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER) - - -@dataclass(frozen=True) -class CloudPlannerConfig: - provider: str = DEFAULT_PROVIDER - model: str = "" - timeout: float = DEFAULT_TIMEOUT_SECONDS - - def resolved_model(self) -> str: - return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider] - - -def load_cloud_planner_config( - env: Mapping[str, str] | None = None, -) -> CloudPlannerConfig: - values = env or os.environ - return CloudPlannerConfig( - provider=_parse_provider(values.get(PROVIDER_ENV)), - model=values.get(MODEL_ENV) or "", - timeout=_parse_timeout(values.get(TIMEOUT_ENV)), - ) - - -def build_cloud_planner_client(config: CloudPlannerConfig) -> ToolCallingClient: - """Construct the same provider client the Host Agent's direct transport uses. - - Reuses ``runtime.tool_calling_client``'s Anthropic/OpenAI wire-format - translation (see design decision D1) instead of a second implementation. - """ - model = config.resolved_model() - if config.provider == "openai": - return OpenAIToolCallingClient(model=model) - return AnthropicToolCallingClient(model=model) - - -def _parse_provider(value: str | None) -> str: - if value is None: - return DEFAULT_PROVIDER - provider = value.strip().lower() - return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER - - -def _parse_timeout(value: str | None) -> float: - if value is None: - return DEFAULT_TIMEOUT_SECONDS - try: - timeout = float(value) - except ValueError: - return DEFAULT_TIMEOUT_SECONDS - return timeout if timeout > 0 else DEFAULT_TIMEOUT_SECONDS +def build_cloud_planner_client( + resolved: ResolvedLlmProviderProfile, +) -> ToolCallingClient: + """Build a provider client without reading Cloud API environment variables.""" + profile = resolved.profile + if profile.provider_type == "openai-compatible": + return OpenAIToolCallingClient( + model=profile.model, + api_key=resolved.api_key, + base_url=profile.base_url, + ) + return AnthropicToolCallingClient(model=profile.model, api_key=resolved.api_key) diff --git a/packages/cloud-platform/cloud/provider_secrets.py b/packages/cloud-platform/cloud/provider_secrets.py new file mode 100644 index 0000000..6694ecc --- /dev/null +++ b/packages/cloud-platform/cloud/provider_secrets.py @@ -0,0 +1,56 @@ +"""Encryption for Cloud-managed LLM Provider credentials.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping + +from cryptography.fernet import Fernet, InvalidToken + + +ENCRYPTION_KEY_ENV = "CLOUD_LLM_PROVIDER_ENCRYPTION_KEY" + + +class ProviderSecretConfigurationError(ValueError): + """Raised when deployment configuration cannot protect a Provider secret.""" + + +class ProviderSecretDecryptionError(ValueError): + """Raised when a persisted Provider secret cannot be decrypted.""" + + +class ProviderSecretBox: + """Small, testable boundary around Fernet encryption.""" + + def __init__(self, key: str) -> None: + try: + self._fernet = Fernet(key.encode("ascii")) + except (UnicodeEncodeError, ValueError) as exc: + raise ProviderSecretConfigurationError( + f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key" + ) from exc + + def encrypt(self, secret: str) -> str: + if not secret: + raise ValueError("Provider API key must not be empty") + return self._fernet.encrypt(secret.encode("utf-8")).decode("ascii") + + def decrypt(self, ciphertext: str) -> str: + try: + return self._fernet.decrypt(ciphertext.encode("ascii")).decode("utf-8") + except (InvalidToken, UnicodeDecodeError, UnicodeEncodeError) as exc: + raise ProviderSecretDecryptionError( + "stored Provider API key cannot be decrypted" + ) from exc + + +def load_provider_secret_box( + env: Mapping[str, str] | None = None, +) -> ProviderSecretBox: + values = os.environ if env is None else env + key = values.get(ENCRYPTION_KEY_ENV, "") + if not key: + raise ProviderSecretConfigurationError( + f"{ENCRYPTION_KEY_ENV} must be configured for database Provider management" + ) + return ProviderSecretBox(key) diff --git a/packages/cloud-platform/cloud/repository.py b/packages/cloud-platform/cloud/repository.py index bcaf193..515a40f 100644 --- a/packages/cloud-platform/cloud/repository.py +++ b/packages/cloud-platform/cloud/repository.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: TokenUsageEvent, UserSubmissionPolicy, ) + from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"] @@ -341,6 +342,48 @@ class CloudRepository(Protocol): self, *, host_id: str, limit: int, offset: int, ) -> list[TokenUsageEvent]: ... + def get_llm_provider_settings(self) -> LlmProviderSettings: ... + + def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ... + + def get_llm_provider_profile(self, profile_id: str) -> LlmProviderProfile | None: ... + + def create_llm_provider_profile( + self, profile: LlmProviderProfile + ) -> LlmProviderProfile: ... + + def update_llm_provider_profile( + self, + profile_id: str, + *, + name: str, + name_normalized: str, + provider_type: ProviderType, + model: str, + base_url: str | None, + timeout_seconds: float, + api_key_ciphertext: str, + key_last_rotated_at: datetime, + enabled: bool, + expected_revision: int | None, + updated_at: datetime, + ) -> LlmProviderProfile: ... + + def activate_llm_provider_profile( + self, + profile_id: str, + *, + expected_settings_revision: int | None, + updated_at: datetime, + ) -> LlmProviderSettings: ... + + def delete_llm_provider_profile( + self, + profile_id: str, + *, + expected_revision: int | None, + ) -> None: ... + def list_reserved_device_ids(self, *, now: datetime) -> set[str]: ... def assign_task( diff --git a/packages/cloud-platform/cloud/schema.py b/packages/cloud-platform/cloud/schema.py index 2c6c3e2..f886d57 100644 --- a/packages/cloud-platform/cloud/schema.py +++ b/packages/cloud-platform/cloud/schema.py @@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext from cloud.database import create_database_engine, normalize_database_url -HEAD_REVISION = "0006_host_planner_transport" +HEAD_REVISION = "0007_llm_provider_management" class SchemaVersionError(RuntimeError): diff --git a/packages/cloud-platform/cloud/sdk/llm_provider_api.py b/packages/cloud-platform/cloud/sdk/llm_provider_api.py new file mode 100644 index 0000000..c211806 --- /dev/null +++ b/packages/cloud-platform/cloud/sdk/llm_provider_api.py @@ -0,0 +1,268 @@ +"""Administrator API for Cloud-managed LLM Provider profiles.""" + +from __future__ import annotations + +from collections.abc import Callable +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException, Query, Request, status + +from cloud.auth import AuthProvider, LLM_PROVIDERS_ADMIN_SCOPE, Principal +from cloud.llm_providers import ( + LlmProviderActiveConflictError, + LlmProviderConflictError, + LlmProviderService, + LlmProviderValidationError, +) +from cloud.observability import current_correlation_id +from cloud.provider_secrets import ( + ProviderSecretConfigurationError, + ProviderSecretDecryptionError, +) +from cloud.sdk.models import ( + LlmProviderProfileActivateRequest, + LlmProviderProfileCreateRequest, + LlmProviderProfileListResponse, + LlmProviderProfileResponse, + LlmProviderProfileUpdateRequest, + LlmProviderSettingsResponse, +) +from cloud.user_auth import AuthAuditEvent +from core.models import utc_now + + +def create_llm_provider_router( + *, + service: LlmProviderService, + repository, + auth_provider: AuthProvider, + csrf_validator: Callable[[Request, Principal], bool], + version_prefix: str = "/v1", +) -> APIRouter: + router = APIRouter(prefix=version_prefix, tags=["llm-provider-management"]) + + def authorize(request: Request) -> Principal: + principal = auth_provider.authenticate(request) + if principal is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="unauthorized", + headers={"WWW-Authenticate": "Bearer"}, + ) + if principal.must_change_password or not principal.has_scope( + LLM_PROVIDERS_ADMIN_SCOPE + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"missing required scope: {LLM_PROVIDERS_ADMIN_SCOPE}", + ) + return principal + + def require_csrf(request: Request, principal: Principal) -> None: + if not csrf_validator(request, principal): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="CSRF validation failed", + ) + + @router.get("/planner/providers", response_model=LlmProviderProfileListResponse) + def list_profiles(request: Request) -> LlmProviderProfileListResponse: + authorize(request) + settings, profiles = service.list_profiles() + return LlmProviderProfileListResponse( + settings=_settings_response(settings), + items=[ + _profile_response(profile, settings.active_profile_id) + for profile in profiles + ], + ) + + @router.post( + "/planner/providers", + response_model=LlmProviderProfileResponse, + status_code=status.HTTP_201_CREATED, + ) + def create_profile( + payload: LlmProviderProfileCreateRequest, + request: Request, + ) -> LlmProviderProfileResponse: + principal = authorize(request) + require_csrf(request, principal) + try: + profile = service.create_profile( + name=payload.name, + provider_type=payload.provider_type, + model=payload.model, + base_url=payload.base_url, + timeout_seconds=payload.timeout_seconds, + api_key=payload.api_key.get_secret_value(), + now=utc_now(), + ) + except LlmProviderValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except LlmProviderConflictError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except ProviderSecretConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) + ) from exc + _audit(repository, principal, profile.id, "llm_provider_create") + return _profile_response(profile, None) + + @router.patch( + "/planner/providers/{profile_id}", response_model=LlmProviderProfileResponse + ) + def update_profile( + profile_id: str, + payload: LlmProviderProfileUpdateRequest, + request: Request, + ) -> LlmProviderProfileResponse: + principal = authorize(request) + require_csrf(request, principal) + try: + profile = service.update_profile( + profile_id, + name=payload.name, + provider_type=payload.provider_type, + model=payload.model, + base_url=payload.base_url, + base_url_supplied="base_url" in payload.model_fields_set, + timeout_seconds=payload.timeout_seconds, + enabled=payload.enabled, + api_key=( + payload.api_key.get_secret_value() + if payload.api_key is not None + else None + ), + expected_revision=payload.expected_revision, + now=utc_now(), + ) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Provider profile not found", + ) from exc + except LlmProviderValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc) + ) from exc + except LlmProviderActiveConflictError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except LlmProviderConflictError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except ProviderSecretConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) + ) from exc + settings = repository.get_llm_provider_settings() + _audit(repository, principal, profile.id, "llm_provider_update") + return _profile_response(profile, settings.active_profile_id) + + @router.post( + "/planner/providers/{profile_id}/activate", + response_model=LlmProviderSettingsResponse, + ) + def activate_profile( + profile_id: str, + payload: LlmProviderProfileActivateRequest, + request: Request, + ) -> LlmProviderSettingsResponse: + principal = authorize(request) + require_csrf(request, principal) + try: + settings = service.activate_profile( + profile_id, + expected_settings_revision=payload.expected_settings_revision, + now=utc_now(), + ) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Provider profile not found", + ) from exc + except (LlmProviderConflictError, ProviderSecretDecryptionError) as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + except ProviderSecretConfigurationError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) + ) from exc + _audit(repository, principal, profile_id, "llm_provider_activate") + return _settings_response(settings) + + @router.delete( + "/planner/providers/{profile_id}", status_code=status.HTTP_204_NO_CONTENT + ) + def delete_profile( + profile_id: str, + request: Request, + expected_revision: int | None = Query(default=None, ge=1), + ) -> None: + principal = authorize(request) + require_csrf(request, principal) + try: + service.delete_profile(profile_id, expected_revision=expected_revision) + except KeyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Provider profile not found", + ) from exc + except LlmProviderConflictError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=str(exc) + ) from exc + _audit(repository, principal, profile_id, "llm_provider_delete") + + return router + + +def _profile_response( + profile, active_profile_id: str | None +) -> LlmProviderProfileResponse: + return LlmProviderProfileResponse( + id=profile.id, + name=profile.name, + provider_type=profile.provider_type, + model=profile.model, + base_url=profile.base_url, + timeout_seconds=profile.timeout_seconds, + enabled=profile.enabled, + revision=profile.revision, + has_api_key=bool(profile.api_key_ciphertext), + key_last_rotated_at=profile.key_last_rotated_at, + created_at=profile.created_at, + updated_at=profile.updated_at, + active=profile.id == active_profile_id, + ) + + +def _settings_response(settings) -> LlmProviderSettingsResponse: + return LlmProviderSettingsResponse( + active_profile_id=settings.active_profile_id, + revision=settings.revision, + updated_at=settings.updated_at, + ) + + +def _audit(repository, principal: Principal, profile_id: str, action: str) -> None: + repository.record_auth_audit( + AuthAuditEvent( + id=uuid4().hex, + occurred_at=utc_now(), + actor_principal_id=principal.id, + target_user_id=None, + action=action, + outcome="success", + correlation_id=current_correlation_id(), + metadata={"provider_profile_id": profile_id}, + ) + ) diff --git a/packages/cloud-platform/cloud/sdk/models.py b/packages/cloud-platform/cloud/sdk/models.py index e7e16bd..a4aee98 100644 --- a/packages/cloud-platform/cloud/sdk/models.py +++ b/packages/cloud-platform/cloud/sdk/models.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, SecretStr class TaskConstraintsModel(BaseModel): @@ -212,3 +212,54 @@ class TokenUsageEventResponse(BaseModel): output_tokens: int | None = None total_tokens: int occurred_at: datetime + + +class LlmProviderProfileCreateRequest(BaseModel): + name: str = Field(min_length=1, max_length=120) + provider_type: Literal["anthropic", "openai-compatible"] + model: str = Field(min_length=1, max_length=200) + base_url: str | None = Field(default=None, max_length=500) + timeout_seconds: float = Field(gt=0, le=120) + api_key: SecretStr + + +class LlmProviderProfileUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + provider_type: Literal["anthropic", "openai-compatible"] | None = None + model: str | None = Field(default=None, min_length=1, max_length=200) + base_url: str | None = Field(default=None, max_length=500) + timeout_seconds: float | None = Field(default=None, gt=0, le=120) + enabled: bool | None = None + api_key: SecretStr | None = None + expected_revision: int | None = Field(default=None, ge=1) + + +class LlmProviderProfileActivateRequest(BaseModel): + expected_settings_revision: int | None = Field(default=None, ge=0) + + +class LlmProviderProfileResponse(BaseModel): + id: str + name: str + provider_type: Literal["anthropic", "openai-compatible"] + model: str + base_url: str | None = None + timeout_seconds: float + enabled: bool + revision: int + has_api_key: bool + key_last_rotated_at: datetime + created_at: datetime + updated_at: datetime + active: bool + + +class LlmProviderSettingsResponse(BaseModel): + active_profile_id: str | None = None + revision: int + updated_at: datetime | None = None + + +class LlmProviderProfileListResponse(BaseModel): + settings: LlmProviderSettingsResponse + items: list[LlmProviderProfileResponse] diff --git a/packages/cloud-platform/cloud/sql_repository.py b/packages/cloud-platform/cloud/sql_repository.py index 875bb0f..6f11631 100644 --- a/packages/cloud-platform/cloud/sql_repository.py +++ b/packages/cloud-platform/cloud/sql_repository.py @@ -20,6 +20,8 @@ from cloud.db_models import ( TaskAttemptRow, AuthAuditRow, HostGovernancePolicyRow, + LlmProviderProfileRow, + LlmProviderSettingsRow, LoginThrottleRow, UserRow, UserSessionRow, @@ -42,6 +44,19 @@ class SQLAlchemyCloudRepository: self._sessions = sessionmaker(bind=engine, expire_on_commit=False) if create_schema: Base.metadata.create_all(engine) + self._ensure_llm_provider_settings() + + def _ensure_llm_provider_settings(self) -> None: + with self._sessions.begin() as session: + if session.get(LlmProviderSettingsRow, "global") is None: + session.add( + LlmProviderSettingsRow( + id="global", + active_profile_id=None, + revision=0, + updated_at=_iso(utc_now()), + ) + ) def enroll_host( self, @@ -503,7 +518,9 @@ class SQLAlchemyCloudRepository: created_at=_iso(user.created_at), updated_at=_iso(user.updated_at), last_login_at=( - _iso(user.last_login_at) if user.last_login_at is not None else None + _iso(user.last_login_at) + if user.last_login_at is not None + else None ), ) session.add(row) @@ -576,7 +593,9 @@ class SQLAlchemyCloudRepository: raise LastAdministratorConflictError( "cannot remove the last enabled administrator" ) - security_changed = next_role != row.role or next_enabled != bool(row.enabled) + security_changed = next_role != row.role or next_enabled != bool( + row.enabled + ) if display_name is not None: row.display_name = display_name row.role = next_role @@ -764,7 +783,9 @@ class SQLAlchemyCloudRepository: session.flush() return _login_throttle_from_row(row) - def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None: + def clear_login_throttle( + self, username_normalized: str, client_bucket: str + ) -> None: with self._sessions.begin() as session: row = session.get(LoginThrottleRow, (username_normalized, client_bucket)) if row is not None: @@ -843,7 +864,9 @@ class SQLAlchemyCloudRepository: ) if row is None: if expected_revision not in {None, 0}: - raise GovernancePolicyConflictError("submission policy revision changed") + raise GovernancePolicyConflictError( + "submission policy revision changed" + ) row = UserSubmissionPolicyRow( user_id=user_id, revision=1, @@ -856,11 +879,10 @@ class SQLAlchemyCloudRepository: ) session.add(row) else: - if ( - expected_revision is not None - and expected_revision != row.revision - ): - raise GovernancePolicyConflictError("submission policy revision changed") + if expected_revision is not None and expected_revision != row.revision: + raise GovernancePolicyConflictError( + "submission policy revision changed" + ) row.revision += 1 row.submission_enabled = 1 if submission_enabled else 0 row.allowed_host_ids_json = _dump_optional_list(allowed_host_ids) @@ -909,10 +931,7 @@ class SQLAlchemyCloudRepository: ) session.add(row) else: - if ( - expected_revision is not None - and expected_revision != row.revision - ): + if expected_revision is not None and expected_revision != row.revision: raise GovernancePolicyConflictError("Host policy revision changed") row.revision += 1 row.self_submission_enabled = 1 if self_submission_enabled else 0 @@ -922,6 +941,220 @@ class SQLAlchemyCloudRepository: session.flush() return _host_governance_policy_from_row(row) + # --------------------------------------------------------- LLM Providers + + def get_llm_provider_settings(self) -> Any: + with self._sessions() as session: + row = session.get(LlmProviderSettingsRow, "global") + return _llm_provider_settings_from_row(row) + + def list_llm_provider_profiles(self) -> list[Any]: + with self._sessions() as session: + rows = session.scalars( + select(LlmProviderProfileRow).order_by( + LlmProviderProfileRow.name_normalized + ) + ).all() + return [_llm_provider_profile_from_row(row) for row in rows] + + def get_llm_provider_profile(self, profile_id: str) -> Any | None: + with self._sessions() as session: + row = session.get(LlmProviderProfileRow, profile_id) + return _llm_provider_profile_from_row(row) if row is not None else None + + def create_llm_provider_profile(self, profile: Any) -> Any: + from cloud.llm_providers import LlmProviderConflictError + + try: + with self._sessions.begin() as session: + if ( + session.scalars( + select(LlmProviderProfileRow).where( + LlmProviderProfileRow.name_normalized + == profile.name_normalized + ) + ).first() + is not None + ): + raise LlmProviderConflictError( + "Provider profile name already exists" + ) + row = LlmProviderProfileRow( + id=profile.id, + name=profile.name, + name_normalized=profile.name_normalized, + provider_type=profile.provider_type, + model=profile.model, + base_url=profile.base_url, + timeout_seconds=profile.timeout_seconds, + api_key_ciphertext=profile.api_key_ciphertext, + key_last_rotated_at=_iso(profile.key_last_rotated_at), + enabled=1 if profile.enabled else 0, + revision=profile.revision, + created_at=_iso(profile.created_at), + updated_at=_iso(profile.updated_at), + ) + session.add(row) + session.flush() + return _llm_provider_profile_from_row(row) + except IntegrityError as exc: + raise LlmProviderConflictError( + "Provider profile name already exists" + ) from exc + + def update_llm_provider_profile( + self, + profile_id: str, + *, + name: str, + name_normalized: str, + provider_type: str, + model: str, + base_url: str | None, + timeout_seconds: float, + api_key_ciphertext: str, + key_last_rotated_at: datetime, + enabled: bool, + expected_revision: int | None, + updated_at: datetime, + ) -> Any: + from cloud.llm_providers import ( + LlmProviderActiveConflictError, + LlmProviderConflictError, + ) + + try: + with self._sessions.begin() as session: + row = session.get( + LlmProviderProfileRow, + profile_id, + with_for_update=self.engine.dialect.name == "postgresql", + ) + if row is None: + raise KeyError(profile_id) + if expected_revision is not None and expected_revision != row.revision: + raise LlmProviderConflictError("Provider profile revision changed") + existing_name = session.scalars( + select(LlmProviderProfileRow).where( + LlmProviderProfileRow.name_normalized == name_normalized, + LlmProviderProfileRow.id != profile_id, + ) + ).first() + if existing_name is not None: + raise LlmProviderConflictError( + "Provider profile name already exists" + ) + settings = session.get( + LlmProviderSettingsRow, + "global", + with_for_update=self.engine.dialect.name == "postgresql", + ) + if ( + not enabled + and settings is not None + and settings.active_profile_id == profile_id + ): + raise LlmProviderActiveConflictError( + "activate another Provider profile before disabling this one" + ) + row.name = name + row.name_normalized = name_normalized + row.provider_type = provider_type + row.model = model + row.base_url = base_url + row.timeout_seconds = timeout_seconds + row.api_key_ciphertext = api_key_ciphertext + row.key_last_rotated_at = _iso(key_last_rotated_at) + row.enabled = 1 if enabled else 0 + row.revision += 1 + row.updated_at = _iso(updated_at) + session.flush() + return _llm_provider_profile_from_row(row) + except IntegrityError as exc: + raise LlmProviderConflictError( + "Provider profile name already exists" + ) from exc + + def activate_llm_provider_profile( + self, + profile_id: str, + *, + expected_settings_revision: int | None, + updated_at: datetime, + ) -> Any: + from cloud.llm_providers import LlmProviderConflictError + + with self._sessions.begin() as session: + profile = session.get( + LlmProviderProfileRow, + profile_id, + with_for_update=self.engine.dialect.name == "postgresql", + ) + if profile is None: + raise KeyError(profile_id) + if not profile.enabled: + raise LlmProviderConflictError( + "Provider profile must be enabled before activation" + ) + settings = session.get( + LlmProviderSettingsRow, + "global", + with_for_update=self.engine.dialect.name == "postgresql", + ) + if settings is None: + if expected_settings_revision not in {None, 0}: + raise LlmProviderConflictError("Provider settings revision changed") + settings = LlmProviderSettingsRow( + id="global", + active_profile_id=profile_id, + revision=1, + updated_at=_iso(updated_at), + ) + session.add(settings) + else: + if ( + expected_settings_revision is not None + and expected_settings_revision != settings.revision + ): + raise LlmProviderConflictError("Provider settings revision changed") + settings.active_profile_id = profile_id + settings.revision += 1 + settings.updated_at = _iso(updated_at) + session.flush() + return _llm_provider_settings_from_row(settings) + + def delete_llm_provider_profile( + self, + profile_id: str, + *, + expected_revision: int | None, + ) -> None: + from cloud.llm_providers import ( + LlmProviderActiveConflictError, + LlmProviderConflictError, + ) + + with self._sessions.begin() as session: + row = session.get( + LlmProviderProfileRow, + profile_id, + with_for_update=self.engine.dialect.name == "postgresql", + ) + if row is None: + raise KeyError(profile_id) + if expected_revision is not None and expected_revision != row.revision: + raise LlmProviderConflictError("Provider profile revision changed") + settings = session.get( + LlmProviderSettingsRow, + "global", + with_for_update=self.engine.dialect.name == "postgresql", + ) + if settings is not None and settings.active_profile_id == profile_id: + raise LlmProviderActiveConflictError( + "activate another Provider profile before deleting this one" + ) + session.delete(row) + def count_active_tasks_for_host(self, host_id: str) -> int: with self._sessions() as session: count = session.scalar( @@ -958,24 +1191,36 @@ class SQLAlchemyCloudRepository: if policy is None or policy.daily_token_budget is None: return None used = session.scalar( - select(func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)).where( + select( + func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0) + ).where( TokenUsageEventRow.host_id == host_id, TokenUsageEventRow.usage_day == usage_day, ) ) reserved = session.scalar( - select(func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)).where( + select( + func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0) + ).where( TokenReservationRow.host_id == host_id, TokenReservationRow.usage_day == usage_day, TokenReservationRow.expires_at > _iso(created_at), ) ) - if int(used or 0) + int(reserved or 0) + reserved_tokens > policy.daily_token_budget: + if ( + int(used or 0) + int(reserved or 0) + reserved_tokens + > policy.daily_token_budget + ): raise TokenBudgetExceededError("Host daily token budget is exhausted") row = TokenReservationRow( - id=reservation_id, host_id=host_id, usage_day=usage_day, - reserved_tokens=reserved_tokens, task_id=task_id, attempt=attempt, - created_at=_iso(created_at), expires_at=_iso(expires_at), + id=reservation_id, + host_id=host_id, + usage_day=usage_day, + reserved_tokens=reserved_tokens, + task_id=task_id, + attempt=attempt, + created_at=_iso(created_at), + expires_at=_iso(expires_at), ) session.add(row) session.flush() @@ -995,16 +1240,24 @@ class SQLAlchemyCloudRepository: ) -> Any | None: with self._sessions.begin() as session: row = session.get( - TokenReservationRow, reservation_id, + TokenReservationRow, + reservation_id, with_for_update=self.engine.dialect.name == "postgresql", ) if row is None: return None event = TokenUsageEventRow( - id=event_id, host_id=row.host_id, usage_day=row.usage_day, - task_id=row.task_id, attempt=row.attempt, provider=provider, model=model, - input_tokens=input_tokens, output_tokens=output_tokens, - total_tokens=total_tokens, occurred_at=_iso(occurred_at), + id=event_id, + host_id=row.host_id, + usage_day=row.usage_day, + task_id=row.task_id, + attempt=row.attempt, + provider=provider, + model=model, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + occurred_at=_iso(occurred_at), ) session.add(event) session.delete(row) @@ -1024,39 +1277,55 @@ class SQLAlchemyCloudRepository: return len(rows) def get_host_token_usage_summary( - self, *, host_id: str, usage_day: str, now: datetime, + self, + *, + host_id: str, + usage_day: str, + now: datetime, ) -> Any: from cloud.governance import TokenUsageSummary with self._sessions() as session: policy = session.get(HostGovernancePolicyRow, host_id) used = session.scalar( - select(func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)).where( + select( + func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0) + ).where( TokenUsageEventRow.host_id == host_id, TokenUsageEventRow.usage_day == usage_day, ) ) reserved = session.scalar( - select(func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)).where( + select( + func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0) + ).where( TokenReservationRow.host_id == host_id, TokenReservationRow.usage_day == usage_day, TokenReservationRow.expires_at > _iso(now), ) ) return TokenUsageSummary( - host_id=host_id, usage_day=usage_day, + host_id=host_id, + usage_day=usage_day, daily_token_budget=(policy.daily_token_budget if policy else None), - used_tokens=int(used or 0), reserved_tokens=int(reserved or 0), + used_tokens=int(used or 0), + reserved_tokens=int(reserved or 0), ) def list_host_token_usage_events( - self, *, host_id: str, limit: int, offset: int, + self, + *, + host_id: str, + limit: int, + offset: int, ) -> list[Any]: with self._sessions() as session: rows = session.scalars( select(TokenUsageEventRow) .where(TokenUsageEventRow.host_id == host_id) - .order_by(TokenUsageEventRow.occurred_at.desc(), TokenUsageEventRow.id.desc()) + .order_by( + TokenUsageEventRow.occurred_at.desc(), TokenUsageEventRow.id.desc() + ) .limit(limit) .offset(offset) ).all() @@ -1423,7 +1692,9 @@ def _host_from_row(row: HostRow) -> Any: address=row.address, last_seen_at=_parse_dt(row.last_seen_at) or utc_now(), planner_transport=( - row.planner_transport if row.planner_transport in {"direct", "cloud"} else "direct" + row.planner_transport + if row.planner_transport in {"direct", "cloud"} + else "direct" ), ) @@ -1519,7 +1790,7 @@ def _load_optional_string_tuple(value: str | None) -> tuple[str, ...] | None: return None try: parsed = json.loads(value) - except (TypeError, ValueError): + except TypeError, ValueError: parsed = [] return tuple(item for item in parsed if isinstance(item, str)) @@ -1531,7 +1802,7 @@ def _load_optional_device_targets( return None try: parsed = json.loads(value) - except (TypeError, ValueError): + except TypeError, ValueError: parsed = [] return tuple( (item[0], item[1]) @@ -1574,8 +1845,12 @@ def _token_reservation_from_row(row: TokenReservationRow) -> Any: from cloud.governance import TokenReservation return TokenReservation( - id=row.id, host_id=row.host_id, usage_day=row.usage_day, - reserved_tokens=row.reserved_tokens, task_id=row.task_id, attempt=row.attempt, + id=row.id, + host_id=row.host_id, + usage_day=row.usage_day, + reserved_tokens=row.reserved_tokens, + task_id=row.task_id, + attempt=row.attempt, created_at=_parse_dt(row.created_at) or utc_now(), expires_at=_parse_dt(row.expires_at) or utc_now(), ) @@ -1585,10 +1860,17 @@ def _token_usage_event_from_row(row: TokenUsageEventRow) -> Any: from cloud.governance import TokenUsageEvent return TokenUsageEvent( - id=row.id, host_id=row.host_id, usage_day=row.usage_day, - task_id=row.task_id, attempt=row.attempt, provider=row.provider, model=row.model, - input_tokens=row.input_tokens, output_tokens=row.output_tokens, - total_tokens=row.total_tokens, occurred_at=_parse_dt(row.occurred_at) or utc_now(), + id=row.id, + host_id=row.host_id, + usage_day=row.usage_day, + task_id=row.task_id, + attempt=row.attempt, + provider=row.provider, + model=row.model, + input_tokens=row.input_tokens, + output_tokens=row.output_tokens, + total_tokens=row.total_tokens, + occurred_at=_parse_dt(row.occurred_at) or utc_now(), ) @@ -1733,3 +2015,36 @@ def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]: ), bool(row.wired), ) + + +def _llm_provider_profile_from_row(row: LlmProviderProfileRow) -> Any: + from cloud.llm_providers import LlmProviderProfile + + now = utc_now() + return LlmProviderProfile( + id=row.id, + name=row.name, + name_normalized=row.name_normalized, + provider_type=row.provider_type, + model=row.model, + base_url=row.base_url, + timeout_seconds=row.timeout_seconds, + api_key_ciphertext=row.api_key_ciphertext, + key_last_rotated_at=_parse_dt(row.key_last_rotated_at) or now, + enabled=bool(row.enabled), + revision=row.revision, + created_at=_parse_dt(row.created_at) or now, + updated_at=_parse_dt(row.updated_at) or now, + ) + + +def _llm_provider_settings_from_row(row: LlmProviderSettingsRow | None) -> Any: + from cloud.llm_providers import LlmProviderSettings + + if row is None: + return LlmProviderSettings(active_profile_id=None) + return LlmProviderSettings( + active_profile_id=row.active_profile_id, + revision=row.revision, + updated_at=_parse_dt(row.updated_at), + ) diff --git a/packages/cloud-platform/pyproject.toml b/packages/cloud-platform/pyproject.toml index d0177ea..a8a9d4b 100644 --- a/packages/cloud-platform/pyproject.toml +++ b/packages/cloud-platform/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.14" dependencies = [ "alembic>=1.14.0", "argon2-cffi>=25.0.0", + "cryptography>=45.0.0", "device-agent-runtime==0.1.0", "psycopg[binary]>=3.2.0", "sqlalchemy>=2.0.0", diff --git a/runtime/tool_calling_client.py b/runtime/tool_calling_client.py index f37f039..2c45f99 100644 --- a/runtime/tool_calling_client.py +++ b/runtime/tool_calling_client.py @@ -46,10 +46,12 @@ class AnthropicToolCallingClient: model: str, transport: Any | None = None, max_tokens: int = 1024, + api_key: str | None = None, ) -> None: self.model = model self._transport = transport self.max_tokens = max_tokens + self._api_key = api_key def decide( self, @@ -118,7 +120,11 @@ class AnthropicToolCallingClient: except Exception as exc: raise ToolCallUnavailable("anthropic SDK is unavailable") from exc - self._transport = anthropic.Anthropic() + self._transport = ( + anthropic.Anthropic(api_key=self._api_key) + if self._api_key is not None + else anthropic.Anthropic() + ) return self._transport @@ -129,10 +135,14 @@ class OpenAIToolCallingClient: model: str, transport: Any | None = None, max_tokens: int = 1024, + api_key: str | None = None, + base_url: str | None = None, ) -> None: self.model = model self._transport = transport self.max_tokens = max_tokens + self._api_key = api_key + self._base_url = base_url def decide( self, @@ -193,7 +203,12 @@ class OpenAIToolCallingClient: except Exception as exc: raise ToolCallUnavailable("openai SDK is unavailable") from exc - self._transport = OpenAI() + kwargs: dict[str, str] = {} + if self._api_key is not None: + kwargs["api_key"] = self._api_key + if self._base_url is not None: + kwargs["base_url"] = self._base_url + self._transport = OpenAI(**kwargs) return self._transport diff --git a/tests/test_cloud_migrations.py b/tests/test_cloud_migrations.py index b09e870..959e357 100644 --- a/tests/test_cloud_migrations.py +++ b/tests/test_cloud_migrations.py @@ -40,8 +40,17 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None: "cloud_auth_audit_events", "cloud_token_reservations", "cloud_token_usage_events", + "cloud_llm_provider_profiles", + "cloud_llm_provider_settings", } <= table_names assert current_revision(database_url) == HEAD_REVISION + assert ( + connection_scalar( + engine, + "select revision from cloud_llm_provider_settings where id = 'global'", + ) + == 0 + ) host_columns = { column["name"] for column in inspect(engine).get_columns("host_registrations") diff --git a/uv.lock b/uv.lock index bd81e35..f505c61 100644 --- a/uv.lock +++ b/uv.lock @@ -545,6 +545,7 @@ source = { editable = "packages/cloud-platform" } dependencies = [ { name = "alembic" }, { name = "argon2-cffi" }, + { name = "cryptography" }, { name = "device-agent-runtime" }, { name = "psycopg", extra = ["binary"] }, { name = "sqlalchemy" }, @@ -554,6 +555,7 @@ dependencies = [ requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "argon2-cffi", specifier = ">=25.0.0" }, + { name = "cryptography", specifier = ">=45.0.0" }, { name = "device-agent-runtime", editable = "." }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "sqlalchemy", specifier = ">=2.0.0" },