This commit is contained in:
@@ -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:"))
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+15
-3
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user