@@ -31,6 +31,7 @@ from cloud.control_config import (
|
|||||||
)
|
)
|
||||||
from cloud.database import CloudDatabase
|
from cloud.database import CloudDatabase
|
||||||
from cloud.internal_api.api import create_internal_router
|
from cloud.internal_api.api import create_internal_router
|
||||||
|
from cloud.llm_providers import LlmProviderService
|
||||||
from cloud.plugins import PluginRegistry
|
from cloud.plugins import PluginRegistry
|
||||||
from cloud.observability import (
|
from cloud.observability import (
|
||||||
CORRELATION_HEADER,
|
CORRELATION_HEADER,
|
||||||
@@ -44,6 +45,7 @@ from cloud.scheduler import TaskScheduler
|
|||||||
from cloud.schema import require_current_schema
|
from cloud.schema import require_current_schema
|
||||||
from cloud.sdk.api import create_cloud_router
|
from cloud.sdk.api import create_cloud_router
|
||||||
from cloud.sdk.governance_api import create_governance_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.sdk.user_api import create_user_auth_router
|
||||||
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
|
||||||
from core.models import utc_now
|
from core.models import utc_now
|
||||||
@@ -77,6 +79,7 @@ class CloudApplicationServices:
|
|||||||
plugin_registry: PluginRegistry
|
plugin_registry: PluginRegistry
|
||||||
auth_provider: Any
|
auth_provider: Any
|
||||||
user_auth_service: UserAuthService
|
user_auth_service: UserAuthService
|
||||||
|
llm_provider_service: LlmProviderService
|
||||||
|
|
||||||
|
|
||||||
class SpaStaticFiles(StaticFiles):
|
class SpaStaticFiles(StaticFiles):
|
||||||
@@ -126,6 +129,7 @@ def create_app(
|
|||||||
cookie_secure=control_config.session_cookie_secure,
|
cookie_secure=control_config.session_cookie_secure,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
llm_provider_service = LlmProviderService(repository)
|
||||||
auth_provider = ChainedAuthProvider(
|
auth_provider = ChainedAuthProvider(
|
||||||
(
|
(
|
||||||
configured_auth_provider,
|
configured_auth_provider,
|
||||||
@@ -146,6 +150,7 @@ def create_app(
|
|||||||
plugin_registry=plugin_registry,
|
plugin_registry=plugin_registry,
|
||||||
auth_provider=auth_provider,
|
auth_provider=auth_provider,
|
||||||
user_auth_service=user_auth_service,
|
user_auth_service=user_auth_service,
|
||||||
|
llm_provider_service=llm_provider_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -295,6 +300,18 @@ def create_app(
|
|||||||
auth_provider=auth_provider,
|
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(
|
app.include_router(
|
||||||
create_internal_router(
|
create_internal_router(
|
||||||
pool=pool,
|
pool=pool,
|
||||||
@@ -307,6 +324,7 @@ def create_app(
|
|||||||
planner_token_reservation_ttl_seconds=(
|
planner_token_reservation_ttl_seconds=(
|
||||||
control_config.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 __future__ import annotations
|
||||||
|
|
||||||
from cloud.planner_config import (
|
from datetime import UTC, datetime
|
||||||
DEFAULT_MODEL_BY_PROVIDER,
|
|
||||||
DEFAULT_PROVIDER,
|
from cloud.llm_providers import LlmProviderProfile, ResolvedLlmProviderProfile
|
||||||
DEFAULT_TIMEOUT_SECONDS,
|
from cloud.planner_config import build_cloud_planner_client
|
||||||
CloudPlannerConfig,
|
|
||||||
build_cloud_planner_client,
|
|
||||||
load_cloud_planner_config,
|
|
||||||
)
|
|
||||||
from runtime.tool_calling_client import (
|
from runtime.tool_calling_client import (
|
||||||
AnthropicToolCallingClient,
|
AnthropicToolCallingClient,
|
||||||
OpenAIToolCallingClient,
|
OpenAIToolCallingClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
_NO_RELEVANT_VARS = {"UNRELATED": "1"}
|
|
||||||
|
|
||||||
|
def _resolved_profile(
|
||||||
def test_load_cloud_planner_config_defaults_when_unset() -> None:
|
provider_type: str, *, base_url: str | None = None
|
||||||
config = load_cloud_planner_config(_NO_RELEVANT_VARS)
|
) -> ResolvedLlmProviderProfile:
|
||||||
|
now = datetime.now(UTC)
|
||||||
assert config == CloudPlannerConfig(
|
profile = LlmProviderProfile(
|
||||||
provider=DEFAULT_PROVIDER,
|
id="provider-1",
|
||||||
model="",
|
name="Managed provider",
|
||||||
timeout=DEFAULT_TIMEOUT_SECONDS,
|
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() -> (
|
def test_build_cloud_planner_client_uses_managed_anthropic_key() -> None:
|
||||||
None
|
client = build_cloud_planner_client(_resolved_profile("anthropic"))
|
||||||
):
|
|
||||||
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())
|
|
||||||
|
|
||||||
assert isinstance(client, AnthropicToolCallingClient)
|
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:
|
def test_build_cloud_planner_client_uses_openai_compatible_base_url() -> None:
|
||||||
client = build_cloud_planner_client(CloudPlannerConfig(provider="openai"))
|
client = build_cloud_planner_client(
|
||||||
|
_resolved_profile("openai-compatible", base_url="https://compat.example/v1")
|
||||||
|
)
|
||||||
|
|
||||||
assert isinstance(client, OpenAIToolCallingClient)
|
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,
|
LogOut,
|
||||||
MonitorSmartphone,
|
MonitorSmartphone,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
|
SlidersHorizontal,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
} from "@lucide/vue";
|
} from "@lucide/vue";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
logout,
|
logout,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
import { hasScope } from "./permissions";
|
||||||
import type { CloudUser } from "./types";
|
import type { CloudUser } from "./types";
|
||||||
import LoginScreen from "./views/LoginScreen.vue";
|
import LoginScreen from "./views/LoginScreen.vue";
|
||||||
import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
|
import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
|
||||||
@@ -21,8 +23,9 @@ import TasksView from "./views/TasksView.vue";
|
|||||||
import DevicesView from "./views/DevicesView.vue";
|
import DevicesView from "./views/DevicesView.vue";
|
||||||
import PluginsView from "./views/PluginsView.vue";
|
import PluginsView from "./views/PluginsView.vue";
|
||||||
import UsersView from "./views/UsersView.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 activeView = ref<ViewId>("tasks");
|
||||||
const currentUser = ref<CloudUser | null>(null);
|
const currentUser = ref<CloudUser | null>(null);
|
||||||
@@ -49,6 +52,7 @@ const canAdminGovernance = computed(
|
|||||||
currentUser.value?.scopes.includes("*") ||
|
currentUser.value?.scopes.includes("*") ||
|
||||||
currentUser.value?.scopes.includes("governance:admin")),
|
currentUser.value?.scopes.includes("governance:admin")),
|
||||||
);
|
);
|
||||||
|
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
|
||||||
const isAuthenticated = computed(() => currentUser.value !== null);
|
const isAuthenticated = computed(() => currentUser.value !== null);
|
||||||
const currentUserLabel = computed(() =>
|
const currentUserLabel = computed(() =>
|
||||||
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
|
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) {
|
if (canAdminUsers.value || canAdminGovernance.value) {
|
||||||
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
|
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
|
||||||
}
|
}
|
||||||
|
if (canAdminProviders.value) {
|
||||||
|
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
|
||||||
|
}
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,6 +126,8 @@ const activeComponent = computed(() => {
|
|||||||
return PluginsView;
|
return PluginsView;
|
||||||
case "users":
|
case "users":
|
||||||
return UsersView;
|
return UsersView;
|
||||||
|
case "providers":
|
||||||
|
return LlmProvidersView;
|
||||||
default:
|
default:
|
||||||
return TasksView;
|
return TasksView;
|
||||||
}
|
}
|
||||||
@@ -155,6 +164,7 @@ const activeComponent = computed(() => {
|
|||||||
:can-admin-users="canAdminUsers"
|
:can-admin-users="canAdminUsers"
|
||||||
:can-admin-governance="canAdminGovernance"
|
:can-admin-governance="canAdminGovernance"
|
||||||
/>
|
/>
|
||||||
|
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
|
||||||
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
AUTH_INVALID_EVENT,
|
AUTH_INVALID_EVENT,
|
||||||
|
createLlmProviderProfile,
|
||||||
listDevices,
|
listDevices,
|
||||||
login,
|
login,
|
||||||
registerPlugin,
|
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 () => {
|
it("invalidates the session only on 401", async () => {
|
||||||
const invalidated = vi.fn();
|
const invalidated = vi.fn();
|
||||||
window.addEventListener(AUTH_INVALID_EVENT, invalidated);
|
window.addEventListener(AUTH_INVALID_EVENT, invalidated);
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type {
|
|||||||
DeviceRecord,
|
DeviceRecord,
|
||||||
HostGovernancePolicy,
|
HostGovernancePolicy,
|
||||||
HostTokenUsageSummary,
|
HostTokenUsageSummary,
|
||||||
|
LlmProviderProfile,
|
||||||
|
LlmProviderProfileListResponse,
|
||||||
|
LlmProviderSettings,
|
||||||
|
LlmProviderType,
|
||||||
HostRecord,
|
HostRecord,
|
||||||
PluginRecord,
|
PluginRecord,
|
||||||
PluginRegistrationPayload,
|
PluginRegistrationPayload,
|
||||||
@@ -253,3 +257,63 @@ export function registerPlugin(payload: PluginRegistrationPayload): Promise<Plug
|
|||||||
body: JSON.stringify(payload),
|
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);
|
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,
|
input,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
@@ -356,6 +370,13 @@ tr.row-selected {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.provider-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: spin 1.2s linear infinite;
|
animation: spin 1.2s linear infinite;
|
||||||
|
|||||||
@@ -149,3 +149,32 @@ export interface TokenUsageEvent {
|
|||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
occurred_at: string;
|
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
|
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
|
||||||
mode:
|
mode:
|
||||||
|
|
||||||
- **Credentials move to the Cloud API.** Configure `AI_PLANNER_PROVIDER`,
|
- **Provider configuration lives in the Cloud database.** Set
|
||||||
`AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`, and
|
`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY` from the deployment secret manager, then
|
||||||
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY` on the Cloud API process instead of the
|
sign in to `/console/` as an administrator and create an active entry under
|
||||||
Host Agent -- edge hosts no longer need provider keys at all.
|
**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:**
|
- **Trade-offs to accept before enabling:**
|
||||||
- *Latency*: every planning step now makes a round trip to the Cloud API in
|
- *Latency*: every planning step now makes a round trip to the Cloud API in
|
||||||
addition to the LLM provider call.
|
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
|
**unmetered**. Cloud cannot enforce or verify their provider token use. Do not
|
||||||
interpret an unmetered Host's absence of usage events as budget compliance.
|
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,
|
Roll out in this order: migrate the Cloud database, provision
|
||||||
switch a pilot Host to Cloud transport, configure a budget above the reservation
|
`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY`, deploy the Cloud API, create and activate
|
||||||
ceiling, then review its usage events before enabling budgets fleet-wide. A
|
a Provider profile in `/console/`, then switch a pilot Host to Cloud transport.
|
||||||
rollback to direct transport requires valid provider credentials on that Host;
|
Configure a budget above the reservation ceiling and review usage events before
|
||||||
preserve usage and policy rows rather than deleting accounting history.
|
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
|
## 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"
|
USERS_ADMIN_SCOPE = "users:admin"
|
||||||
GOVERNANCE_READ_SCOPE = "governance:read"
|
GOVERNANCE_READ_SCOPE = "governance:read"
|
||||||
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
|
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
|
||||||
|
LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -285,3 +285,45 @@ class TokenUsageEventRow(Base):
|
|||||||
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
total_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
total_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
occurred_at: Mapped[str] = mapped_column(String, 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,
|
TerminalResultRequest,
|
||||||
TerminalResultResponse,
|
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 (
|
from cloud.repository import (
|
||||||
DeviceEnrollmentConflictError,
|
DeviceEnrollmentConflictError,
|
||||||
HostEnrollmentConflictError,
|
HostEnrollmentConflictError,
|
||||||
@@ -65,6 +67,7 @@ def create_internal_router(
|
|||||||
lease_duration_seconds: float = 60.0,
|
lease_duration_seconds: float = 60.0,
|
||||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||||
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
|
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
|
||||||
|
planner_provider_service: LlmProviderService | None = None,
|
||||||
scheduler: TaskScheduler | None = None,
|
scheduler: TaskScheduler | None = None,
|
||||||
planner_token_reservation_ceiling: int = 4096,
|
planner_token_reservation_ceiling: int = 4096,
|
||||||
planner_token_reservation_ttl_seconds: float = 300.0,
|
planner_token_reservation_ttl_seconds: float = 300.0,
|
||||||
@@ -78,8 +81,6 @@ def create_internal_router(
|
|||||||
if planner_token_reservation_ttl_seconds <= 0:
|
if planner_token_reservation_ttl_seconds <= 0:
|
||||||
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero")
|
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero")
|
||||||
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
|
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:
|
def authorize_host(request: Request, host_id: str) -> None:
|
||||||
principal = auth_provider.authenticate(request)
|
principal = auth_provider.authenticate(request)
|
||||||
if principal is None:
|
if principal is None:
|
||||||
@@ -395,6 +396,28 @@ def create_internal_router(
|
|||||||
for tool in payload.tools
|
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()
|
now = utc_now()
|
||||||
reservation = None
|
reservation = None
|
||||||
try:
|
try:
|
||||||
@@ -415,7 +438,6 @@ def create_internal_router(
|
|||||||
)
|
)
|
||||||
|
|
||||||
started_at = monotonic()
|
started_at = monotonic()
|
||||||
client = build_planner_client()
|
|
||||||
try:
|
try:
|
||||||
decision = client.decide(
|
decision = client.decide(
|
||||||
system_prompt=payload.system_prompt,
|
system_prompt=payload.system_prompt,
|
||||||
@@ -439,12 +461,11 @@ def create_internal_router(
|
|||||||
)
|
)
|
||||||
usage = decision.usage
|
usage = decision.usage
|
||||||
if reservation is not None and usage is not None and usage.total_tokens is not None:
|
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(
|
pool.store.settle_host_token_reservation(
|
||||||
reservation_id=reservation.id,
|
reservation_id=reservation.id,
|
||||||
event_id=uuid4().hex,
|
event_id=uuid4().hex,
|
||||||
provider=planner_config.provider,
|
provider=resolved_provider,
|
||||||
model=planner_config.resolved_model(),
|
model=resolved_model,
|
||||||
input_tokens=usage.input_tokens,
|
input_tokens=usage.input_tokens,
|
||||||
output_tokens=usage.output_tokens,
|
output_tokens=usage.output_tokens,
|
||||||
total_tokens=usage.total_tokens,
|
total_tokens=usage.total_tokens,
|
||||||
@@ -469,10 +490,6 @@ def create_internal_router(
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _default_planner_client_factory() -> ToolCallingClient:
|
|
||||||
return build_cloud_planner_client(load_cloud_planner_config())
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_assignment_identity(
|
def _validate_assignment_identity(
|
||||||
*,
|
*,
|
||||||
host_id: str,
|
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.
|
"""Construct Cloud planner clients from resolved database Provider profiles."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from cloud.llm_providers import ResolvedLlmProviderProfile
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from runtime.tool_calling_client import (
|
from runtime.tool_calling_client import (
|
||||||
AnthropicToolCallingClient,
|
AnthropicToolCallingClient,
|
||||||
OpenAIToolCallingClient,
|
OpenAIToolCallingClient,
|
||||||
ToolCallingClient,
|
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"
|
def build_cloud_planner_client(
|
||||||
MODEL_ENV = "AI_PLANNER_MODEL"
|
resolved: ResolvedLlmProviderProfile,
|
||||||
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
|
) -> ToolCallingClient:
|
||||||
|
"""Build a provider client without reading Cloud API environment variables."""
|
||||||
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
|
profile = resolved.profile
|
||||||
|
if profile.provider_type == "openai-compatible":
|
||||||
|
return OpenAIToolCallingClient(
|
||||||
@dataclass(frozen=True)
|
model=profile.model,
|
||||||
class CloudPlannerConfig:
|
api_key=resolved.api_key,
|
||||||
provider: str = DEFAULT_PROVIDER
|
base_url=profile.base_url,
|
||||||
model: str = ""
|
)
|
||||||
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
return AnthropicToolCallingClient(model=profile.model, api_key=resolved.api_key)
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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,
|
TokenUsageEvent,
|
||||||
UserSubmissionPolicy,
|
UserSubmissionPolicy,
|
||||||
)
|
)
|
||||||
|
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType
|
||||||
|
|
||||||
|
|
||||||
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
||||||
@@ -341,6 +342,48 @@ class CloudRepository(Protocol):
|
|||||||
self, *, host_id: str, limit: int, offset: int,
|
self, *, host_id: str, limit: int, offset: int,
|
||||||
) -> list[TokenUsageEvent]: ...
|
) -> 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 list_reserved_device_ids(self, *, now: datetime) -> set[str]: ...
|
||||||
|
|
||||||
def assign_task(
|
def assign_task(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
|
|||||||
from cloud.database import create_database_engine, normalize_database_url
|
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):
|
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 datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, SecretStr
|
||||||
|
|
||||||
|
|
||||||
class TaskConstraintsModel(BaseModel):
|
class TaskConstraintsModel(BaseModel):
|
||||||
@@ -212,3 +212,54 @@ class TokenUsageEventResponse(BaseModel):
|
|||||||
output_tokens: int | None = None
|
output_tokens: int | None = None
|
||||||
total_tokens: int
|
total_tokens: int
|
||||||
occurred_at: datetime
|
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,
|
TaskAttemptRow,
|
||||||
AuthAuditRow,
|
AuthAuditRow,
|
||||||
HostGovernancePolicyRow,
|
HostGovernancePolicyRow,
|
||||||
|
LlmProviderProfileRow,
|
||||||
|
LlmProviderSettingsRow,
|
||||||
LoginThrottleRow,
|
LoginThrottleRow,
|
||||||
UserRow,
|
UserRow,
|
||||||
UserSessionRow,
|
UserSessionRow,
|
||||||
@@ -42,6 +44,19 @@ class SQLAlchemyCloudRepository:
|
|||||||
self._sessions = sessionmaker(bind=engine, expire_on_commit=False)
|
self._sessions = sessionmaker(bind=engine, expire_on_commit=False)
|
||||||
if create_schema:
|
if create_schema:
|
||||||
Base.metadata.create_all(engine)
|
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(
|
def enroll_host(
|
||||||
self,
|
self,
|
||||||
@@ -503,7 +518,9 @@ class SQLAlchemyCloudRepository:
|
|||||||
created_at=_iso(user.created_at),
|
created_at=_iso(user.created_at),
|
||||||
updated_at=_iso(user.updated_at),
|
updated_at=_iso(user.updated_at),
|
||||||
last_login_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)
|
session.add(row)
|
||||||
@@ -576,7 +593,9 @@ class SQLAlchemyCloudRepository:
|
|||||||
raise LastAdministratorConflictError(
|
raise LastAdministratorConflictError(
|
||||||
"cannot remove the last enabled administrator"
|
"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:
|
if display_name is not None:
|
||||||
row.display_name = display_name
|
row.display_name = display_name
|
||||||
row.role = next_role
|
row.role = next_role
|
||||||
@@ -764,7 +783,9 @@ class SQLAlchemyCloudRepository:
|
|||||||
session.flush()
|
session.flush()
|
||||||
return _login_throttle_from_row(row)
|
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:
|
with self._sessions.begin() as session:
|
||||||
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
|
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
|
||||||
if row is not None:
|
if row is not None:
|
||||||
@@ -843,7 +864,9 @@ class SQLAlchemyCloudRepository:
|
|||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
if expected_revision not in {None, 0}:
|
if expected_revision not in {None, 0}:
|
||||||
raise GovernancePolicyConflictError("submission policy revision changed")
|
raise GovernancePolicyConflictError(
|
||||||
|
"submission policy revision changed"
|
||||||
|
)
|
||||||
row = UserSubmissionPolicyRow(
|
row = UserSubmissionPolicyRow(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
revision=1,
|
revision=1,
|
||||||
@@ -856,11 +879,10 @@ class SQLAlchemyCloudRepository:
|
|||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
else:
|
else:
|
||||||
if (
|
if expected_revision is not None and expected_revision != row.revision:
|
||||||
expected_revision is not None
|
raise GovernancePolicyConflictError(
|
||||||
and expected_revision != row.revision
|
"submission policy revision changed"
|
||||||
):
|
)
|
||||||
raise GovernancePolicyConflictError("submission policy revision changed")
|
|
||||||
row.revision += 1
|
row.revision += 1
|
||||||
row.submission_enabled = 1 if submission_enabled else 0
|
row.submission_enabled = 1 if submission_enabled else 0
|
||||||
row.allowed_host_ids_json = _dump_optional_list(allowed_host_ids)
|
row.allowed_host_ids_json = _dump_optional_list(allowed_host_ids)
|
||||||
@@ -909,10 +931,7 @@ class SQLAlchemyCloudRepository:
|
|||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
else:
|
else:
|
||||||
if (
|
if expected_revision is not None and expected_revision != row.revision:
|
||||||
expected_revision is not None
|
|
||||||
and expected_revision != row.revision
|
|
||||||
):
|
|
||||||
raise GovernancePolicyConflictError("Host policy revision changed")
|
raise GovernancePolicyConflictError("Host policy revision changed")
|
||||||
row.revision += 1
|
row.revision += 1
|
||||||
row.self_submission_enabled = 1 if self_submission_enabled else 0
|
row.self_submission_enabled = 1 if self_submission_enabled else 0
|
||||||
@@ -922,6 +941,220 @@ class SQLAlchemyCloudRepository:
|
|||||||
session.flush()
|
session.flush()
|
||||||
return _host_governance_policy_from_row(row)
|
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:
|
def count_active_tasks_for_host(self, host_id: str) -> int:
|
||||||
with self._sessions() as session:
|
with self._sessions() as session:
|
||||||
count = session.scalar(
|
count = session.scalar(
|
||||||
@@ -958,24 +1191,36 @@ class SQLAlchemyCloudRepository:
|
|||||||
if policy is None or policy.daily_token_budget is None:
|
if policy is None or policy.daily_token_budget is None:
|
||||||
return None
|
return None
|
||||||
used = session.scalar(
|
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.host_id == host_id,
|
||||||
TokenUsageEventRow.usage_day == usage_day,
|
TokenUsageEventRow.usage_day == usage_day,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
reserved = session.scalar(
|
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.host_id == host_id,
|
||||||
TokenReservationRow.usage_day == usage_day,
|
TokenReservationRow.usage_day == usage_day,
|
||||||
TokenReservationRow.expires_at > _iso(created_at),
|
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")
|
raise TokenBudgetExceededError("Host daily token budget is exhausted")
|
||||||
row = TokenReservationRow(
|
row = TokenReservationRow(
|
||||||
id=reservation_id, host_id=host_id, usage_day=usage_day,
|
id=reservation_id,
|
||||||
reserved_tokens=reserved_tokens, task_id=task_id, attempt=attempt,
|
host_id=host_id,
|
||||||
created_at=_iso(created_at), expires_at=_iso(expires_at),
|
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.add(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
@@ -995,16 +1240,24 @@ class SQLAlchemyCloudRepository:
|
|||||||
) -> Any | None:
|
) -> Any | None:
|
||||||
with self._sessions.begin() as session:
|
with self._sessions.begin() as session:
|
||||||
row = session.get(
|
row = session.get(
|
||||||
TokenReservationRow, reservation_id,
|
TokenReservationRow,
|
||||||
|
reservation_id,
|
||||||
with_for_update=self.engine.dialect.name == "postgresql",
|
with_for_update=self.engine.dialect.name == "postgresql",
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
event = TokenUsageEventRow(
|
event = TokenUsageEventRow(
|
||||||
id=event_id, host_id=row.host_id, usage_day=row.usage_day,
|
id=event_id,
|
||||||
task_id=row.task_id, attempt=row.attempt, provider=provider, model=model,
|
host_id=row.host_id,
|
||||||
input_tokens=input_tokens, output_tokens=output_tokens,
|
usage_day=row.usage_day,
|
||||||
total_tokens=total_tokens, occurred_at=_iso(occurred_at),
|
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.add(event)
|
||||||
session.delete(row)
|
session.delete(row)
|
||||||
@@ -1024,39 +1277,55 @@ class SQLAlchemyCloudRepository:
|
|||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
def get_host_token_usage_summary(
|
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:
|
) -> Any:
|
||||||
from cloud.governance import TokenUsageSummary
|
from cloud.governance import TokenUsageSummary
|
||||||
|
|
||||||
with self._sessions() as session:
|
with self._sessions() as session:
|
||||||
policy = session.get(HostGovernancePolicyRow, host_id)
|
policy = session.get(HostGovernancePolicyRow, host_id)
|
||||||
used = session.scalar(
|
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.host_id == host_id,
|
||||||
TokenUsageEventRow.usage_day == usage_day,
|
TokenUsageEventRow.usage_day == usage_day,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
reserved = session.scalar(
|
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.host_id == host_id,
|
||||||
TokenReservationRow.usage_day == usage_day,
|
TokenReservationRow.usage_day == usage_day,
|
||||||
TokenReservationRow.expires_at > _iso(now),
|
TokenReservationRow.expires_at > _iso(now),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return TokenUsageSummary(
|
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),
|
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(
|
def list_host_token_usage_events(
|
||||||
self, *, host_id: str, limit: int, offset: int,
|
self,
|
||||||
|
*,
|
||||||
|
host_id: str,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
with self._sessions() as session:
|
with self._sessions() as session:
|
||||||
rows = session.scalars(
|
rows = session.scalars(
|
||||||
select(TokenUsageEventRow)
|
select(TokenUsageEventRow)
|
||||||
.where(TokenUsageEventRow.host_id == host_id)
|
.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)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
).all()
|
).all()
|
||||||
@@ -1423,7 +1692,9 @@ def _host_from_row(row: HostRow) -> Any:
|
|||||||
address=row.address,
|
address=row.address,
|
||||||
last_seen_at=_parse_dt(row.last_seen_at) or utc_now(),
|
last_seen_at=_parse_dt(row.last_seen_at) or utc_now(),
|
||||||
planner_transport=(
|
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
|
return None
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(value)
|
parsed = json.loads(value)
|
||||||
except (TypeError, ValueError):
|
except TypeError, ValueError:
|
||||||
parsed = []
|
parsed = []
|
||||||
return tuple(item for item in parsed if isinstance(item, str))
|
return tuple(item for item in parsed if isinstance(item, str))
|
||||||
|
|
||||||
@@ -1531,7 +1802,7 @@ def _load_optional_device_targets(
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
parsed = json.loads(value)
|
parsed = json.loads(value)
|
||||||
except (TypeError, ValueError):
|
except TypeError, ValueError:
|
||||||
parsed = []
|
parsed = []
|
||||||
return tuple(
|
return tuple(
|
||||||
(item[0], item[1])
|
(item[0], item[1])
|
||||||
@@ -1574,8 +1845,12 @@ def _token_reservation_from_row(row: TokenReservationRow) -> Any:
|
|||||||
from cloud.governance import TokenReservation
|
from cloud.governance import TokenReservation
|
||||||
|
|
||||||
return TokenReservation(
|
return TokenReservation(
|
||||||
id=row.id, host_id=row.host_id, usage_day=row.usage_day,
|
id=row.id,
|
||||||
reserved_tokens=row.reserved_tokens, task_id=row.task_id, attempt=row.attempt,
|
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(),
|
created_at=_parse_dt(row.created_at) or utc_now(),
|
||||||
expires_at=_parse_dt(row.expires_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
|
from cloud.governance import TokenUsageEvent
|
||||||
|
|
||||||
return TokenUsageEvent(
|
return TokenUsageEvent(
|
||||||
id=row.id, host_id=row.host_id, usage_day=row.usage_day,
|
id=row.id,
|
||||||
task_id=row.task_id, attempt=row.attempt, provider=row.provider, model=row.model,
|
host_id=row.host_id,
|
||||||
input_tokens=row.input_tokens, output_tokens=row.output_tokens,
|
usage_day=row.usage_day,
|
||||||
total_tokens=row.total_tokens, occurred_at=_parse_dt(row.occurred_at) or utc_now(),
|
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),
|
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 = [
|
dependencies = [
|
||||||
"alembic>=1.14.0",
|
"alembic>=1.14.0",
|
||||||
"argon2-cffi>=25.0.0",
|
"argon2-cffi>=25.0.0",
|
||||||
|
"cryptography>=45.0.0",
|
||||||
"device-agent-runtime==0.1.0",
|
"device-agent-runtime==0.1.0",
|
||||||
"psycopg[binary]>=3.2.0",
|
"psycopg[binary]>=3.2.0",
|
||||||
"sqlalchemy>=2.0.0",
|
"sqlalchemy>=2.0.0",
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ class AnthropicToolCallingClient:
|
|||||||
model: str,
|
model: str,
|
||||||
transport: Any | None = None,
|
transport: Any | None = None,
|
||||||
max_tokens: int = 1024,
|
max_tokens: int = 1024,
|
||||||
|
api_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.model = model
|
self.model = model
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self.max_tokens = max_tokens
|
self.max_tokens = max_tokens
|
||||||
|
self._api_key = api_key
|
||||||
|
|
||||||
def decide(
|
def decide(
|
||||||
self,
|
self,
|
||||||
@@ -118,7 +120,11 @@ class AnthropicToolCallingClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ToolCallUnavailable("anthropic SDK is unavailable") from 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
|
return self._transport
|
||||||
|
|
||||||
|
|
||||||
@@ -129,10 +135,14 @@ class OpenAIToolCallingClient:
|
|||||||
model: str,
|
model: str,
|
||||||
transport: Any | None = None,
|
transport: Any | None = None,
|
||||||
max_tokens: int = 1024,
|
max_tokens: int = 1024,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.model = model
|
self.model = model
|
||||||
self._transport = transport
|
self._transport = transport
|
||||||
self.max_tokens = max_tokens
|
self.max_tokens = max_tokens
|
||||||
|
self._api_key = api_key
|
||||||
|
self._base_url = base_url
|
||||||
|
|
||||||
def decide(
|
def decide(
|
||||||
self,
|
self,
|
||||||
@@ -193,7 +203,12 @@ class OpenAIToolCallingClient:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ToolCallUnavailable("openai SDK is unavailable") from 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
|
return self._transport
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,17 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None:
|
|||||||
"cloud_auth_audit_events",
|
"cloud_auth_audit_events",
|
||||||
"cloud_token_reservations",
|
"cloud_token_reservations",
|
||||||
"cloud_token_usage_events",
|
"cloud_token_usage_events",
|
||||||
|
"cloud_llm_provider_profiles",
|
||||||
|
"cloud_llm_provider_settings",
|
||||||
} <= table_names
|
} <= table_names
|
||||||
assert current_revision(database_url) == HEAD_REVISION
|
assert current_revision(database_url) == HEAD_REVISION
|
||||||
|
assert (
|
||||||
|
connection_scalar(
|
||||||
|
engine,
|
||||||
|
"select revision from cloud_llm_provider_settings where id = 'global'",
|
||||||
|
)
|
||||||
|
== 0
|
||||||
|
)
|
||||||
host_columns = {
|
host_columns = {
|
||||||
column["name"]
|
column["name"]
|
||||||
for column in inspect(engine).get_columns("host_registrations")
|
for column in inspect(engine).get_columns("host_registrations")
|
||||||
|
|||||||
@@ -545,6 +545,7 @@ source = { editable = "packages/cloud-platform" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "device-agent-runtime" },
|
{ name = "device-agent-runtime" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
@@ -554,6 +555,7 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "alembic", specifier = ">=1.14.0" },
|
{ name = "alembic", specifier = ">=1.14.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=25.0.0" },
|
{ name = "argon2-cffi", specifier = ">=25.0.0" },
|
||||||
|
{ name = "cryptography", specifier = ">=45.0.0" },
|
||||||
{ name = "device-agent-runtime", editable = "." },
|
{ name = "device-agent-runtime", editable = "." },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
{ name = "sqlalchemy", specifier = ">=2.0.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user