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
@@ -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.