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
+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)