feat(cloud): support Anthropic provider base URL
Tests / Test passed: 665

This commit is contained in:
2026-07-14 07:58:38 +08:00
parent 8b7e5a2800
commit 9250254dec
13 changed files with 124 additions and 39 deletions
@@ -40,6 +40,15 @@ def test_build_cloud_planner_client_uses_managed_anthropic_key() -> None:
assert client._api_key == "managed-api-key"
def test_build_cloud_planner_client_uses_anthropic_base_url() -> None:
client = build_cloud_planner_client(
_resolved_profile("anthropic", base_url="https://anthropic-proxy.example")
)
assert isinstance(client, AnthropicToolCallingClient)
assert client._base_url == "https://anthropic-proxy.example"
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")
@@ -136,6 +136,29 @@ def test_provider_profile_requires_admin_scope(monkeypatch) -> None:
)
def test_anthropic_profile_accepts_custom_base_url(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)
response = client.post(
"/v1/planner/providers",
headers=_csrf_headers(client),
json=_profile_payload(
name="Anthropic proxy",
provider_type="anthropic",
base_url="https://anthropic-proxy.example/",
),
)
assert response.status_code == 201, response.text
assert response.json()["base_url"] == "https://anthropic-proxy.example"
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:"))
+3 -3
View File
@@ -98,7 +98,7 @@ async function saveProfile() {
saving.value = true;
try {
const baseUrl = form.provider_type === "anthropic" ? null : form.base_url.trim() || null;
const baseUrl = 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");
@@ -195,8 +195,8 @@ onMounted(refresh);
</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 class="field-full">Base URL
<input v-model="form.base_url" placeholder="Official provider 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" />
+4 -3
View File
@@ -387,9 +387,10 @@ mode:
`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.
Both accept an optional absolute HTTP(S) Base URL; leave it blank for the
official provider endpoint. The configured endpoint must accept the selected
provider's existing request schema and authentication; custom headers or
incompatible parameter dialects 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
@@ -10,9 +10,9 @@ 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.
therefore read keys from their environment. Database-backed credentials and a
custom base URL require those clients to accept explicit values while
preserving the unchanged direct-transport default.
## Goals / Non-Goals
@@ -22,8 +22,8 @@ while preserving the unchanged direct-transport default.
- 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.
- Support custom base URLs for Anthropic native tool use and OpenAI-compatible
Chat Completions tool calling without changing their respective protocols.
- Remove Cloud API environment-based planner Provider configuration so every
cloud-transport decision is governed by the active database profile.
@@ -32,8 +32,8 @@ while preserving the unchanged direct-transport default.
- 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.
- Custom request headers or different Anthropic/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.
@@ -103,16 +103,16 @@ 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
### D4: Treat each provider wire protocol as a concrete 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.
Profiles have `provider_type` of `anthropic` or `openai-compatible`. Either
profile may omit `base_url` to use its official provider endpoint, or supply
an absolute HTTP(S) base URL for a compatible proxy or provider 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.
`AnthropicToolCallingClient` gains optional explicit API key and `base_url`
arguments and constructs `Anthropic` explicitly when supplied. 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
@@ -10,7 +10,7 @@ control plane for its planner decisions.
- 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
model, timeout, optional 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
@@ -25,10 +25,10 @@ control plane for its planner decisions.
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.
- Extend the existing provider client construction so cloud-managed profiles
pass their encrypted API key and configured base URL explicitly to the
Anthropic or OpenAI SDK, while Host Agent direct transport continues to use
its existing environment-based credentials.
## Capabilities
@@ -6,7 +6,7 @@ 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
model, timeout, configured base URL, and encrypted
cloud-held credential. It SHALL NOT read Cloud API planner Provider/model/
timeout/API-key environment variables.
@@ -4,9 +4,8 @@
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.
`anthropic` or `openai-compatible`; either profile MAY specify an absolute
HTTP(S) base URL and SHALL use its official provider endpoint when it does not.
#### Scenario: Administrator creates an OpenAI-compatible profile
- **WHEN** an authorized administrator submits a unique profile name,
@@ -15,6 +14,13 @@ it does not.
- **THEN** the Cloud Control Plane persists an enabled profile with a new
revision and returns its non-secret metadata
#### Scenario: Administrator creates an Anthropic profile with a custom base URL
- **WHEN** an authorized administrator submits a unique profile name,
`anthropic` provider type, model, valid timeout, API key, and a valid
Anthropic-compatible base URL
- **THEN** the Cloud Control Plane persists the normalized URL with the profile
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
@@ -81,6 +87,12 @@ reconfiguration.
tool-calling request to that base URL using the profile's decrypted API key
and returns the resulting single tool-call decision
#### Scenario: Active Anthropic profile is used with a custom base URL
- **WHEN** the active profile is Anthropic and includes a base URL
- **THEN** the Cloud Control Plane makes the existing Anthropic native
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
@@ -26,3 +26,9 @@
- [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.
## 6. Anthropic custom base URL
- [x] 6.1 Allow Anthropic Provider profiles to validate and persist an optional custom base URL, and pass it to the Cloud planner client.
- [x] 6.2 Extend `AnthropicToolCallingClient` to construct the Anthropic SDK with an explicit base URL while preserving direct-transport defaults.
- [x] 6.3 Expose the Base URL field for Anthropic in the Cloud Console; add backend/runtime tests and update deployment/specification documentation.
@@ -96,10 +96,6 @@ def validate_profile_input(
"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,
@@ -21,4 +21,8 @@ def build_cloud_planner_client(
api_key=resolved.api_key,
base_url=profile.base_url,
)
return AnthropicToolCallingClient(model=profile.model, api_key=resolved.api_key)
return AnthropicToolCallingClient(
model=profile.model,
api_key=resolved.api_key,
base_url=profile.base_url,
)
+8 -5
View File
@@ -47,11 +47,13 @@ class AnthropicToolCallingClient:
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,
@@ -120,11 +122,12 @@ class AnthropicToolCallingClient:
except Exception as exc:
raise ToolCallUnavailable("anthropic SDK is unavailable") from exc
self._transport = (
anthropic.Anthropic(api_key=self._api_key)
if self._api_key is not None
else anthropic.Anthropic()
)
client_kwargs: dict[str, str] = {}
if self._api_key is not None:
client_kwargs["api_key"] = self._api_key
if self._base_url is not None:
client_kwargs["base_url"] = self._base_url
self._transport = anthropic.Anthropic(**client_kwargs)
return self._transport
+31
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import base64
import sys
from typing import Any
from types import SimpleNamespace
import pytest
@@ -124,6 +126,35 @@ def test_anthropic_tool_calling_client_includes_image_block_when_screenshot_pres
assert content[1] == {"type": "text", "text": "user"}
def test_anthropic_tool_calling_client_passes_custom_base_url_to_sdk(
monkeypatch,
) -> None:
constructed: list[dict[str, Any]] = []
class RecordingAnthropic:
def __init__(self, **kwargs: str) -> None:
constructed.append(kwargs)
monkeypatch.setitem(
sys.modules,
"anthropic",
SimpleNamespace(Anthropic=RecordingAnthropic),
)
client = AnthropicToolCallingClient(
model="test-model",
api_key="managed-api-key",
base_url="https://anthropic-proxy.example",
)
assert isinstance(client._client(), RecordingAnthropic)
assert constructed == [
{
"api_key": "managed-api-key",
"base_url": "https://anthropic-proxy.example",
}
]
def test_anthropic_tool_calling_client_wraps_transport_errors() -> None:
messages = FakeMessages(error=TimeoutError("timed out"))
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))