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

This commit is contained in:
2026-07-14 00:31:47 +08:00
parent b613a315ff
commit a166ffd8a4
35 changed files with 2432 additions and 204 deletions
+18
View File
@@ -31,6 +31,7 @@ from cloud.control_config import (
)
from cloud.database import CloudDatabase
from cloud.internal_api.api import create_internal_router
from cloud.llm_providers import LlmProviderService
from cloud.plugins import PluginRegistry
from cloud.observability import (
CORRELATION_HEADER,
@@ -44,6 +45,7 @@ from cloud.scheduler import TaskScheduler
from cloud.schema import require_current_schema
from cloud.sdk.api import create_cloud_router
from cloud.sdk.governance_api import create_governance_router
from cloud.sdk.llm_provider_api import create_llm_provider_router
from cloud.sdk.user_api import create_user_auth_router
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
from core.models import utc_now
@@ -77,6 +79,7 @@ class CloudApplicationServices:
plugin_registry: PluginRegistry
auth_provider: Any
user_auth_service: UserAuthService
llm_provider_service: LlmProviderService
class SpaStaticFiles(StaticFiles):
@@ -126,6 +129,7 @@ def create_app(
cookie_secure=control_config.session_cookie_secure,
),
)
llm_provider_service = LlmProviderService(repository)
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
@@ -146,6 +150,7 @@ def create_app(
plugin_registry=plugin_registry,
auth_provider=auth_provider,
user_auth_service=user_auth_service,
llm_provider_service=llm_provider_service,
)
@asynccontextmanager
@@ -295,6 +300,18 @@ def create_app(
auth_provider=auth_provider,
)
)
app.include_router(
create_llm_provider_router(
service=llm_provider_service,
repository=repository,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_internal_router(
pool=pool,
@@ -307,6 +324,7 @@ def create_app(
planner_token_reservation_ttl_seconds=(
control_config.planner_token_reservation_ttl_seconds
),
planner_provider_service=llm_provider_service,
)
)
@@ -1,80 +1,51 @@
from __future__ import annotations
from cloud.planner_config import (
DEFAULT_MODEL_BY_PROVIDER,
DEFAULT_PROVIDER,
DEFAULT_TIMEOUT_SECONDS,
CloudPlannerConfig,
build_cloud_planner_client,
load_cloud_planner_config,
)
from datetime import UTC, datetime
from cloud.llm_providers import LlmProviderProfile, ResolvedLlmProviderProfile
from cloud.planner_config import build_cloud_planner_client
from runtime.tool_calling_client import (
AnthropicToolCallingClient,
OpenAIToolCallingClient,
)
_NO_RELEVANT_VARS = {"UNRELATED": "1"}
def test_load_cloud_planner_config_defaults_when_unset() -> None:
config = load_cloud_planner_config(_NO_RELEVANT_VARS)
assert config == CloudPlannerConfig(
provider=DEFAULT_PROVIDER,
model="",
timeout=DEFAULT_TIMEOUT_SECONDS,
def _resolved_profile(
provider_type: str, *, base_url: str | None = None
) -> ResolvedLlmProviderProfile:
now = datetime.now(UTC)
profile = LlmProviderProfile(
id="provider-1",
name="Managed provider",
name_normalized="managed provider",
provider_type=provider_type, # type: ignore[arg-type]
model="test-model",
base_url=base_url,
timeout_seconds=15,
api_key_ciphertext="ciphertext",
key_last_rotated_at=now,
enabled=True,
revision=1,
created_at=now,
updated_at=now,
)
assert config.resolved_model() == DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER]
return ResolvedLlmProviderProfile(profile=profile, api_key="managed-api-key")
def test_load_cloud_planner_config_selects_provider_and_resolves_default_model() -> (
None
):
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "openai"})
assert config.provider == "openai"
assert config.resolved_model() == "gpt-5.6"
def test_load_cloud_planner_config_falls_back_to_default_provider_when_unsupported() -> (
None
):
config = load_cloud_planner_config({"AI_PLANNER_PROVIDER": "not-a-real-provider"})
assert config.provider == DEFAULT_PROVIDER
def test_load_cloud_planner_config_model_override_wins_regardless_of_provider() -> None:
config = load_cloud_planner_config(
{"AI_PLANNER_PROVIDER": "openai", "AI_PLANNER_MODEL": "custom-model"}
)
assert config.resolved_model() == "custom-model"
def test_load_cloud_planner_config_parses_valid_timeout() -> None:
config = load_cloud_planner_config({"AI_PLANNER_TIMEOUT_SECONDS": "12.5"})
assert config.timeout == 12.5
def test_load_cloud_planner_config_falls_back_to_default_timeout_when_invalid() -> None:
for value in ["not-a-number", "0", "-5"]:
config = load_cloud_planner_config(
{"AI_PLANNER_TIMEOUT_SECONDS": value, **_NO_RELEVANT_VARS}
)
assert config.timeout == DEFAULT_TIMEOUT_SECONDS
def test_build_cloud_planner_client_selects_anthropic_by_default() -> None:
client = build_cloud_planner_client(CloudPlannerConfig())
def test_build_cloud_planner_client_uses_managed_anthropic_key() -> None:
client = build_cloud_planner_client(_resolved_profile("anthropic"))
assert isinstance(client, AnthropicToolCallingClient)
assert client.model == DEFAULT_MODEL_BY_PROVIDER["anthropic"]
assert client.model == "test-model"
assert client._api_key == "managed-api-key"
def test_build_cloud_planner_client_selects_openai() -> None:
client = build_cloud_planner_client(CloudPlannerConfig(provider="openai"))
def test_build_cloud_planner_client_uses_openai_compatible_base_url() -> None:
client = build_cloud_planner_client(
_resolved_profile("openai-compatible", base_url="https://compat.example/v1")
)
assert isinstance(client, OpenAIToolCallingClient)
assert client.model == DEFAULT_MODEL_BY_PROVIDER["openai"]
assert client.model == "test-model"
assert client._api_key == "managed-api-key"
assert client._base_url == "https://compat.example/v1"
@@ -0,0 +1,151 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from cloud.control_config import CloudControlConfig
from cloud_api.app import create_app
def _create_admin(client: TestClient) -> None:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
response = client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 200
def _csrf_headers(client: TestClient) -> dict[str, str]:
token = client.cookies.get("amcp_csrf")
assert token is not None
return {"X-CSRF-Token": token}
def _profile_payload(**overrides: object) -> dict[str, object]:
payload = {
"name": "OpenAI Compatible",
"provider_type": "openai-compatible",
"model": "gpt-compatible",
"base_url": "https://compat.example/v1",
"timeout_seconds": 20,
"api_key": "provider-secret-value",
}
payload.update(overrides)
return payload
def test_provider_profiles_are_encrypted_redacted_and_activated(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
headers = _csrf_headers(client)
missing_csrf = client.post("/v1/planner/providers", json=_profile_payload())
assert missing_csrf.status_code == 403
empty_key = client.post(
"/v1/planner/providers",
headers=headers,
json=_profile_payload(api_key=""),
)
assert empty_key.status_code == 422
created = client.post(
"/v1/planner/providers", headers=headers, json=_profile_payload()
)
assert created.status_code == 201, created.text
profile = created.json()
assert profile["has_api_key"] is True
assert "api_key" not in profile
assert "ciphertext" not in profile
repository = client.app.state.cloud_services.repository
stored = repository.get_llm_provider_profile(profile["id"])
assert stored is not None
assert stored.api_key_ciphertext != "provider-secret-value"
assert "provider-secret-value" not in stored.api_key_ciphertext
listed = client.get("/v1/planner/providers")
assert listed.status_code == 200
assert listed.json()["settings"]["active_profile_id"] is None
assert "provider-secret-value" not in listed.text
assert stored.api_key_ciphertext not in listed.text
activated = client.post(
f"/v1/planner/providers/{profile['id']}/activate",
headers=headers,
json={"expected_settings_revision": 0},
)
assert activated.status_code == 200, activated.text
assert activated.json()["active_profile_id"] == profile["id"]
disable_active = client.patch(
f"/v1/planner/providers/{profile['id']}",
headers=headers,
json={"enabled": False, "expected_revision": profile["revision"]},
)
assert disable_active.status_code == 409
def test_provider_profile_requires_admin_scope(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
service = client.app.state.cloud_services.user_auth_service
service.create_user(
username="operator",
display_name="Operator",
role="operator",
password="correct-horse-battery-staple",
must_change_password=False,
)
assert (
client.post(
"/v1/auth/login",
json={
"username": "operator",
"password": "correct-horse-battery-staple",
},
).status_code
== 200
)
assert client.get("/v1/planner/providers").status_code == 403
assert (
client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(),
).status_code
== 403
)
def test_profile_write_requires_encryption_key(monkeypatch) -> None:
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
_create_admin(client)
response = client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(),
)
assert response.status_code == 503
assert "provider-secret-value" not in response.text
@@ -0,0 +1,155 @@
from __future__ import annotations
from fastapi.testclient import TestClient
import cloud.internal_api.api as internal_api
from cloud.control_config import CloudControlConfig
from runtime.tool_calling_client import ToolCallDecision
from cloud_api.app import create_app
class _FakePlannerClient:
def __init__(self) -> None:
self.calls = 0
def decide(self, **_kwargs) -> ToolCallDecision:
self.calls += 1
return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
def _login_admin(client: TestClient) -> dict[str, str]:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
assert (
client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
).status_code
== 200
)
csrf = client.cookies.get("amcp_csrf")
assert csrf is not None
return {"X-CSRF-Token": csrf}
def _create_profile(
client: TestClient, headers: dict[str, str], *, name: str, model: str
) -> dict:
response = client.post(
"/v1/planner/providers",
headers=headers,
json={
"name": name,
"provider_type": "openai-compatible",
"model": model,
"base_url": "https://compat.example/v1",
"timeout_seconds": 30,
"api_key": f"key-for-{name}",
},
)
assert response.status_code == 201, response.text
return response.json()
def _enroll_host(client: TestClient) -> tuple[str, dict[str, str]]:
enrollment = client.post(
"/internal/v1/enrollments",
json={"agent_instance_id": "agent-a", "host_token": "host-token-" + ("a" * 40)},
)
assert enrollment.status_code == 201, enrollment.text
return enrollment.json()["host_id"], {
"Authorization": "Bearer host-token-" + ("a" * 40)
}
def _decision_payload(host_id: str) -> dict:
return {
"host_id": host_id,
"system_prompt": "system",
"user_prompt": "user",
"tools": [{"name": "tap", "description": "tap", "parameters": {}}],
"timeout_seconds": 10,
}
def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
from cryptography.fernet import Fernet
monkeypatch.setenv(
"CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", Fernet.generate_key().decode()
)
resolved_profiles = []
fake = _FakePlannerClient()
def build(resolved):
resolved_profiles.append(resolved)
return fake
monkeypatch.setattr(internal_api, "build_cloud_planner_client", build)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
admin_headers = _login_admin(client)
first = _create_profile(
client, admin_headers, name="First", model="first-model"
)
assert (
client.post(
f"/v1/planner/providers/{first['id']}/activate",
headers=admin_headers,
json={"expected_settings_revision": 0},
).status_code
== 200
)
host_id, host_headers = _enroll_host(client)
first_decision = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert first_decision.status_code == 200, first_decision.text
assert resolved_profiles[-1].profile.model == "first-model"
assert resolved_profiles[-1].api_key == "key-for-First"
second = _create_profile(
client, admin_headers, name="Second", model="second-model"
)
settings = client.get("/v1/planner/providers").json()["settings"]
activated = client.post(
f"/v1/planner/providers/{second['id']}/activate",
headers=admin_headers,
json={"expected_settings_revision": settings["revision"]},
)
assert activated.status_code == 200, activated.text
second_decision = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert second_decision.status_code == 200, second_decision.text
assert resolved_profiles[-1].profile.model == "second-model"
assert fake.calls == 2
def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None:
monkeypatch.delenv("CLOUD_LLM_PROVIDER_ENCRYPTION_KEY", raising=False)
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
with TestClient(app) as client:
host_id, host_headers = _enroll_host(client)
response = client.post(
f"/internal/v1/hosts/{host_id}/planner/decide",
headers=host_headers,
json=_decision_payload(host_id),
)
assert response.status_code == 502
assert response.json() == {
"code": "planner_unavailable",
"detail": "no active database Provider profile",
}
+11 -1
View File
@@ -7,6 +7,7 @@ import {
LogOut,
MonitorSmartphone,
Puzzle,
SlidersHorizontal,
UsersRound,
} from "@lucide/vue";
import {
@@ -14,6 +15,7 @@ import {
getCurrentUser,
logout,
} from "./api";
import { hasScope } from "./permissions";
import type { CloudUser } from "./types";
import LoginScreen from "./views/LoginScreen.vue";
import PasswordChangeScreen from "./views/PasswordChangeScreen.vue";
@@ -21,8 +23,9 @@ import TasksView from "./views/TasksView.vue";
import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
import UsersView from "./views/UsersView.vue";
import LlmProvidersView from "./views/LlmProvidersView.vue";
type ViewId = "tasks" | "devices" | "plugins" | "users";
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
const activeView = ref<ViewId>("tasks");
const currentUser = ref<CloudUser | null>(null);
@@ -49,6 +52,7 @@ const canAdminGovernance = computed(
currentUser.value?.scopes.includes("*") ||
currentUser.value?.scopes.includes("governance:admin")),
);
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
const isAuthenticated = computed(() => currentUser.value !== null);
const currentUserLabel = computed(() =>
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
@@ -63,6 +67,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
if (canAdminUsers.value || canAdminGovernance.value) {
items.push({ id: "users", label: "Users & limits", icon: UsersRound });
}
if (canAdminProviders.value) {
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
}
return items;
});
@@ -119,6 +126,8 @@ const activeComponent = computed(() => {
return PluginsView;
case "users":
return UsersView;
case "providers":
return LlmProvidersView;
default:
return TasksView;
}
@@ -155,6 +164,7 @@ const activeComponent = computed(() => {
:can-admin-users="canAdminUsers"
:can-admin-governance="canAdminGovernance"
/>
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
</main>
</div>
+39
View File
@@ -3,6 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_INVALID_EVENT,
createLlmProviderProfile,
listDevices,
login,
registerPlugin,
@@ -74,6 +75,44 @@ describe("Cloud Console API authentication", () => {
);
});
it("sends a Provider profile write through the CSRF-protected management API", async () => {
document.cookie = "amcp_csrf=csrf-value; path=/";
vi.mocked(fetch).mockResolvedValueOnce(
response({
id: "provider-a",
name: "OpenAI compatible",
provider_type: "openai-compatible",
model: "model-a",
base_url: "https://compat.example/v1",
timeout_seconds: 30,
enabled: true,
revision: 1,
has_api_key: true,
key_last_rotated_at: "2026-01-01T00:00:00+00:00",
created_at: "2026-01-01T00:00:00+00:00",
updated_at: "2026-01-01T00:00:00+00:00",
active: false,
}),
);
await createLlmProviderProfile({
name: "OpenAI compatible",
provider_type: "openai-compatible",
model: "model-a",
base_url: "https://compat.example/v1",
timeout_seconds: 30,
api_key: "secret-value",
});
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/v1\/planner\/providers$/),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ "X-CSRF-Token": "csrf-value" }),
}),
);
});
it("invalidates the session only on 401", async () => {
const invalidated = vi.fn();
window.addEventListener(AUTH_INVALID_EVENT, invalidated);
+64
View File
@@ -3,6 +3,10 @@ import type {
DeviceRecord,
HostGovernancePolicy,
HostTokenUsageSummary,
LlmProviderProfile,
LlmProviderProfileListResponse,
LlmProviderSettings,
LlmProviderType,
HostRecord,
PluginRecord,
PluginRegistrationPayload,
@@ -253,3 +257,63 @@ export function registerPlugin(payload: PluginRegistrationPayload): Promise<Plug
body: JSON.stringify(payload),
});
}
export function listLlmProviderProfiles(): Promise<LlmProviderProfileListResponse> {
return request<LlmProviderProfileListResponse>("/v1/planner/providers");
}
export function createLlmProviderProfile(payload: {
name: string;
provider_type: LlmProviderType;
model: string;
base_url?: string | null;
timeout_seconds: number;
api_key: string;
}): Promise<LlmProviderProfile> {
return request<LlmProviderProfile>("/v1/planner/providers", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateLlmProviderProfile(
profileId: string,
payload: {
name?: string;
provider_type?: LlmProviderType;
model?: string;
base_url?: string | null;
timeout_seconds?: number;
enabled?: boolean;
api_key?: string;
expected_revision?: number;
},
): Promise<LlmProviderProfile> {
return request<LlmProviderProfile>(`/v1/planner/providers/${encodeURIComponent(profileId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function activateLlmProviderProfile(
profileId: string,
expectedSettingsRevision: number,
): Promise<LlmProviderSettings> {
return request<LlmProviderSettings>(
`/v1/planner/providers/${encodeURIComponent(profileId)}/activate`,
{
method: "POST",
body: JSON.stringify({ expected_settings_revision: expectedSettingsRevision }),
},
);
}
export function deleteLlmProviderProfile(
profileId: string,
expectedRevision: number,
): Promise<void> {
return request<void>(
`/v1/planner/providers/${encodeURIComponent(profileId)}?expected_revision=${expectedRevision}`,
{ method: "DELETE" },
);
}
+24
View File
@@ -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);
});
});
+5
View File
@@ -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));
}
+21
View File
@@ -65,6 +65,20 @@ button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
button.icon-button {
width: 30px;
height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
button.danger:hover:not(:disabled) {
background: var(--danger-bg);
border-color: var(--danger);
}
input,
select,
textarea {
@@ -356,6 +370,13 @@ tr.row-selected {
color: var(--text-muted);
}
.provider-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
white-space: nowrap;
}
.loader {
display: inline-block;
animation: spin 1.2s linear infinite;
+29
View File
@@ -149,3 +149,32 @@ export interface TokenUsageEvent {
total_tokens: number;
occurred_at: string;
}
export type LlmProviderType = "anthropic" | "openai-compatible";
export interface LlmProviderProfile {
id: string;
name: string;
provider_type: LlmProviderType;
model: string;
base_url: string | null;
timeout_seconds: number;
enabled: boolean;
revision: number;
has_api_key: boolean;
key_last_rotated_at: string;
created_at: string;
updated_at: string;
active: boolean;
}
export interface LlmProviderSettings {
active_profile_id: string | null;
revision: number;
updated_at: string | null;
}
export interface LlmProviderProfileListResponse {
settings: LlmProviderSettings;
items: LlmProviderProfile[];
}
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { Check, LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
import {
activateLlmProviderProfile,
createLlmProviderProfile,
deleteLlmProviderProfile,
listLlmProviderProfiles,
updateLlmProviderProfile,
} from "../api";
import type { LlmProviderProfile, LlmProviderSettings, LlmProviderType } from "../types";
defineProps<{ canAdmin: boolean }>();
const profiles = ref<LlmProviderProfile[]>([]);
const settings = ref<LlmProviderSettings>({ active_profile_id: null, revision: 0, updated_at: null });
const loading = ref(false);
const saving = ref(false);
const editingId = ref<string | null>(null);
const formError = ref("");
const successMessage = ref("");
const errorMessage = ref("");
const form = reactive({
name: "",
provider_type: "openai-compatible" as LlmProviderType,
model: "",
base_url: "",
timeout_seconds: "30",
api_key: "",
enabled: true,
});
const editingProfile = computed(
() => profiles.value.find((profile) => profile.id === editingId.value) ?? null,
);
function resetForm() {
editingId.value = null;
form.name = "";
form.provider_type = "openai-compatible";
form.model = "";
form.base_url = "";
form.timeout_seconds = "30";
form.api_key = "";
form.enabled = true;
formError.value = "";
}
function showError(error: unknown, fallback: string) {
successMessage.value = "";
errorMessage.value = error instanceof Error ? error.message : fallback;
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
const response = await listLlmProviderProfiles();
profiles.value = response.items;
settings.value = response.settings;
} catch (error) {
showError(error, "failed to load LLM providers");
} finally {
loading.value = false;
}
}
function editProfile(profile: LlmProviderProfile) {
editingId.value = profile.id;
form.name = profile.name;
form.provider_type = profile.provider_type;
form.model = profile.model;
form.base_url = profile.base_url ?? "";
form.timeout_seconds = String(profile.timeout_seconds);
form.api_key = "";
form.enabled = profile.enabled;
formError.value = "";
successMessage.value = "";
}
function parseTimeout(): number | null {
const timeout = Number(form.timeout_seconds);
return Number.isFinite(timeout) && timeout > 0 && timeout <= 120 ? timeout : null;
}
async function saveProfile() {
formError.value = "";
successMessage.value = "";
const timeout = parseTimeout();
if (!form.name.trim() || !form.model.trim() || timeout === null) {
formError.value = "name, model, and a timeout between 0 and 120 are required";
return;
}
if (!editingId.value && !form.api_key) {
formError.value = "an API key is required for a new Provider profile";
return;
}
saving.value = true;
try {
const baseUrl = form.provider_type === "anthropic" ? null : form.base_url.trim() || null;
if (editingId.value) {
const existing = profiles.value.find((profile) => profile.id === editingId.value);
if (!existing) throw new Error("Provider profile no longer exists");
const payload: Parameters<typeof updateLlmProviderProfile>[1] = {
name: form.name.trim(),
provider_type: form.provider_type,
model: form.model.trim(),
base_url: baseUrl,
timeout_seconds: timeout,
enabled: form.enabled,
expected_revision: existing.revision,
};
if (form.api_key) payload.api_key = form.api_key;
await updateLlmProviderProfile(editingId.value, payload);
successMessage.value = "Provider profile saved";
} else {
await createLlmProviderProfile({
name: form.name.trim(),
provider_type: form.provider_type,
model: form.model.trim(),
base_url: baseUrl,
timeout_seconds: timeout,
api_key: form.api_key,
});
successMessage.value = "Provider profile created";
}
resetForm();
await refresh();
} catch (error) {
formError.value = error instanceof Error ? error.message : "failed to save Provider profile";
} finally {
form.api_key = "";
saving.value = false;
}
}
async function activate(profile: LlmProviderProfile) {
saving.value = true;
errorMessage.value = "";
try {
settings.value = await activateLlmProviderProfile(profile.id, settings.value.revision);
successMessage.value = `${profile.name} is active for Cloud planner requests`;
await refresh();
} catch (error) {
showError(error, "failed to activate Provider profile");
} finally {
saving.value = false;
}
}
async function remove(profile: LlmProviderProfile) {
if (!window.confirm(`Delete ${profile.name}?`)) return;
saving.value = true;
errorMessage.value = "";
try {
await deleteLlmProviderProfile(profile.id, profile.revision);
successMessage.value = "Provider profile deleted";
if (editingId.value === profile.id) resetForm();
await refresh();
} catch (error) {
showError(error, "failed to delete Provider profile");
} finally {
saving.value = false;
}
}
onMounted(refresh);
</script>
<template>
<div>
<div class="toolbar">
<h2>LLM providers</h2>
<button title="Refresh providers" aria-label="Refresh providers" :disabled="loading" @click="refresh">
<RefreshCw :size="14" />
</button>
<button class="primary" @click="resetForm"><Plus :size="14" /> New provider</button>
<span v-if="loading" class="muted"><LoaderCircle :size="14" class="loader" /> loading</span>
</div>
<div v-if="errorMessage" class="notice error">{{ errorMessage }}</div>
<div v-if="successMessage" class="notice success">{{ successMessage }}</div>
<div class="panel">
<h3>{{ editingId ? "Edit Provider" : "New Provider" }}</h3>
<form @submit.prevent="saveProfile">
<div class="form-grid">
<label>Name <input v-model="form.name" autocomplete="off" /></label>
<label>Protocol
<select v-model="form.provider_type">
<option value="openai-compatible">OpenAI-compatible</option>
<option value="anthropic">Anthropic</option>
</select>
</label>
<label>Model <input v-model="form.model" autocomplete="off" /></label>
<label>Timeout seconds <input v-model="form.timeout_seconds" inputmode="decimal" /></label>
<label v-if="form.provider_type === 'openai-compatible'" class="field-full">Base URL
<input v-model="form.base_url" placeholder="Official OpenAI endpoint when blank" autocomplete="url" />
</label>
<label class="field-full">{{ editingId ? "Rotate API key" : "API key" }}
<input v-model="form.api_key" type="password" autocomplete="new-password" />
</label>
<label v-if="editingId"><input v-model="form.enabled" type="checkbox" :disabled="editingProfile?.active" /> Enabled</label>
</div>
<div v-if="formError" class="notice error" style="margin-top: 12px">{{ formError }}</div>
<div class="toolbar" style="margin-top: 12px">
<button type="submit" class="primary" :disabled="saving">{{ editingId ? "Save Provider" : "Create Provider" }}</button>
<button v-if="editingId" type="button" title="Cancel edit" aria-label="Cancel edit" @click="resetForm"><X :size="14" /></button>
</div>
</form>
</div>
<div class="panel">
<table v-if="profiles.length">
<thead><tr><th>Name</th><th>Protocol / model</th><th>Endpoint</th><th>State</th><th>Key rotation</th><th></th></tr></thead>
<tbody>
<tr v-for="profile in profiles" :key="profile.id">
<td><strong>{{ profile.name }}</strong></td>
<td><span class="status-badge queued">{{ profile.provider_type }}</span><br /><span class="dim">{{ profile.model }}</span></td>
<td class="dim">{{ profile.base_url ?? "Official endpoint" }}</td>
<td><span :class="profile.active ? 'status-badge done' : profile.enabled ? 'status-badge idle' : 'status-badge failed'">{{ profile.active ? "active" : profile.enabled ? "ready" : "disabled" }}</span></td>
<td class="dim">{{ new Date(profile.key_last_rotated_at).toLocaleString() }}</td>
<td class="provider-actions">
<button v-if="!profile.active" class="icon-button" title="Activate Provider" :aria-label="`Activate ${profile.name}`" :disabled="saving || !profile.enabled" @click="activate(profile)"><Check :size="14" /></button>
<button class="icon-button" title="Edit Provider" :aria-label="`Edit ${profile.name}`" :disabled="saving" @click="editProfile(profile)"><Pencil :size="14" /></button>
<button class="icon-button danger" title="Delete Provider" :aria-label="`Delete ${profile.name}`" :disabled="saving || profile.active" @click="remove(profile)"><Trash2 :size="14" /></button>
</td>
</tr>
</tbody>
</table>
<p v-else class="muted">No Provider profiles configured.</p>
</div>
</div>
</template>
+26 -9
View File
@@ -379,10 +379,22 @@ planning decision through the Cloud API's
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
mode:
- **Credentials move to the Cloud API.** Configure `AI_PLANNER_PROVIDER`,
`AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`, and
`ANTHROPIC_API_KEY`/`OPENAI_API_KEY` on the Cloud API process instead of the
Host Agent -- edge hosts no longer need provider keys at all.
- **Provider configuration lives in the Cloud database.** Set
`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY` from the deployment secret manager, then
sign in to `/console/` as an administrator and create an active entry under
**LLM providers**. Provider API keys are encrypted in the database and are
never returned by the API or Console. Edge Hosts do not hold Provider keys.
The Cloud API does not read `AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`,
`AI_PLANNER_TIMEOUT_SECONDS`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`.
- **Profile types:** choose **Anthropic** for native Anthropic tool use, or
**OpenAI-compatible** for the OpenAI Chat Completions tool-calling protocol.
Leave its Base URL blank for official OpenAI, or provide the compatible
provider's absolute HTTP(S) `/v1` endpoint. Providers requiring another
request schema or custom authentication are not supported by this path.
- **Activation is immediate:** a newly activated enabled profile becomes the
Provider/model for the next Cloud-proxy planner decision. A Cloud-planner
request fails closed until one enabled profile is active; it never falls back
to a Cloud API environment credential.
- **Trade-offs to accept before enabling:**
- *Latency*: every planning step now makes a round trip to the Cloud API in
addition to the LLM provider call.
@@ -420,11 +432,16 @@ Hosts reporting `AI_PLANNER_TRANSPORT=direct` are explicitly shown as
**unmetered**. Cloud cannot enforce or verify their provider token use. Do not
interpret an unmetered Host's absence of usage events as budget compliance.
Roll out in this order: migrate the Cloud database, deploy the Cloud API,
switch a pilot Host to Cloud transport, configure a budget above the reservation
ceiling, then review its usage events before enabling budgets fleet-wide. A
rollback to direct transport requires valid provider credentials on that Host;
preserve usage and policy rows rather than deleting accounting history.
Roll out in this order: migrate the Cloud database, provision
`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY`, deploy the Cloud API, create and activate
a Provider profile in `/console/`, then switch a pilot Host to Cloud transport.
Configure a budget above the reservation ceiling and review usage events before
enabling budgets fleet-wide. Rotate a Provider API key by editing that profile;
the encryption master key is deployment-managed and must be preserved with the
database backups. A rollback to an older Cloud API requires restoring its
legacy Provider environment configuration, while a rollback to `direct`
transport requires valid provider credentials on that Host; preserve usage and
policy rows rather than deleting accounting history.
## Operational Limitations
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-13
@@ -0,0 +1,178 @@
## Context
`cloud-planner-proxy` centralizes an LLM call for Hosts that opt into
`AI_PLANNER_TRANSPORT=cloud`, but `cloud.planner_config` currently resolves
provider, model, and credentials from Cloud API environment variables. The
proxy route builds a client for every decision request, so it already has a
natural per-request configuration boundary. The Cloud Control Plane also has a
durable SQLAlchemy repository, Alembic migrations, session-authenticated
administrator Console, CSRF protection, and auth audit records that this
change can reuse.
The existing runtime clients instantiate provider SDKs with no arguments and
therefore read keys from their environment. Database-backed credentials and
an OpenAI-compatible base URL require those clients to accept explicit values
while preserving the unchanged direct-transport default.
## Goals / Non-Goals
**Goals:**
- Let administrators manage a Cloud-wide catalog of `anthropic` and
`openai-compatible` planner profiles from the Cloud Console.
- Encrypt every stored Provider API key and never return or log its plaintext.
- Switch the provider/model used by all cloud-transport Host Agents without a
Host configuration change, Cloud API restart, or local credential.
- Support OpenAI-compatible endpoints through a configurable base URL and
API key using the existing Chat Completions tool-calling implementation.
- Remove Cloud API environment-based planner Provider configuration so every
cloud-transport decision is governed by the active database profile.
**Non-Goals:**
- Per-Host, per-user, per-task, weighted, or failover Provider selection.
- Provider usage billing, connection-test endpoints, model discovery, or
support for protocols other than Anthropic native tool use and OpenAI Chat
Completions tool calling.
- Arbitrary Anthropic-compatible endpoints, custom request headers, or
different OpenAI-compatible parameter dialects.
- Automatic encryption-master-key rotation. Provider API key rotation is
supported by updating a profile; master-key rotation remains an operational
migration until a key-ring design is separately proposed.
## Decisions
### D1: Persist a profile catalog and a singleton active-profile setting
Add `cloud_llm_provider_profiles` with immutable id, unique normalized name,
provider type, model, optional base URL, timeout, encrypted API key, enabled
state, revision, and timestamps. Add a one-row
`cloud_llm_provider_settings` record with `database_management_enabled`,
`active_profile_id`, revision, and timestamp. The profile table represents
the operator's choices; the singleton holds the one Cloud-wide choice that is
active.
Activating an enabled profile updates the singleton in the same transaction
and sets `database_management_enabled=true`. An active profile cannot be
disabled or deleted before another enabled profile is activated. This avoids
the ambiguity and cross-database portability problems of a partial unique
"one active row" index, and gives PostgreSQL and SQLite one repository
contract.
Alternative considered: an `is_active` column on profiles. Rejected because
atomic replacement and uniqueness semantics differ between SQLite and
PostgreSQL, while a singleton pointer is explicit and makes initial
environment-fallback state representable.
### D2: Resolve the active profile on every planner decision
The internal planner endpoint resolves the active profile from the repository
at the start of each request, decrypts its API key, and builds the existing
tool-calling client with the resolved values. It uses that same resolved
profile for token-usage metadata, preventing a model switch during the call
from recording a mismatched provider/model. No application-level cache is
used; the one indexed settings lookup and symmetric decryption are negligible
relative to an LLM request and make a successful activation effective on the
next decision.
The resolver fails a planner request with the existing structured
`planner_unavailable` response when the active profile is missing, disabled,
corrupt, or undecryptable. It never reads legacy `AI_PLANNER_*` or Provider
API-key environment variables.
Alternative considered: move profile configuration into Host Agent heartbeat
responses. Rejected because it would replicate API keys to edge hosts, delay
changes until heartbeat, and duplicate the Cloud proxy's existing decision
boundary.
### D3: Use Fernet encryption with an environment-held master key
`cryptography` is added to `device-cloud-platform`. A small secret-box port
uses `cryptography.fernet.Fernet` with
`CLOUD_LLM_PROVIDER_ENCRYPTION_KEY`; only Fernet ciphertext is stored in the
database. The key remains environment or secret-manager configuration and is
not stored in the database. Profile write requests accept a plaintext API key
only to encrypt it; profile read responses expose `has_api_key` and
`key_last_rotated_at` rather than a key value.
The Cloud API can list profile metadata without the encryption key, but
creating, rotating, activating, or resolving a profile without a valid key
fails with a controlled configuration error that excludes the supplied secret.
Provider mutation audit events retain profile id and action only.
Alternative considered: plaintext database keys. Rejected because database
backup or read access would expose external-provider credentials. Alternative
considered: a database-held master key. Rejected because it does not create a
separate protection boundary.
### D4: Treat OpenAI-compatible as a concrete wire-protocol contract
Profiles have `provider_type` of `anthropic` or `openai-compatible`. An
`openai-compatible` profile may omit `base_url` to use the official OpenAI
endpoint, or supply an absolute HTTP(S) base URL for a compatible service.
The runtime `OpenAIToolCallingClient` gains optional `api_key` and `base_url`
constructor arguments and constructs `OpenAI` explicitly when supplied.
`AnthropicToolCallingClient` gains an optional explicit API key for cloud-held
credentials; absent optional arguments retain current SDK environment
behavior for direct transport.
Compatibility means the endpoint accepts OpenAI Chat Completions requests
with the tool/function-calling fields emitted by the existing client and
returns a single compatible tool call. It does not claim compatibility with
providers requiring a different request schema or custom authentication.
Alternative considered: create a new cloud-only HTTP adapter. Rejected because
it duplicates response parsing and would drift from the direct provider path.
### D5: Expose a dedicated administrator API and Console view
Add a public `/v1/planner/providers` router composed by the Cloud API. It
requires a new `llm-providers:admin` scope, which the existing admin role
receives via its wildcard. All writes use the existing session CSRF validator
and record non-secret auth audit events. The Console shows only for principals
with that scope and provides profile creation, editing/key rotation,
enable/disable, activation, and deletion of inactive profiles. Responses use
revisions for optimistic-concurrency conflicts rather than overwriting an
administrator's newer edit.
Alternative considered: reuse a generic configuration endpoint. Rejected
because Provider records carry secrets and need substantially stricter output,
audit, and activation invariants.
## Risks / Trade-offs
- [Risk] The Cloud database and encryption key become part of the planner hot
path. -> Mitigation: resolver failures use the established fail-fast
response, and readiness/configuration documentation makes the required
active profile explicit.
- [Risk] An administrator can direct Cloud traffic to an arbitrary compatible
base URL. -> Mitigation: only `llm-providers:admin` can write a profile;
deployments must treat that role as privileged network-configuration access.
- [Risk] Losing the master key makes stored Provider credentials unusable. ->
Mitigation: document secret-manager backup and keep a legacy deployment
rollback path while adopting the feature.
- [Risk] Some services marketed as OpenAI-compatible diverge on parameters or
tools. -> Mitigation: document the exact Chat Completions tool-calling
contract and surface provider failures instead of retrying against another
profile.
- [Risk] Concurrent Console administrators can race to edit or activate a
profile. -> Mitigation: revision checks on profile/settings writes and a
transaction for activation.
## Migration Plan
1. Apply the migration and provision `CLOUD_LLM_PROVIDER_ENCRYPTION_KEY` from
the deployment secret manager before routing cloud-transport planner work
to the new Cloud API version.
2. Create and activate a profile through the administrator Console. Until an
active profile exists, cloud planner requests fail closed.
3. Verify profile metadata and token-usage provider/model labels, then remove
`AI_PLANNER_*`, `ANTHROPIC_API_KEY`, and `OPENAI_API_KEY` from the Cloud API
deployment configuration.
4. To roll back, restore a previous Cloud API version together with its legacy
environment configuration. Do not remove the encryption key or database
rows before a planned migration.
## Open Questions
- None for the initial Cloud-wide selection. Per-Host assignment, cost-aware
routing, and master-key rotation need separate designs because they change
the selection and operational contracts.
@@ -0,0 +1,56 @@
## Why
The Cloud Control Plane proxy currently reads its LLM provider, model, and
credentials from process environment variables. Changing a model therefore
requires a deployment change and cannot be audited or operated from the Cloud
Console, even though every cloud-transport Host Agent already depends on the
control plane for its planner decisions.
## What Changes
- Add a durable, Cloud-wide catalog of planner Provider profiles. Each profile
records its supported provider kind (`anthropic` or `openai-compatible`),
model, timeout, optional OpenAI-compatible base URL, enabled state, and an
encrypted API key.
- Add an administrator-only Cloud API and Cloud Console view to create, list,
update, activate, disable, and retire Provider profiles. Read responses
never expose an API key; writes can rotate a key without returning it.
- Make the cloud planner-decision endpoint resolve the active profile from the
database for each request, so a newly activated profile applies to all
cloud-transport Host Agents without changing their configuration or
restarting them.
- Remove Cloud API environment-variable configuration for the planner
provider, model, timeout, and Provider API keys. An absent active database
profile fails closed instead of silently using a stale environment
credential.
- Add an encryption-key configuration for protecting Provider API keys at rest
and document deployment, rotation, and migration behavior.
- Extend the existing provider client construction so cloud-managed
OpenAI-compatible profiles pass their encrypted API key and configured base
URL explicitly to the OpenAI SDK, while Host Agent direct transport continues
to use its existing environment-based credentials.
## Capabilities
### New Capabilities
- `llm-provider-management`: Durable, administrator-managed Cloud-wide LLM
Provider profiles, encrypted credential handling, and Console/API operations.
### Modified Capabilities
- `cloud-planner-proxy`: Cloud planner decisions select their provider,
model, timeout, and credential from the active database profile once
database management is enabled, rather than requiring a Cloud API restart
and environment-variable change.
## Impact
- `packages/cloud-platform/cloud`: SQLAlchemy models, repository contract and
implementation, Alembic migration, encrypted-secret service, and planner
client construction from a resolved profile.
- `apps/cloud-api` and `cloud.sdk`: an authenticated management router and
application composition for the new service.
- `cloud-console`: an administrator-only Provider management view, client API,
and types.
- Cloud deployment configuration: the encryption key remains deployment-held,
while planner Provider/model/timeout/API-key environment variables are
removed.
@@ -0,0 +1,28 @@
## MODIFIED Requirements
### Requirement: Endpoint resolves exactly one tool-call decision using cloud-held provider configuration
The Cloud Control Plane SHALL use its own configured LLM provider, model, and
credentials -- not any value supplied by the requesting Host Agent -- to
resolve a planner-decision request to exactly one tool name and one arguments
object, within the request's timeout. The endpoint SHALL resolve the active
database-managed Provider profile for every request and use its provider,
model, timeout, OpenAI-compatible base URL when applicable, and encrypted
cloud-held credential. It SHALL NOT read Cloud API planner Provider/model/
timeout/API-key environment variables.
#### Scenario: Provider returns a usable decision
- **WHEN** the configured provider responds to a planner-decision request
with a tool call
- **THEN** the Cloud Control Plane returns exactly one resolved tool name
and arguments object to the requesting Host Agent
#### Scenario: Configured provider is unreachable or misconfigured
- **WHEN** the Cloud Control Plane's configured provider call fails (for
example, invalid credentials, provider error, or timeout)
- **THEN** the endpoint returns a structured failure response rather than a
fabricated decision, and does not crash the Cloud Control Plane process
#### Scenario: Database profile is the only planner configuration source
- **WHEN** the Cloud API process has legacy planner environment variables
- **THEN** subsequent planner-decision requests use only the active database
profile and do not read a legacy Provider credential for that decision
@@ -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.
+1
View File
@@ -18,6 +18,7 @@ PLUGINS_ADMIN_SCOPE = "plugins:admin"
USERS_ADMIN_SCOPE = "users:admin"
GOVERNANCE_READ_SCOPE = "governance:read"
GOVERNANCE_ADMIN_SCOPE = "governance:admin"
LLM_PROVIDERS_ADMIN_SCOPE = "llm-providers:admin"
@dataclass(frozen=True)
@@ -285,3 +285,45 @@ class TokenUsageEventRow(Base):
output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_tokens: Mapped[int] = mapped_column(Integer, nullable=False)
occurred_at: Mapped[str] = mapped_column(String, nullable=False)
class LlmProviderProfileRow(Base):
__tablename__ = "cloud_llm_provider_profiles"
__table_args__ = (
UniqueConstraint(
"name_normalized",
name="uq_cloud_llm_provider_profiles_name_normalized",
),
Index("ix_cloud_llm_provider_profiles_enabled", "enabled"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
name_normalized: Mapped[str] = mapped_column(String, nullable=False)
provider_type: Mapped[str] = mapped_column(String, nullable=False)
model: Mapped[str] = mapped_column(String, nullable=False)
base_url: Mapped[str | None] = mapped_column(String, nullable=True)
timeout_seconds: Mapped[float] = mapped_column(nullable=False)
api_key_ciphertext: Mapped[str] = mapped_column(Text, nullable=False)
key_last_rotated_at: Mapped[str] = mapped_column(String, nullable=False)
enabled: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
revision: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
created_at: Mapped[str] = mapped_column(String, nullable=False)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
class LlmProviderSettingsRow(Base):
__tablename__ = "cloud_llm_provider_settings"
id: Mapped[str] = mapped_column(String, primary_key=True)
active_profile_id: Mapped[str | None] = mapped_column(
ForeignKey("cloud_llm_provider_profiles.id"), nullable=True
)
revision: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
updated_at: Mapped[str] = mapped_column(String, nullable=False)
@@ -39,7 +39,9 @@ from cloud.internal_api.models import (
TerminalResultRequest,
TerminalResultResponse,
)
from cloud.planner_config import build_cloud_planner_client, load_cloud_planner_config
from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService
from cloud.planner_config import build_cloud_planner_client
from cloud.provider_secrets import ProviderSecretConfigurationError
from cloud.repository import (
DeviceEnrollmentConflictError,
HostEnrollmentConflictError,
@@ -65,6 +67,7 @@ def create_internal_router(
lease_duration_seconds: float = 60.0,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
planner_client_factory: Callable[[], ToolCallingClient] | None = None,
planner_provider_service: LlmProviderService | None = None,
scheduler: TaskScheduler | None = None,
planner_token_reservation_ceiling: int = 4096,
planner_token_reservation_ttl_seconds: float = 300.0,
@@ -78,8 +81,6 @@ def create_internal_router(
if planner_token_reservation_ttl_seconds <= 0:
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero")
router = APIRouter(prefix=version_prefix, tags=["host-agent"])
build_planner_client = planner_client_factory or _default_planner_client_factory
def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request)
if principal is None:
@@ -395,6 +396,28 @@ def create_internal_router(
for tool in payload.tools
]
try:
if planner_client_factory is not None:
client = planner_client_factory()
resolved_provider = "test"
resolved_model = "test"
elif planner_provider_service is not None:
resolved = planner_provider_service.resolve_active_profile()
client = build_cloud_planner_client(resolved)
resolved_provider = resolved.profile.provider_type
resolved_model = resolved.profile.model
else:
raise LlmProviderResolutionError("no database Provider resolver configured")
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
logger.info(
"planner-decision request failed",
extra={"host_id": host_id, "error_class": type(exc).__name__},
)
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content=PlannerDecisionError(detail=str(exc)).model_dump(),
)
now = utc_now()
reservation = None
try:
@@ -415,7 +438,6 @@ def create_internal_router(
)
started_at = monotonic()
client = build_planner_client()
try:
decision = client.decide(
system_prompt=payload.system_prompt,
@@ -439,12 +461,11 @@ def create_internal_router(
)
usage = decision.usage
if reservation is not None and usage is not None and usage.total_tokens is not None:
planner_config = load_cloud_planner_config()
pool.store.settle_host_token_reservation(
reservation_id=reservation.id,
event_id=uuid4().hex,
provider=planner_config.provider,
model=planner_config.resolved_model(),
provider=resolved_provider,
model=resolved_model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
total_tokens=usage.total_tokens,
@@ -469,10 +490,6 @@ def create_internal_router(
return router
def _default_planner_client_factory() -> ToolCallingClient:
return build_cloud_planner_client(load_cloud_planner_config())
def _validate_assignment_identity(
*,
host_id: str,
@@ -0,0 +1,274 @@
"""Domain and service layer for Cloud-managed LLM Provider profiles."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
from urllib.parse import urlparse
from uuid import uuid4
from cloud.provider_secrets import ProviderSecretBox, load_provider_secret_box
ProviderType = Literal["anthropic", "openai-compatible"]
SUPPORTED_PROVIDER_TYPES = frozenset({"anthropic", "openai-compatible"})
class LlmProviderValidationError(ValueError):
pass
class LlmProviderConflictError(RuntimeError):
pass
class LlmProviderActiveConflictError(LlmProviderConflictError):
pass
class LlmProviderResolutionError(RuntimeError):
pass
@dataclass(frozen=True)
class LlmProviderProfile:
id: str
name: str
name_normalized: str
provider_type: ProviderType
model: str
base_url: str | None
timeout_seconds: float
api_key_ciphertext: str = field(repr=False)
key_last_rotated_at: datetime
enabled: bool
revision: int
created_at: datetime
updated_at: datetime
@dataclass(frozen=True)
class LlmProviderSettings:
active_profile_id: str | None
revision: int = 0
updated_at: datetime | None = None
@dataclass(frozen=True)
class ResolvedLlmProviderProfile:
profile: LlmProviderProfile
api_key: str = field(repr=False)
@dataclass(frozen=True)
class LlmProviderProfileInput:
name: str
name_normalized: str
provider_type: ProviderType
model: str
base_url: str | None
timeout_seconds: float
def validate_profile_input(
*,
name: str,
provider_type: str,
model: str,
base_url: str | None,
timeout_seconds: float,
) -> LlmProviderProfileInput:
display_name = " ".join(name.split())
if not display_name or len(display_name) > 120:
raise LlmProviderValidationError(
"Provider name must contain 1 to 120 characters"
)
normalized_name = display_name.casefold()
if provider_type not in SUPPORTED_PROVIDER_TYPES:
raise LlmProviderValidationError("Provider type is not supported")
normalized_model = model.strip()
if not normalized_model or len(normalized_model) > 200:
raise LlmProviderValidationError("Model must contain 1 to 200 characters")
if timeout_seconds <= 0 or timeout_seconds > 120:
raise LlmProviderValidationError(
"Timeout must be greater than zero and at most 120"
)
normalized_base_url = _normalize_base_url(base_url)
if provider_type == "anthropic" and normalized_base_url is not None:
raise LlmProviderValidationError(
"Anthropic profiles do not support a custom base URL"
)
return LlmProviderProfileInput(
name=display_name,
name_normalized=normalized_name,
provider_type=provider_type, # type: ignore[arg-type]
model=normalized_model,
base_url=normalized_base_url,
timeout_seconds=timeout_seconds,
)
class LlmProviderService:
"""Applies secret and active-profile invariants above the repository port."""
def __init__(
self,
repository,
*,
secret_box_factory: Callable[[], ProviderSecretBox] = load_provider_secret_box,
) -> None:
self._repository = repository
self._secret_box_factory = secret_box_factory
def list_profiles(self) -> tuple[LlmProviderSettings, list[LlmProviderProfile]]:
return (
self._repository.get_llm_provider_settings(),
self._repository.list_llm_provider_profiles(),
)
def create_profile(
self,
*,
name: str,
provider_type: str,
model: str,
base_url: str | None,
timeout_seconds: float,
api_key: str,
now: datetime,
) -> LlmProviderProfile:
if not api_key:
raise LlmProviderValidationError("Provider API key must not be empty")
values = validate_profile_input(
name=name,
provider_type=provider_type,
model=model,
base_url=base_url,
timeout_seconds=timeout_seconds,
)
return self._repository.create_llm_provider_profile(
LlmProviderProfile(
id=uuid4().hex,
name=values.name,
name_normalized=values.name_normalized,
provider_type=values.provider_type,
model=values.model,
base_url=values.base_url,
timeout_seconds=values.timeout_seconds,
api_key_ciphertext=self._secret_box_factory().encrypt(api_key),
key_last_rotated_at=now,
enabled=True,
revision=1,
created_at=now,
updated_at=now,
)
)
def update_profile(
self,
profile_id: str,
*,
name: str | None,
provider_type: str | None,
model: str | None,
base_url: str | None,
base_url_supplied: bool,
timeout_seconds: float | None,
enabled: bool | None,
api_key: str | None,
expected_revision: int | None,
now: datetime,
) -> LlmProviderProfile:
current = self._repository.get_llm_provider_profile(profile_id)
if current is None:
raise KeyError(profile_id)
values = validate_profile_input(
name=current.name if name is None else name,
provider_type=current.provider_type
if provider_type is None
else provider_type,
model=current.model if model is None else model,
base_url=current.base_url if not base_url_supplied else base_url,
timeout_seconds=(
current.timeout_seconds if timeout_seconds is None else timeout_seconds
),
)
ciphertext = current.api_key_ciphertext
key_last_rotated_at = current.key_last_rotated_at
if api_key is not None:
if not api_key:
raise LlmProviderValidationError("Provider API key must not be empty")
ciphertext = self._secret_box_factory().encrypt(api_key)
key_last_rotated_at = now
return self._repository.update_llm_provider_profile(
profile_id,
name=values.name,
name_normalized=values.name_normalized,
provider_type=values.provider_type,
model=values.model,
base_url=values.base_url,
timeout_seconds=values.timeout_seconds,
api_key_ciphertext=ciphertext,
key_last_rotated_at=key_last_rotated_at,
enabled=current.enabled if enabled is None else enabled,
expected_revision=expected_revision,
updated_at=now,
)
def activate_profile(
self,
profile_id: str,
*,
expected_settings_revision: int | None,
now: datetime,
) -> LlmProviderSettings:
profile = self._repository.get_llm_provider_profile(profile_id)
if profile is None:
raise KeyError(profile_id)
# Fail before persisting the activation when the credential cannot be used.
self._secret_box_factory().decrypt(profile.api_key_ciphertext)
return self._repository.activate_llm_provider_profile(
profile_id,
expected_settings_revision=expected_settings_revision,
updated_at=now,
)
def delete_profile(
self,
profile_id: str,
*,
expected_revision: int | None,
) -> None:
self._repository.delete_llm_provider_profile(
profile_id,
expected_revision=expected_revision,
)
def resolve_active_profile(self) -> ResolvedLlmProviderProfile:
settings = self._repository.get_llm_provider_settings()
if settings.active_profile_id is None:
raise LlmProviderResolutionError("no active database Provider profile")
profile = self._repository.get_llm_provider_profile(settings.active_profile_id)
if profile is None or not profile.enabled:
raise LlmProviderResolutionError(
"active database Provider profile is unavailable"
)
try:
api_key = self._secret_box_factory().decrypt(profile.api_key_ciphertext)
except ValueError as exc:
raise LlmProviderResolutionError(str(exc)) from exc
return ResolvedLlmProviderProfile(profile=profile, api_key=api_key)
def _normalize_base_url(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().rstrip("/")
if not normalized:
return None
parsed = urlparse(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise LlmProviderValidationError("Base URL must be an absolute HTTP(S) URL")
return normalized
@@ -0,0 +1,65 @@
"""Add durable, encrypted Cloud LLM Provider profiles."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0007_llm_provider_management"
down_revision = "0006_host_planner_transport"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"cloud_llm_provider_profiles",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("name_normalized", sa.String(), nullable=False),
sa.Column("provider_type", sa.String(), nullable=False),
sa.Column("model", sa.String(), nullable=False),
sa.Column("base_url", sa.String(), nullable=True),
sa.Column("timeout_seconds", sa.Float(), nullable=False),
sa.Column("api_key_ciphertext", sa.Text(), nullable=False),
sa.Column("key_last_rotated_at", sa.String(), nullable=False),
sa.Column("enabled", sa.Integer(), nullable=False, server_default="1"),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("created_at", sa.String(), nullable=False),
sa.Column("updated_at", sa.String(), nullable=False),
sa.UniqueConstraint(
"name_normalized", name="uq_cloud_llm_provider_profiles_name_normalized"
),
)
op.create_index(
"ix_cloud_llm_provider_profiles_enabled",
"cloud_llm_provider_profiles",
["enabled"],
)
op.create_table(
"cloud_llm_provider_settings",
sa.Column("id", sa.String(), primary_key=True),
sa.Column(
"active_profile_id",
sa.String(),
sa.ForeignKey("cloud_llm_provider_profiles.id"),
nullable=True,
),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column("updated_at", sa.String(), nullable=False),
)
op.execute(
"insert into cloud_llm_provider_settings "
"(id, active_profile_id, revision, updated_at) values "
"('global', null, 0, '1970-01-01T00:00:00+00:00')"
)
def downgrade() -> None:
op.drop_table("cloud_llm_provider_settings")
op.drop_index(
"ix_cloud_llm_provider_profiles_enabled",
table_name="cloud_llm_provider_profiles",
)
op.drop_table("cloud_llm_provider_profiles")
+14 -76
View File
@@ -1,86 +1,24 @@
"""Cloud Control Plane's own AI Planner provider configuration.
Analogous to ``runtime/planner_config.py``, but loaded from the Cloud API's
own process environment rather than a Host Agent's. There is no ``enabled``
flag here: the planner-decision endpoint always exists once the Cloud API is
running, and simply fails a given request if the configured provider call
fails (see ``cloud.internal_api.api``). Provider API keys
(``ANTHROPIC_API_KEY``/``OPENAI_API_KEY``) are not modeled as fields here --
like the Host Agent's direct-transport path, they are read implicitly by the
``anthropic``/``openai`` SDK clients from the process environment.
"""
"""Construct Cloud planner clients from resolved database Provider profiles."""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from cloud.llm_providers import ResolvedLlmProviderProfile
from runtime.tool_calling_client import (
AnthropicToolCallingClient,
OpenAIToolCallingClient,
ToolCallingClient,
)
DEFAULT_PROVIDER = "anthropic"
DEFAULT_MODEL_BY_PROVIDER = {
"anthropic": "claude-sonnet-5",
"openai": "gpt-5.6",
}
DEFAULT_TIMEOUT_SECONDS = 30.0
PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@dataclass(frozen=True)
class CloudPlannerConfig:
provider: str = DEFAULT_PROVIDER
model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS
def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
def load_cloud_planner_config(
env: Mapping[str, str] | None = None,
) -> CloudPlannerConfig:
values = env or os.environ
return CloudPlannerConfig(
provider=_parse_provider(values.get(PROVIDER_ENV)),
model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
)
def build_cloud_planner_client(config: CloudPlannerConfig) -> ToolCallingClient:
"""Construct the same provider client the Host Agent's direct transport uses.
Reuses ``runtime.tool_calling_client``'s Anthropic/OpenAI wire-format
translation (see design decision D1) instead of a second implementation.
"""
model = config.resolved_model()
if config.provider == "openai":
return OpenAIToolCallingClient(model=model)
return AnthropicToolCallingClient(model=model)
def _parse_provider(value: str | None) -> str:
if value is None:
return DEFAULT_PROVIDER
provider = value.strip().lower()
return provider if provider in SUPPORTED_PROVIDERS else DEFAULT_PROVIDER
def _parse_timeout(value: str | None) -> float:
if value is None:
return DEFAULT_TIMEOUT_SECONDS
try:
timeout = float(value)
except ValueError:
return DEFAULT_TIMEOUT_SECONDS
return timeout if timeout > 0 else DEFAULT_TIMEOUT_SECONDS
def build_cloud_planner_client(
resolved: ResolvedLlmProviderProfile,
) -> ToolCallingClient:
"""Build a provider client without reading Cloud API environment variables."""
profile = resolved.profile
if profile.provider_type == "openai-compatible":
return OpenAIToolCallingClient(
model=profile.model,
api_key=resolved.api_key,
base_url=profile.base_url,
)
return AnthropicToolCallingClient(model=profile.model, api_key=resolved.api_key)
@@ -0,0 +1,56 @@
"""Encryption for Cloud-managed LLM Provider credentials."""
from __future__ import annotations
import os
from collections.abc import Mapping
from cryptography.fernet import Fernet, InvalidToken
ENCRYPTION_KEY_ENV = "CLOUD_LLM_PROVIDER_ENCRYPTION_KEY"
class ProviderSecretConfigurationError(ValueError):
"""Raised when deployment configuration cannot protect a Provider secret."""
class ProviderSecretDecryptionError(ValueError):
"""Raised when a persisted Provider secret cannot be decrypted."""
class ProviderSecretBox:
"""Small, testable boundary around Fernet encryption."""
def __init__(self, key: str) -> None:
try:
self._fernet = Fernet(key.encode("ascii"))
except (UnicodeEncodeError, ValueError) as exc:
raise ProviderSecretConfigurationError(
f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key"
) from exc
def encrypt(self, secret: str) -> str:
if not secret:
raise ValueError("Provider API key must not be empty")
return self._fernet.encrypt(secret.encode("utf-8")).decode("ascii")
def decrypt(self, ciphertext: str) -> str:
try:
return self._fernet.decrypt(ciphertext.encode("ascii")).decode("utf-8")
except (InvalidToken, UnicodeDecodeError, UnicodeEncodeError) as exc:
raise ProviderSecretDecryptionError(
"stored Provider API key cannot be decrypted"
) from exc
def load_provider_secret_box(
env: Mapping[str, str] | None = None,
) -> ProviderSecretBox:
values = os.environ if env is None else env
key = values.get(ENCRYPTION_KEY_ENV, "")
if not key:
raise ProviderSecretConfigurationError(
f"{ENCRYPTION_KEY_ENV} must be configured for database Provider management"
)
return ProviderSecretBox(key)
@@ -22,6 +22,7 @@ if TYPE_CHECKING:
TokenUsageEvent,
UserSubmissionPolicy,
)
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -341,6 +342,48 @@ class CloudRepository(Protocol):
self, *, host_id: str, limit: int, offset: int,
) -> list[TokenUsageEvent]: ...
def get_llm_provider_settings(self) -> LlmProviderSettings: ...
def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ...
def get_llm_provider_profile(self, profile_id: str) -> LlmProviderProfile | None: ...
def create_llm_provider_profile(
self, profile: LlmProviderProfile
) -> LlmProviderProfile: ...
def update_llm_provider_profile(
self,
profile_id: str,
*,
name: str,
name_normalized: str,
provider_type: ProviderType,
model: str,
base_url: str | None,
timeout_seconds: float,
api_key_ciphertext: str,
key_last_rotated_at: datetime,
enabled: bool,
expected_revision: int | None,
updated_at: datetime,
) -> LlmProviderProfile: ...
def activate_llm_provider_profile(
self,
profile_id: str,
*,
expected_settings_revision: int | None,
updated_at: datetime,
) -> LlmProviderSettings: ...
def delete_llm_provider_profile(
self,
profile_id: str,
*,
expected_revision: int | None,
) -> None: ...
def list_reserved_device_ids(self, *, now: datetime) -> set[str]: ...
def assign_task(
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0006_host_planner_transport"
HEAD_REVISION = "0007_llm_provider_management"
class SchemaVersionError(RuntimeError):
@@ -0,0 +1,268 @@
"""Administrator API for Cloud-managed LLM Provider profiles."""
from __future__ import annotations
from collections.abc import Callable
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, Request, status
from cloud.auth import AuthProvider, LLM_PROVIDERS_ADMIN_SCOPE, Principal
from cloud.llm_providers import (
LlmProviderActiveConflictError,
LlmProviderConflictError,
LlmProviderService,
LlmProviderValidationError,
)
from cloud.observability import current_correlation_id
from cloud.provider_secrets import (
ProviderSecretConfigurationError,
ProviderSecretDecryptionError,
)
from cloud.sdk.models import (
LlmProviderProfileActivateRequest,
LlmProviderProfileCreateRequest,
LlmProviderProfileListResponse,
LlmProviderProfileResponse,
LlmProviderProfileUpdateRequest,
LlmProviderSettingsResponse,
)
from cloud.user_auth import AuthAuditEvent
from core.models import utc_now
def create_llm_provider_router(
*,
service: LlmProviderService,
repository,
auth_provider: AuthProvider,
csrf_validator: Callable[[Request, Principal], bool],
version_prefix: str = "/v1",
) -> APIRouter:
router = APIRouter(prefix=version_prefix, tags=["llm-provider-management"])
def authorize(request: Request) -> Principal:
principal = auth_provider.authenticate(request)
if principal is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
if principal.must_change_password or not principal.has_scope(
LLM_PROVIDERS_ADMIN_SCOPE
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"missing required scope: {LLM_PROVIDERS_ADMIN_SCOPE}",
)
return principal
def require_csrf(request: Request, principal: Principal) -> None:
if not csrf_validator(request, principal):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="CSRF validation failed",
)
@router.get("/planner/providers", response_model=LlmProviderProfileListResponse)
def list_profiles(request: Request) -> LlmProviderProfileListResponse:
authorize(request)
settings, profiles = service.list_profiles()
return LlmProviderProfileListResponse(
settings=_settings_response(settings),
items=[
_profile_response(profile, settings.active_profile_id)
for profile in profiles
],
)
@router.post(
"/planner/providers",
response_model=LlmProviderProfileResponse,
status_code=status.HTTP_201_CREATED,
)
def create_profile(
payload: LlmProviderProfileCreateRequest,
request: Request,
) -> LlmProviderProfileResponse:
principal = authorize(request)
require_csrf(request, principal)
try:
profile = service.create_profile(
name=payload.name,
provider_type=payload.provider_type,
model=payload.model,
base_url=payload.base_url,
timeout_seconds=payload.timeout_seconds,
api_key=payload.api_key.get_secret_value(),
now=utc_now(),
)
except LlmProviderValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except LlmProviderConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except ProviderSecretConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
_audit(repository, principal, profile.id, "llm_provider_create")
return _profile_response(profile, None)
@router.patch(
"/planner/providers/{profile_id}", response_model=LlmProviderProfileResponse
)
def update_profile(
profile_id: str,
payload: LlmProviderProfileUpdateRequest,
request: Request,
) -> LlmProviderProfileResponse:
principal = authorize(request)
require_csrf(request, principal)
try:
profile = service.update_profile(
profile_id,
name=payload.name,
provider_type=payload.provider_type,
model=payload.model,
base_url=payload.base_url,
base_url_supplied="base_url" in payload.model_fields_set,
timeout_seconds=payload.timeout_seconds,
enabled=payload.enabled,
api_key=(
payload.api_key.get_secret_value()
if payload.api_key is not None
else None
),
expected_revision=payload.expected_revision,
now=utc_now(),
)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Provider profile not found",
) from exc
except LlmProviderValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
) from exc
except LlmProviderActiveConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except LlmProviderConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except ProviderSecretConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
settings = repository.get_llm_provider_settings()
_audit(repository, principal, profile.id, "llm_provider_update")
return _profile_response(profile, settings.active_profile_id)
@router.post(
"/planner/providers/{profile_id}/activate",
response_model=LlmProviderSettingsResponse,
)
def activate_profile(
profile_id: str,
payload: LlmProviderProfileActivateRequest,
request: Request,
) -> LlmProviderSettingsResponse:
principal = authorize(request)
require_csrf(request, principal)
try:
settings = service.activate_profile(
profile_id,
expected_settings_revision=payload.expected_settings_revision,
now=utc_now(),
)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Provider profile not found",
) from exc
except (LlmProviderConflictError, ProviderSecretDecryptionError) as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
except ProviderSecretConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
) from exc
_audit(repository, principal, profile_id, "llm_provider_activate")
return _settings_response(settings)
@router.delete(
"/planner/providers/{profile_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_profile(
profile_id: str,
request: Request,
expected_revision: int | None = Query(default=None, ge=1),
) -> None:
principal = authorize(request)
require_csrf(request, principal)
try:
service.delete_profile(profile_id, expected_revision=expected_revision)
except KeyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Provider profile not found",
) from exc
except LlmProviderConflictError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
) from exc
_audit(repository, principal, profile_id, "llm_provider_delete")
return router
def _profile_response(
profile, active_profile_id: str | None
) -> LlmProviderProfileResponse:
return LlmProviderProfileResponse(
id=profile.id,
name=profile.name,
provider_type=profile.provider_type,
model=profile.model,
base_url=profile.base_url,
timeout_seconds=profile.timeout_seconds,
enabled=profile.enabled,
revision=profile.revision,
has_api_key=bool(profile.api_key_ciphertext),
key_last_rotated_at=profile.key_last_rotated_at,
created_at=profile.created_at,
updated_at=profile.updated_at,
active=profile.id == active_profile_id,
)
def _settings_response(settings) -> LlmProviderSettingsResponse:
return LlmProviderSettingsResponse(
active_profile_id=settings.active_profile_id,
revision=settings.revision,
updated_at=settings.updated_at,
)
def _audit(repository, principal: Principal, profile_id: str, action: str) -> None:
repository.record_auth_audit(
AuthAuditEvent(
id=uuid4().hex,
occurred_at=utc_now(),
actor_principal_id=principal.id,
target_user_id=None,
action=action,
outcome="success",
correlation_id=current_correlation_id(),
metadata={"provider_profile_id": profile_id},
)
)
+52 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, SecretStr
class TaskConstraintsModel(BaseModel):
@@ -212,3 +212,54 @@ class TokenUsageEventResponse(BaseModel):
output_tokens: int | None = None
total_tokens: int
occurred_at: datetime
class LlmProviderProfileCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
provider_type: Literal["anthropic", "openai-compatible"]
model: str = Field(min_length=1, max_length=200)
base_url: str | None = Field(default=None, max_length=500)
timeout_seconds: float = Field(gt=0, le=120)
api_key: SecretStr
class LlmProviderProfileUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
provider_type: Literal["anthropic", "openai-compatible"] | None = None
model: str | None = Field(default=None, min_length=1, max_length=200)
base_url: str | None = Field(default=None, max_length=500)
timeout_seconds: float | None = Field(default=None, gt=0, le=120)
enabled: bool | None = None
api_key: SecretStr | None = None
expected_revision: int | None = Field(default=None, ge=1)
class LlmProviderProfileActivateRequest(BaseModel):
expected_settings_revision: int | None = Field(default=None, ge=0)
class LlmProviderProfileResponse(BaseModel):
id: str
name: str
provider_type: Literal["anthropic", "openai-compatible"]
model: str
base_url: str | None = None
timeout_seconds: float
enabled: bool
revision: int
has_api_key: bool
key_last_rotated_at: datetime
created_at: datetime
updated_at: datetime
active: bool
class LlmProviderSettingsResponse(BaseModel):
active_profile_id: str | None = None
revision: int
updated_at: datetime | None = None
class LlmProviderProfileListResponse(BaseModel):
settings: LlmProviderSettingsResponse
items: list[LlmProviderProfileResponse]
+355 -40
View File
@@ -20,6 +20,8 @@ from cloud.db_models import (
TaskAttemptRow,
AuthAuditRow,
HostGovernancePolicyRow,
LlmProviderProfileRow,
LlmProviderSettingsRow,
LoginThrottleRow,
UserRow,
UserSessionRow,
@@ -42,6 +44,19 @@ class SQLAlchemyCloudRepository:
self._sessions = sessionmaker(bind=engine, expire_on_commit=False)
if create_schema:
Base.metadata.create_all(engine)
self._ensure_llm_provider_settings()
def _ensure_llm_provider_settings(self) -> None:
with self._sessions.begin() as session:
if session.get(LlmProviderSettingsRow, "global") is None:
session.add(
LlmProviderSettingsRow(
id="global",
active_profile_id=None,
revision=0,
updated_at=_iso(utc_now()),
)
)
def enroll_host(
self,
@@ -503,7 +518,9 @@ class SQLAlchemyCloudRepository:
created_at=_iso(user.created_at),
updated_at=_iso(user.updated_at),
last_login_at=(
_iso(user.last_login_at) if user.last_login_at is not None else None
_iso(user.last_login_at)
if user.last_login_at is not None
else None
),
)
session.add(row)
@@ -576,7 +593,9 @@ class SQLAlchemyCloudRepository:
raise LastAdministratorConflictError(
"cannot remove the last enabled administrator"
)
security_changed = next_role != row.role or next_enabled != bool(row.enabled)
security_changed = next_role != row.role or next_enabled != bool(
row.enabled
)
if display_name is not None:
row.display_name = display_name
row.role = next_role
@@ -764,7 +783,9 @@ class SQLAlchemyCloudRepository:
session.flush()
return _login_throttle_from_row(row)
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None:
def clear_login_throttle(
self, username_normalized: str, client_bucket: str
) -> None:
with self._sessions.begin() as session:
row = session.get(LoginThrottleRow, (username_normalized, client_bucket))
if row is not None:
@@ -843,7 +864,9 @@ class SQLAlchemyCloudRepository:
)
if row is None:
if expected_revision not in {None, 0}:
raise GovernancePolicyConflictError("submission policy revision changed")
raise GovernancePolicyConflictError(
"submission policy revision changed"
)
row = UserSubmissionPolicyRow(
user_id=user_id,
revision=1,
@@ -856,11 +879,10 @@ class SQLAlchemyCloudRepository:
)
session.add(row)
else:
if (
expected_revision is not None
and expected_revision != row.revision
):
raise GovernancePolicyConflictError("submission policy revision changed")
if expected_revision is not None and expected_revision != row.revision:
raise GovernancePolicyConflictError(
"submission policy revision changed"
)
row.revision += 1
row.submission_enabled = 1 if submission_enabled else 0
row.allowed_host_ids_json = _dump_optional_list(allowed_host_ids)
@@ -909,10 +931,7 @@ class SQLAlchemyCloudRepository:
)
session.add(row)
else:
if (
expected_revision is not None
and expected_revision != row.revision
):
if expected_revision is not None and expected_revision != row.revision:
raise GovernancePolicyConflictError("Host policy revision changed")
row.revision += 1
row.self_submission_enabled = 1 if self_submission_enabled else 0
@@ -922,6 +941,220 @@ class SQLAlchemyCloudRepository:
session.flush()
return _host_governance_policy_from_row(row)
# --------------------------------------------------------- LLM Providers
def get_llm_provider_settings(self) -> Any:
with self._sessions() as session:
row = session.get(LlmProviderSettingsRow, "global")
return _llm_provider_settings_from_row(row)
def list_llm_provider_profiles(self) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(LlmProviderProfileRow).order_by(
LlmProviderProfileRow.name_normalized
)
).all()
return [_llm_provider_profile_from_row(row) for row in rows]
def get_llm_provider_profile(self, profile_id: str) -> Any | None:
with self._sessions() as session:
row = session.get(LlmProviderProfileRow, profile_id)
return _llm_provider_profile_from_row(row) if row is not None else None
def create_llm_provider_profile(self, profile: Any) -> Any:
from cloud.llm_providers import LlmProviderConflictError
try:
with self._sessions.begin() as session:
if (
session.scalars(
select(LlmProviderProfileRow).where(
LlmProviderProfileRow.name_normalized
== profile.name_normalized
)
).first()
is not None
):
raise LlmProviderConflictError(
"Provider profile name already exists"
)
row = LlmProviderProfileRow(
id=profile.id,
name=profile.name,
name_normalized=profile.name_normalized,
provider_type=profile.provider_type,
model=profile.model,
base_url=profile.base_url,
timeout_seconds=profile.timeout_seconds,
api_key_ciphertext=profile.api_key_ciphertext,
key_last_rotated_at=_iso(profile.key_last_rotated_at),
enabled=1 if profile.enabled else 0,
revision=profile.revision,
created_at=_iso(profile.created_at),
updated_at=_iso(profile.updated_at),
)
session.add(row)
session.flush()
return _llm_provider_profile_from_row(row)
except IntegrityError as exc:
raise LlmProviderConflictError(
"Provider profile name already exists"
) from exc
def update_llm_provider_profile(
self,
profile_id: str,
*,
name: str,
name_normalized: str,
provider_type: str,
model: str,
base_url: str | None,
timeout_seconds: float,
api_key_ciphertext: str,
key_last_rotated_at: datetime,
enabled: bool,
expected_revision: int | None,
updated_at: datetime,
) -> Any:
from cloud.llm_providers import (
LlmProviderActiveConflictError,
LlmProviderConflictError,
)
try:
with self._sessions.begin() as session:
row = session.get(
LlmProviderProfileRow,
profile_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
raise KeyError(profile_id)
if expected_revision is not None and expected_revision != row.revision:
raise LlmProviderConflictError("Provider profile revision changed")
existing_name = session.scalars(
select(LlmProviderProfileRow).where(
LlmProviderProfileRow.name_normalized == name_normalized,
LlmProviderProfileRow.id != profile_id,
)
).first()
if existing_name is not None:
raise LlmProviderConflictError(
"Provider profile name already exists"
)
settings = session.get(
LlmProviderSettingsRow,
"global",
with_for_update=self.engine.dialect.name == "postgresql",
)
if (
not enabled
and settings is not None
and settings.active_profile_id == profile_id
):
raise LlmProviderActiveConflictError(
"activate another Provider profile before disabling this one"
)
row.name = name
row.name_normalized = name_normalized
row.provider_type = provider_type
row.model = model
row.base_url = base_url
row.timeout_seconds = timeout_seconds
row.api_key_ciphertext = api_key_ciphertext
row.key_last_rotated_at = _iso(key_last_rotated_at)
row.enabled = 1 if enabled else 0
row.revision += 1
row.updated_at = _iso(updated_at)
session.flush()
return _llm_provider_profile_from_row(row)
except IntegrityError as exc:
raise LlmProviderConflictError(
"Provider profile name already exists"
) from exc
def activate_llm_provider_profile(
self,
profile_id: str,
*,
expected_settings_revision: int | None,
updated_at: datetime,
) -> Any:
from cloud.llm_providers import LlmProviderConflictError
with self._sessions.begin() as session:
profile = session.get(
LlmProviderProfileRow,
profile_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if profile is None:
raise KeyError(profile_id)
if not profile.enabled:
raise LlmProviderConflictError(
"Provider profile must be enabled before activation"
)
settings = session.get(
LlmProviderSettingsRow,
"global",
with_for_update=self.engine.dialect.name == "postgresql",
)
if settings is None:
if expected_settings_revision not in {None, 0}:
raise LlmProviderConflictError("Provider settings revision changed")
settings = LlmProviderSettingsRow(
id="global",
active_profile_id=profile_id,
revision=1,
updated_at=_iso(updated_at),
)
session.add(settings)
else:
if (
expected_settings_revision is not None
and expected_settings_revision != settings.revision
):
raise LlmProviderConflictError("Provider settings revision changed")
settings.active_profile_id = profile_id
settings.revision += 1
settings.updated_at = _iso(updated_at)
session.flush()
return _llm_provider_settings_from_row(settings)
def delete_llm_provider_profile(
self,
profile_id: str,
*,
expected_revision: int | None,
) -> None:
from cloud.llm_providers import (
LlmProviderActiveConflictError,
LlmProviderConflictError,
)
with self._sessions.begin() as session:
row = session.get(
LlmProviderProfileRow,
profile_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
raise KeyError(profile_id)
if expected_revision is not None and expected_revision != row.revision:
raise LlmProviderConflictError("Provider profile revision changed")
settings = session.get(
LlmProviderSettingsRow,
"global",
with_for_update=self.engine.dialect.name == "postgresql",
)
if settings is not None and settings.active_profile_id == profile_id:
raise LlmProviderActiveConflictError(
"activate another Provider profile before deleting this one"
)
session.delete(row)
def count_active_tasks_for_host(self, host_id: str) -> int:
with self._sessions() as session:
count = session.scalar(
@@ -958,24 +1191,36 @@ class SQLAlchemyCloudRepository:
if policy is None or policy.daily_token_budget is None:
return None
used = session.scalar(
select(func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)
).where(
TokenUsageEventRow.host_id == host_id,
TokenUsageEventRow.usage_day == usage_day,
)
)
reserved = session.scalar(
select(func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)
).where(
TokenReservationRow.host_id == host_id,
TokenReservationRow.usage_day == usage_day,
TokenReservationRow.expires_at > _iso(created_at),
)
)
if int(used or 0) + int(reserved or 0) + reserved_tokens > policy.daily_token_budget:
if (
int(used or 0) + int(reserved or 0) + reserved_tokens
> policy.daily_token_budget
):
raise TokenBudgetExceededError("Host daily token budget is exhausted")
row = TokenReservationRow(
id=reservation_id, host_id=host_id, usage_day=usage_day,
reserved_tokens=reserved_tokens, task_id=task_id, attempt=attempt,
created_at=_iso(created_at), expires_at=_iso(expires_at),
id=reservation_id,
host_id=host_id,
usage_day=usage_day,
reserved_tokens=reserved_tokens,
task_id=task_id,
attempt=attempt,
created_at=_iso(created_at),
expires_at=_iso(expires_at),
)
session.add(row)
session.flush()
@@ -995,16 +1240,24 @@ class SQLAlchemyCloudRepository:
) -> Any | None:
with self._sessions.begin() as session:
row = session.get(
TokenReservationRow, reservation_id,
TokenReservationRow,
reservation_id,
with_for_update=self.engine.dialect.name == "postgresql",
)
if row is None:
return None
event = TokenUsageEventRow(
id=event_id, host_id=row.host_id, usage_day=row.usage_day,
task_id=row.task_id, attempt=row.attempt, provider=provider, model=model,
input_tokens=input_tokens, output_tokens=output_tokens,
total_tokens=total_tokens, occurred_at=_iso(occurred_at),
id=event_id,
host_id=row.host_id,
usage_day=row.usage_day,
task_id=row.task_id,
attempt=row.attempt,
provider=provider,
model=model,
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
occurred_at=_iso(occurred_at),
)
session.add(event)
session.delete(row)
@@ -1024,39 +1277,55 @@ class SQLAlchemyCloudRepository:
return len(rows)
def get_host_token_usage_summary(
self, *, host_id: str, usage_day: str, now: datetime,
self,
*,
host_id: str,
usage_day: str,
now: datetime,
) -> Any:
from cloud.governance import TokenUsageSummary
with self._sessions() as session:
policy = session.get(HostGovernancePolicyRow, host_id)
used = session.scalar(
select(func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenUsageEventRow.total_tokens), 0)
).where(
TokenUsageEventRow.host_id == host_id,
TokenUsageEventRow.usage_day == usage_day,
)
)
reserved = session.scalar(
select(func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)).where(
select(
func.coalesce(func.sum(TokenReservationRow.reserved_tokens), 0)
).where(
TokenReservationRow.host_id == host_id,
TokenReservationRow.usage_day == usage_day,
TokenReservationRow.expires_at > _iso(now),
)
)
return TokenUsageSummary(
host_id=host_id, usage_day=usage_day,
host_id=host_id,
usage_day=usage_day,
daily_token_budget=(policy.daily_token_budget if policy else None),
used_tokens=int(used or 0), reserved_tokens=int(reserved or 0),
used_tokens=int(used or 0),
reserved_tokens=int(reserved or 0),
)
def list_host_token_usage_events(
self, *, host_id: str, limit: int, offset: int,
self,
*,
host_id: str,
limit: int,
offset: int,
) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(TokenUsageEventRow)
.where(TokenUsageEventRow.host_id == host_id)
.order_by(TokenUsageEventRow.occurred_at.desc(), TokenUsageEventRow.id.desc())
.order_by(
TokenUsageEventRow.occurred_at.desc(), TokenUsageEventRow.id.desc()
)
.limit(limit)
.offset(offset)
).all()
@@ -1423,7 +1692,9 @@ def _host_from_row(row: HostRow) -> Any:
address=row.address,
last_seen_at=_parse_dt(row.last_seen_at) or utc_now(),
planner_transport=(
row.planner_transport if row.planner_transport in {"direct", "cloud"} else "direct"
row.planner_transport
if row.planner_transport in {"direct", "cloud"}
else "direct"
),
)
@@ -1519,7 +1790,7 @@ def _load_optional_string_tuple(value: str | None) -> tuple[str, ...] | None:
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
except TypeError, ValueError:
parsed = []
return tuple(item for item in parsed if isinstance(item, str))
@@ -1531,7 +1802,7 @@ def _load_optional_device_targets(
return None
try:
parsed = json.loads(value)
except (TypeError, ValueError):
except TypeError, ValueError:
parsed = []
return tuple(
(item[0], item[1])
@@ -1574,8 +1845,12 @@ def _token_reservation_from_row(row: TokenReservationRow) -> Any:
from cloud.governance import TokenReservation
return TokenReservation(
id=row.id, host_id=row.host_id, usage_day=row.usage_day,
reserved_tokens=row.reserved_tokens, task_id=row.task_id, attempt=row.attempt,
id=row.id,
host_id=row.host_id,
usage_day=row.usage_day,
reserved_tokens=row.reserved_tokens,
task_id=row.task_id,
attempt=row.attempt,
created_at=_parse_dt(row.created_at) or utc_now(),
expires_at=_parse_dt(row.expires_at) or utc_now(),
)
@@ -1585,10 +1860,17 @@ def _token_usage_event_from_row(row: TokenUsageEventRow) -> Any:
from cloud.governance import TokenUsageEvent
return TokenUsageEvent(
id=row.id, host_id=row.host_id, usage_day=row.usage_day,
task_id=row.task_id, attempt=row.attempt, provider=row.provider, model=row.model,
input_tokens=row.input_tokens, output_tokens=row.output_tokens,
total_tokens=row.total_tokens, occurred_at=_parse_dt(row.occurred_at) or utc_now(),
id=row.id,
host_id=row.host_id,
usage_day=row.usage_day,
task_id=row.task_id,
attempt=row.attempt,
provider=row.provider,
model=row.model,
input_tokens=row.input_tokens,
output_tokens=row.output_tokens,
total_tokens=row.total_tokens,
occurred_at=_parse_dt(row.occurred_at) or utc_now(),
)
@@ -1733,3 +2015,36 @@ def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
),
bool(row.wired),
)
def _llm_provider_profile_from_row(row: LlmProviderProfileRow) -> Any:
from cloud.llm_providers import LlmProviderProfile
now = utc_now()
return LlmProviderProfile(
id=row.id,
name=row.name,
name_normalized=row.name_normalized,
provider_type=row.provider_type,
model=row.model,
base_url=row.base_url,
timeout_seconds=row.timeout_seconds,
api_key_ciphertext=row.api_key_ciphertext,
key_last_rotated_at=_parse_dt(row.key_last_rotated_at) or now,
enabled=bool(row.enabled),
revision=row.revision,
created_at=_parse_dt(row.created_at) or now,
updated_at=_parse_dt(row.updated_at) or now,
)
def _llm_provider_settings_from_row(row: LlmProviderSettingsRow | None) -> Any:
from cloud.llm_providers import LlmProviderSettings
if row is None:
return LlmProviderSettings(active_profile_id=None)
return LlmProviderSettings(
active_profile_id=row.active_profile_id,
revision=row.revision,
updated_at=_parse_dt(row.updated_at),
)
+1
View File
@@ -7,6 +7,7 @@ requires-python = ">=3.14"
dependencies = [
"alembic>=1.14.0",
"argon2-cffi>=25.0.0",
"cryptography>=45.0.0",
"device-agent-runtime==0.1.0",
"psycopg[binary]>=3.2.0",
"sqlalchemy>=2.0.0",
+17 -2
View File
@@ -46,10 +46,12 @@ class AnthropicToolCallingClient:
model: str,
transport: Any | None = None,
max_tokens: int = 1024,
api_key: str | None = None,
) -> None:
self.model = model
self._transport = transport
self.max_tokens = max_tokens
self._api_key = api_key
def decide(
self,
@@ -118,7 +120,11 @@ class AnthropicToolCallingClient:
except Exception as exc:
raise ToolCallUnavailable("anthropic SDK is unavailable") from exc
self._transport = anthropic.Anthropic()
self._transport = (
anthropic.Anthropic(api_key=self._api_key)
if self._api_key is not None
else anthropic.Anthropic()
)
return self._transport
@@ -129,10 +135,14 @@ class OpenAIToolCallingClient:
model: str,
transport: Any | None = None,
max_tokens: int = 1024,
api_key: str | None = None,
base_url: str | None = None,
) -> None:
self.model = model
self._transport = transport
self.max_tokens = max_tokens
self._api_key = api_key
self._base_url = base_url
def decide(
self,
@@ -193,7 +203,12 @@ class OpenAIToolCallingClient:
except Exception as exc:
raise ToolCallUnavailable("openai SDK is unavailable") from exc
self._transport = OpenAI()
kwargs: dict[str, str] = {}
if self._api_key is not None:
kwargs["api_key"] = self._api_key
if self._base_url is not None:
kwargs["base_url"] = self._base_url
self._transport = OpenAI(**kwargs)
return self._transport
+9
View File
@@ -40,8 +40,17 @@ def test_forward_and_downgrade_migrations_on_empty_database(tmp_path) -> None:
"cloud_auth_audit_events",
"cloud_token_reservations",
"cloud_token_usage_events",
"cloud_llm_provider_profiles",
"cloud_llm_provider_settings",
} <= table_names
assert current_revision(database_url) == HEAD_REVISION
assert (
connection_scalar(
engine,
"select revision from cloud_llm_provider_settings where id = 'global'",
)
== 0
)
host_columns = {
column["name"]
for column in inspect(engine).get_columns("host_registrations")
Generated
+2
View File
@@ -545,6 +545,7 @@ source = { editable = "packages/cloud-platform" }
dependencies = [
{ name = "alembic" },
{ name = "argon2-cffi" },
{ name = "cryptography" },
{ name = "device-agent-runtime" },
{ name = "psycopg", extra = ["binary"] },
{ name = "sqlalchemy" },
@@ -554,6 +555,7 @@ dependencies = [
requires-dist = [
{ name = "alembic", specifier = ">=1.14.0" },
{ name = "argon2-cffi", specifier = ">=25.0.0" },
{ name = "cryptography", specifier = ">=45.0.0" },
{ name = "device-agent-runtime", editable = "." },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
{ name = "sqlalchemy", specifier = ">=2.0.0" },