@@ -31,6 +31,7 @@ from cloud.control_config import (
|
||||
)
|
||||
from cloud.database import CloudDatabase
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.llm_providers import LlmProviderService
|
||||
from cloud.plugins import PluginRegistry
|
||||
from cloud.observability import (
|
||||
CORRELATION_HEADER,
|
||||
@@ -44,6 +45,7 @@ from cloud.scheduler import TaskScheduler
|
||||
from cloud.schema import require_current_schema
|
||||
from cloud.sdk.api import create_cloud_router
|
||||
from cloud.sdk.governance_api import create_governance_router
|
||||
from cloud.sdk.llm_provider_api import create_llm_provider_router
|
||||
from cloud.sdk.user_api import create_user_auth_router
|
||||
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
||||
from core.models import utc_now
|
||||
@@ -77,6 +79,7 @@ class CloudApplicationServices:
|
||||
plugin_registry: PluginRegistry
|
||||
auth_provider: Any
|
||||
user_auth_service: UserAuthService
|
||||
llm_provider_service: LlmProviderService
|
||||
|
||||
|
||||
class SpaStaticFiles(StaticFiles):
|
||||
@@ -126,6 +129,7 @@ def create_app(
|
||||
cookie_secure=control_config.session_cookie_secure,
|
||||
),
|
||||
)
|
||||
llm_provider_service = LlmProviderService(repository)
|
||||
auth_provider = ChainedAuthProvider(
|
||||
(
|
||||
configured_auth_provider,
|
||||
@@ -146,6 +150,7 @@ def create_app(
|
||||
plugin_registry=plugin_registry,
|
||||
auth_provider=auth_provider,
|
||||
user_auth_service=user_auth_service,
|
||||
llm_provider_service=llm_provider_service,
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -295,6 +300,18 @@ def create_app(
|
||||
auth_provider=auth_provider,
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_llm_provider_router(
|
||||
service=llm_provider_service,
|
||||
repository=repository,
|
||||
auth_provider=auth_provider,
|
||||
csrf_validator=lambda request, principal: _valid_csrf_request(
|
||||
request,
|
||||
principal,
|
||||
user_auth_service,
|
||||
),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
create_internal_router(
|
||||
pool=pool,
|
||||
@@ -307,6 +324,7 @@ def create_app(
|
||||
planner_token_reservation_ttl_seconds=(
|
||||
control_config.planner_token_reservation_ttl_seconds
|
||||
),
|
||||
planner_provider_service=llm_provider_service,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,80 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cloud.planner_config import (
|
||||
DEFAULT_MODEL_BY_PROVIDER,
|
||||
DEFAULT_PROVIDER,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
CloudPlannerConfig,
|
||||
build_cloud_planner_client,
|
||||
load_cloud_planner_config,
|
||||
)
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cloud.llm_providers import LlmProviderProfile, ResolvedLlmProviderProfile
|
||||
from cloud.planner_config import build_cloud_planner_client
|
||||
from runtime.tool_calling_client import (
|
||||
AnthropicToolCallingClient,
|
||||
OpenAIToolCallingClient,
|
||||
)
|
||||
|
||||
_NO_RELEVANT_VARS = {"UNRELATED": "1"}
|
||||
|
||||
|
||||
def test_load_cloud_planner_config_defaults_when_unset() -> None:
|
||||
config = load_cloud_planner_config(_NO_RELEVANT_VARS)
|
||||
|
||||
assert config == CloudPlannerConfig(
|
||||
provider=DEFAULT_PROVIDER,
|
||||
model="",
|
||||
timeout=DEFAULT_TIMEOUT_SECONDS,
|
||||
def _resolved_profile(
|
||||
provider_type: str, *, base_url: str | None = None
|
||||
) -> ResolvedLlmProviderProfile:
|
||||
now = datetime.now(UTC)
|
||||
profile = LlmProviderProfile(
|
||||
id="provider-1",
|
||||
name="Managed provider",
|
||||
name_normalized="managed provider",
|
||||
provider_type=provider_type, # type: ignore[arg-type]
|
||||
model="test-model",
|
||||
base_url=base_url,
|
||||
timeout_seconds=15,
|
||||
api_key_ciphertext="ciphertext",
|
||||
key_last_rotated_at=now,
|
||||
enabled=True,
|
||||
revision=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
assert config.resolved_model() == DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER]
|
||||
return ResolvedLlmProviderProfile(profile=profile, api_key="managed-api-key")
|
||||
|
||||
|
||||
def test_load_cloud_planner_config_selects_provider_and_resolves_default_model() -> (
|
||||
None
|
||||
):
|
||||
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "openai"})
|
||||
|
||||
assert config.provider == "openai"
|
||||
assert config.resolved_model() == "gpt-5.6"
|
||||
|
||||
|
||||
def test_load_cloud_planner_config_falls_back_to_default_provider_when_unsupported() -> (
|
||||
None
|
||||
):
|
||||
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "not-a-real-provider"})
|
||||
|
||||
assert config.provider == DEFAULT_PROVIDER
|
||||
|
||||
|
||||
def test_load_cloud_planner_config_model_override_wins_regardless_of_provider() -> None:
|
||||
config = load_cloud_planner_config(
|
||||
{"AI_PLANNER_PROVIDER": "openai", "AI_PLANNER_MODEL": "custom-model"}
|
||||
)
|
||||
|
||||
assert config.resolved_model() == "custom-model"
|
||||
|
||||
|
||||
def test_load_cloud_planner_config_parses_valid_timeout() -> None:
|
||||
config = load_cloud_planner_config({"AI_PLANNER_TIMEOUT_SECONDS": "12.5"})
|
||||
|
||||
assert config.timeout == 12.5
|
||||
|
||||
|
||||
def test_load_cloud_planner_config_falls_back_to_default_timeout_when_invalid() -> None:
|
||||
for value in ["not-a-number", "0", "-5"]:
|
||||
config = load_cloud_planner_config(
|
||||
{"AI_PLANNER_TIMEOUT_SECONDS": value, **_NO_RELEVANT_VARS}
|
||||
)
|
||||
assert config.timeout == DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
|
||||
def test_build_cloud_planner_client_selects_anthropic_by_default() -> None:
|
||||
client = build_cloud_planner_client(CloudPlannerConfig())
|
||||
def test_build_cloud_planner_client_uses_managed_anthropic_key() -> None:
|
||||
client = build_cloud_planner_client(_resolved_profile("anthropic"))
|
||||
|
||||
assert isinstance(client, AnthropicToolCallingClient)
|
||||
assert client.model == DEFAULT_MODEL_BY_PROVIDER["anthropic"]
|
||||
assert client.model == "test-model"
|
||||
assert client._api_key == "managed-api-key"
|
||||
|
||||
|
||||
def test_build_cloud_planner_client_selects_openai() -> None:
|
||||
client = build_cloud_planner_client(CloudPlannerConfig(provider="openai"))
|
||||
def test_build_cloud_planner_client_uses_openai_compatible_base_url() -> None:
|
||||
client = build_cloud_planner_client(
|
||||
_resolved_profile("openai-compatible", base_url="https://compat.example/v1")
|
||||
)
|
||||
|
||||
assert isinstance(client, OpenAIToolCallingClient)
|
||||
assert client.model == DEFAULT_MODEL_BY_PROVIDER["openai"]
|
||||
assert client.model == "test-model"
|
||||
assert client._api_key == "managed-api-key"
|
||||
assert client._base_url == "https://compat.example/v1"
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from cloud.control_config import CloudControlConfig
|
||||
from cloud_api.app import create_app
|
||||
|
||||
|
||||
def _create_admin(client: TestClient) -> None:
|
||||
client.app.state.cloud_services.user_auth_service.create_user(
|
||||
username="admin",
|
||||
display_name="Administrator",
|
||||
role="admin",
|
||||
password="correct-horse-battery-staple",
|
||||
must_change_password=False,
|
||||
)
|
||||
response = client.post(
|
||||
"/v1/auth/login",
|
||||
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _csrf_headers(client: TestClient) -> dict[str, str]:
|
||||
token = client.cookies.get("amcp_csrf")
|
||||
assert token is not None
|
||||
return {"X-CSRF-Token": token}
|
||||
|
||||
|
||||
def _profile_payload(**overrides: object) -> dict[str, object]:
|
||||
payload = {
|
||||
"name": "OpenAI Compatible",
|
||||
"provider_type": "openai-compatible",
|
||||
"model": "gpt-compatible",
|
||||
"base_url": "https://compat.example/v1",
|
||||
"timeout_seconds": 20,
|
||||
"api_key": "provider-secret-value",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_provider_profiles_are_encrypted_redacted_and_activated(monkeypatch) -> None:
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
monkeypatch.setenv(
|
||||
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
|
||||
)
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
with TestClient(app) as client:
|
||||
_create_admin(client)
|
||||
headers = _csrf_headers(client)
|
||||
|
||||
missing_csrf = client.post("/v1/planner/providers", json=_profile_payload())
|
||||
assert missing_csrf.status_code == 403
|
||||
|
||||
empty_key = client.post(
|
||||
"/v1/planner/providers",
|
||||
headers=headers,
|
||||
json=_profile_payload(api_key=""),
|
||||
)
|
||||
assert empty_key.status_code == 422
|
||||
|
||||
created = client.post(
|
||||
"/v1/planner/providers", headers=headers, json=_profile_payload()
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
profile = created.json()
|
||||
assert profile["has_api_key"] is True
|
||||
assert "api_key" not in profile
|
||||
assert "ciphertext" not in profile
|
||||
|
||||
repository = client.app.state.cloud_services.repository
|
||||
stored = repository.get_llm_provider_profile(profile["id"])
|
||||
assert stored is not None
|
||||
assert stored.api_key_ciphertext != "provider-secret-value"
|
||||
assert "provider-secret-value" not in stored.api_key_ciphertext
|
||||
|
||||
listed = client.get("/v1/planner/providers")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["settings"]["active_profile_id"] is None
|
||||
assert "provider-secret-value" not in listed.text
|
||||
assert stored.api_key_ciphertext not in listed.text
|
||||
|
||||
activated = client.post(
|
||||
f"/v1/planner/providers/{profile['id']}/activate",
|
||||
headers=headers,
|
||||
json={"expected_settings_revision": 0},
|
||||
)
|
||||
assert activated.status_code == 200, activated.text
|
||||
assert activated.json()["active_profile_id"] == profile["id"]
|
||||
|
||||
disable_active = client.patch(
|
||||
f"/v1/planner/providers/{profile['id']}",
|
||||
headers=headers,
|
||||
json={"enabled": False, "expected_revision": profile["revision"]},
|
||||
)
|
||||
assert disable_active.status_code == 409
|
||||
|
||||
|
||||
def test_provider_profile_requires_admin_scope(monkeypatch) -> None:
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
monkeypatch.setenv(
|
||||
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
|
||||
)
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
with TestClient(app) as client:
|
||||
service = client.app.state.cloud_services.user_auth_service
|
||||
service.create_user(
|
||||
username="operator",
|
||||
display_name="Operator",
|
||||
role="operator",
|
||||
password="correct-horse-battery-staple",
|
||||
must_change_password=False,
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/v1/auth/login",
|
||||
json={
|
||||
"username": "operator",
|
||||
"password": "correct-horse-battery-staple",
|
||||
},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
assert client.get("/v1/planner/providers").status_code == 403
|
||||
assert (
|
||||
client.post(
|
||||
"/v1/planner/providers",
|
||||
headers=_csrf_headers(client),
|
||||
json=_profile_payload(),
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_profile_write_requires_encryption_key(monkeypatch) -> None:
|
||||
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
with TestClient(app) as client:
|
||||
_create_admin(client)
|
||||
response = client.post(
|
||||
"/v1/planner/providers",
|
||||
headers=_csrf_headers(client),
|
||||
json=_profile_payload(),
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "provider-secret-value" not in response.text
|
||||
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import cloud.internal_api.api as internal_api
|
||||
from cloud.control_config import CloudControlConfig
|
||||
from runtime.tool_calling_client import ToolCallDecision
|
||||
from cloud_api.app import create_app
|
||||
|
||||
|
||||
class _FakePlannerClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def decide(self, **_kwargs) -> ToolCallDecision:
|
||||
self.calls += 1
|
||||
return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
|
||||
|
||||
|
||||
def _login_admin(client: TestClient) -> dict[str, str]:
|
||||
client.app.state.cloud_services.user_auth_service.create_user(
|
||||
username="admin",
|
||||
display_name="Administrator",
|
||||
role="admin",
|
||||
password="correct-horse-battery-staple",
|
||||
must_change_password=False,
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/v1/auth/login",
|
||||
json={"username": "admin", "password": "correct-horse-battery-staple"},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
csrf = client.cookies.get("amcp_csrf")
|
||||
assert csrf is not None
|
||||
return {"X-CSRF-Token": csrf}
|
||||
|
||||
|
||||
def _create_profile(
|
||||
client: TestClient, headers: dict[str, str], *, name: str, model: str
|
||||
) -> dict:
|
||||
response = client.post(
|
||||
"/v1/planner/providers",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": name,
|
||||
"provider_type": "openai-compatible",
|
||||
"model": model,
|
||||
"base_url": "https://compat.example/v1",
|
||||
"timeout_seconds": 30,
|
||||
"api_key": f"key-for-{name}",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def _enroll_host(client: TestClient) -> tuple[str, dict[str, str]]:
|
||||
enrollment = client.post(
|
||||
"/internal/v1/enrollments",
|
||||
json={"agent_instance_id": "agent-a", "host_token": "host-token-" + ("a" * 40)},
|
||||
)
|
||||
assert enrollment.status_code == 201, enrollment.text
|
||||
return enrollment.json()["host_id"], {
|
||||
"Authorization": "Bearer host-token-" + ("a" * 40)
|
||||
}
|
||||
|
||||
|
||||
def _decision_payload(host_id: str) -> dict:
|
||||
return {
|
||||
"host_id": host_id,
|
||||
"system_prompt": "system",
|
||||
"user_prompt": "user",
|
||||
"tools": [{"name": "tap", "description": "tap", "parameters": {}}],
|
||||
"timeout_seconds": 10,
|
||||
}
|
||||
|
||||
|
||||
def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
monkeypatch.setenv(
|
||||
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
|
||||
)
|
||||
resolved_profiles = []
|
||||
fake = _FakePlannerClient()
|
||||
|
||||
def build(resolved):
|
||||
resolved_profiles.append(resolved)
|
||||
return fake
|
||||
|
||||
monkeypatch.setattr(internal_api, "build_cloud_planner_client", build)
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
with TestClient(app) as client:
|
||||
admin_headers = _login_admin(client)
|
||||
first = _create_profile(
|
||||
client, admin_headers, name="First", model="first-model"
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/v1/planner/providers/{first['id']}/activate",
|
||||
headers=admin_headers,
|
||||
json={"expected_settings_revision": 0},
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
host_id, host_headers = _enroll_host(client)
|
||||
|
||||
first_decision = client.post(
|
||||
f"/internal/v1/hosts/{host_id}/planner/decide",
|
||||
headers=host_headers,
|
||||
json=_decision_payload(host_id),
|
||||
)
|
||||
assert first_decision.status_code == 200, first_decision.text
|
||||
assert resolved_profiles[-1].profile.model == "first-model"
|
||||
assert resolved_profiles[-1].api_key == "key-for-First"
|
||||
|
||||
second = _create_profile(
|
||||
client, admin_headers, name="Second", model="second-model"
|
||||
)
|
||||
settings = client.get("/v1/planner/providers").json()["settings"]
|
||||
activated = client.post(
|
||||
f"/v1/planner/providers/{second['id']}/activate",
|
||||
headers=admin_headers,
|
||||
json={"expected_settings_revision": settings["revision"]},
|
||||
)
|
||||
assert activated.status_code == 200, activated.text
|
||||
|
||||
second_decision = client.post(
|
||||
f"/internal/v1/hosts/{host_id}/planner/decide",
|
||||
headers=host_headers,
|
||||
json=_decision_payload(host_id),
|
||||
)
|
||||
assert second_decision.status_code == 200, second_decision.text
|
||||
assert resolved_profiles[-1].profile.model == "second-model"
|
||||
assert fake.calls == 2
|
||||
|
||||
|
||||
def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None:
|
||||
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
|
||||
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
|
||||
with TestClient(app) as client:
|
||||
host_id, host_headers = _enroll_host(client)
|
||||
response = client.post(
|
||||
f"/internal/v1/hosts/{host_id}/planner/decide",
|
||||
headers=host_headers,
|
||||
json=_decision_payload(host_id),
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
assert response.json() == {
|
||||
"code": "planner_unavailable",
|
||||
"detail": "no active database Provider profile",
|
||||
}
|
||||
@@ -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<ViewId>("tasks");
|
||||
const currentUser = ref<CloudUser | null>(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"
|
||||
/>
|
||||
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
||||
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Plug
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function listLlmProviderProfiles(): Promise<LlmProviderProfileListResponse> {
|
||||
return request<LlmProviderProfileListResponse>("/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<LlmProviderProfile> {
|
||||
return request<LlmProviderProfile>("/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<LlmProviderProfile> {
|
||||
return request<LlmProviderProfile>(`/v1/planner/providers/${encodeURIComponent(profileId)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function activateLlmProviderProfile(
|
||||
profileId: string,
|
||||
expectedSettingsRevision: number,
|
||||
): Promise<LlmProviderSettings> {
|
||||
return request<LlmProviderSettings>(
|
||||
`/v1/planner/providers/${encodeURIComponent(profileId)}/activate`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_settings_revision: expectedSettingsRevision }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteLlmProviderProfile(
|
||||
profileId: string,
|
||||
expectedRevision: number,
|
||||
): Promise<void> {
|
||||
return request<void>(
|
||||
`/v1/planner/providers/${encodeURIComponent(profileId)}?expected_revision=${expectedRevision}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasScope } from "./permissions";
|
||||
import type { CloudUser } from "./types";
|
||||
|
||||
const baseUser: Omit<CloudUser, "scopes"> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { Check, LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
|
||||
import {
|
||||
activateLlmProviderProfile,
|
||||
createLlmProviderProfile,
|
||||
deleteLlmProviderProfile,
|
||||
listLlmProviderProfiles,
|
||||
updateLlmProviderProfile,
|
||||
} from "../api";
|
||||
import type { LlmProviderProfile, LlmProviderSettings, LlmProviderType } from "../types";
|
||||
|
||||
defineProps<{ canAdmin: boolean }>();
|
||||
|
||||
const profiles = ref<LlmProviderProfile[]>([]);
|
||||
const settings = ref<LlmProviderSettings>({ active_profile_id: null, revision: 0, updated_at: null });
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
const formError = ref("");
|
||||
const successMessage = ref("");
|
||||
const errorMessage = ref("");
|
||||
|
||||
const form = reactive({
|
||||
name: "",
|
||||
provider_type: "openai-compatible" as LlmProviderType,
|
||||
model: "",
|
||||
base_url: "",
|
||||
timeout_seconds: "30",
|
||||
api_key: "",
|
||||
enabled: true,
|
||||
});
|
||||
const editingProfile = computed(
|
||||
() => profiles.value.find((profile) => profile.id === editingId.value) ?? null,
|
||||
);
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null;
|
||||
form.name = "";
|
||||
form.provider_type = "openai-compatible";
|
||||
form.model = "";
|
||||
form.base_url = "";
|
||||
form.timeout_seconds = "30";
|
||||
form.api_key = "";
|
||||
form.enabled = true;
|
||||
formError.value = "";
|
||||
}
|
||||
|
||||
function showError(error: unknown, fallback: string) {
|
||||
successMessage.value = "";
|
||||
errorMessage.value = error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const response = await listLlmProviderProfiles();
|
||||
profiles.value = response.items;
|
||||
settings.value = response.settings;
|
||||
} catch (error) {
|
||||
showError(error, "failed to load LLM providers");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editProfile(profile: LlmProviderProfile) {
|
||||
editingId.value = profile.id;
|
||||
form.name = profile.name;
|
||||
form.provider_type = profile.provider_type;
|
||||
form.model = profile.model;
|
||||
form.base_url = profile.base_url ?? "";
|
||||
form.timeout_seconds = String(profile.timeout_seconds);
|
||||
form.api_key = "";
|
||||
form.enabled = profile.enabled;
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
}
|
||||
|
||||
function parseTimeout(): number | null {
|
||||
const timeout = Number(form.timeout_seconds);
|
||||
return Number.isFinite(timeout) && timeout > 0 && timeout <= 120 ? timeout : null;
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
formError.value = "";
|
||||
successMessage.value = "";
|
||||
const timeout = parseTimeout();
|
||||
if (!form.name.trim() || !form.model.trim() || timeout === null) {
|
||||
formError.value = "name, model, and a timeout between 0 and 120 are required";
|
||||
return;
|
||||
}
|
||||
if (!editingId.value && !form.api_key) {
|
||||
formError.value = "an API key is required for a new Provider profile";
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const baseUrl = form.provider_type === "anthropic" ? null : form.base_url.trim() || null;
|
||||
if (editingId.value) {
|
||||
const existing = profiles.value.find((profile) => profile.id === editingId.value);
|
||||
if (!existing) throw new Error("Provider profile no longer exists");
|
||||
const payload: Parameters<typeof updateLlmProviderProfile>[1] = {
|
||||
name: form.name.trim(),
|
||||
provider_type: form.provider_type,
|
||||
model: form.model.trim(),
|
||||
base_url: baseUrl,
|
||||
timeout_seconds: timeout,
|
||||
enabled: form.enabled,
|
||||
expected_revision: existing.revision,
|
||||
};
|
||||
if (form.api_key) payload.api_key = form.api_key;
|
||||
await updateLlmProviderProfile(editingId.value, payload);
|
||||
successMessage.value = "Provider profile saved";
|
||||
} else {
|
||||
await createLlmProviderProfile({
|
||||
name: form.name.trim(),
|
||||
provider_type: form.provider_type,
|
||||
model: form.model.trim(),
|
||||
base_url: baseUrl,
|
||||
timeout_seconds: timeout,
|
||||
api_key: form.api_key,
|
||||
});
|
||||
successMessage.value = "Provider profile created";
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
formError.value = error instanceof Error ? error.message : "failed to save Provider profile";
|
||||
} finally {
|
||||
form.api_key = "";
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(profile: LlmProviderProfile) {
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
settings.value = await activateLlmProviderProfile(profile.id, settings.value.revision);
|
||||
successMessage.value = `${profile.name} is active for Cloud planner requests`;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to activate Provider profile");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(profile: LlmProviderProfile) {
|
||||
if (!window.confirm(`Delete ${profile.name}?`)) return;
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await deleteLlmProviderProfile(profile.id, profile.revision);
|
||||
successMessage.value = "Provider profile deleted";
|
||||
if (editingId.value === profile.id) resetForm();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
showError(error, "failed to delete Provider profile");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<h2>LLM providers</h2>
|
||||
<button title="Refresh providers" aria-label="Refresh providers" :disabled="loading" @click="refresh">
|
||||
<RefreshCw :size="14" />
|
||||
</button>
|
||||
<button class="primary" @click="resetForm"><Plus :size="14" /> New provider</button>
|
||||
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading…</span>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
|
||||
<div v-if="successMessage" class="notice success">{{ successMessage }}</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>{{ editingId ? "Edit Provider" : "New Provider" }}</h3>
|
||||
<form @submit.prevent="saveProfile">
|
||||
<div class="form-grid">
|
||||
<label>Name <input v-model="form.name" autocomplete="off" /></label>
|
||||
<label>Protocol
|
||||
<select v-model="form.provider_type">
|
||||
<option value="openai-compatible">OpenAI-compatible</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Model <input v-model="form.model" autocomplete="off" /></label>
|
||||
<label>Timeout seconds <input v-model="form.timeout_seconds" inputmode="decimal" /></label>
|
||||
<label v-if="form.provider_type === 'openai-compatible'" class="field-full">Base URL
|
||||
<input v-model="form.base_url" placeholder="Official OpenAI endpoint when blank" autocomplete="url" />
|
||||
</label>
|
||||
<label class="field-full">{{ editingId ? "Rotate API key" : "API key" }}
|
||||
<input v-model="form.api_key" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label v-if="editingId"><input v-model="form.enabled" type="checkbox" :disabled="editingProfile?.active" /> Enabled</label>
|
||||
</div>
|
||||
<div v-if="formError" class="notice error" style="margin-top: 12px">{{ formError }}</div>
|
||||
<div class="toolbar" style="margin-top: 12px">
|
||||
<button type="submit" class="primary" :disabled="saving">{{ editingId ? "Save Provider" : "Create Provider" }}</button>
|
||||
<button v-if="editingId" type="button" title="Cancel edit" aria-label="Cancel edit" @click="resetForm"><X :size="14" /></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table v-if="profiles.length">
|
||||
<thead><tr><th>Name</th><th>Protocol / model</th><th>Endpoint</th><th>State</th><th>Key rotation</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="profile in profiles" :key="profile.id">
|
||||
<td><strong>{{ profile.name }}</strong></td>
|
||||
<td><span class="status-badge queued">{{ profile.provider_type }}</span><br /><span class="dim">{{ profile.model }}</span></td>
|
||||
<td class="dim">{{ profile.base_url ?? "Official endpoint" }}</td>
|
||||
<td><span :class="profile.active ? 'status-badge done' : profile.enabled ? 'status-badge idle' : 'status-badge failed'">{{ profile.active ? "active" : profile.enabled ? "ready" : "disabled" }}</span></td>
|
||||
<td class="dim">{{ new Date(profile.key_last_rotated_at).toLocaleString() }}</td>
|
||||
<td class="provider-actions">
|
||||
<button v-if="!profile.active" class="icon-button" title="Activate Provider" :aria-label="`Activate ${profile.name}`" :disabled="saving || !profile.enabled" @click="activate(profile)"><Check :size="14" /></button>
|
||||
<button class="icon-button" title="Edit Provider" :aria-label="`Edit ${profile.name}`" :disabled="saving" @click="editProfile(profile)"><Pencil :size="14" /></button>
|
||||
<button class="icon-button danger" title="Delete Provider" :aria-label="`Delete ${profile.name}`" :disabled="saving || profile.active" @click="remove(profile)"><Trash2 :size="14" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="muted">No Provider profiles configured.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-13
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+100
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
)
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user