mirror of
https://github.com/lllllllama/RigorPilot-Skills.git
synced 2026-09-14 13:43:27 +08:00
115 lines
6.1 KiB
Python
115 lines
6.1 KiB
Python
"""Small Anthropic Messages client. Credentials remain in environment variables."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import http.client
|
|
import math
|
|
import os
|
|
import socket
|
|
import ssl
|
|
import urllib.error
|
|
import urllib.request
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
class ProviderError(RuntimeError):
|
|
"""Public diagnostic fields contain classifications, never response bodies."""
|
|
|
|
def __init__(self, message: str, *, code: str = "provider_error", http_status: int | None = None,
|
|
errno: int | None = None):
|
|
super().__init__(message)
|
|
self.diagnostic = {"code": code}
|
|
if http_status is not None:
|
|
self.diagnostic["http_status"] = http_status
|
|
if errno is not None:
|
|
self.diagnostic["errno"] = errno
|
|
|
|
|
|
def transport_error(error: object) -> ProviderError:
|
|
if isinstance(error, ssl.SSLError):
|
|
code = "tls_error"
|
|
elif isinstance(error, socket.gaierror):
|
|
code = "dns_error"
|
|
elif isinstance(error, TimeoutError):
|
|
code = "timeout"
|
|
elif isinstance(error, http.client.HTTPException):
|
|
code = "invalid_response"
|
|
else:
|
|
code = "connection_error"
|
|
number = getattr(error, "errno", None)
|
|
if code not in {"connection_error", "dns_error"} or type(number) is not int or not -65535 <= number <= 65535:
|
|
number = None
|
|
return ProviderError(f"Provider {code}; request not automatically retried", code=code, errno=number)
|
|
|
|
|
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
raise ProviderError("Provider redirect refused; configure its final endpoint",
|
|
code="redirect_refused", http_status=code)
|
|
|
|
|
|
class AnthropicProvider:
|
|
def __init__(self, profile: dict):
|
|
self.profile = profile
|
|
self.parameters = self._parameters(profile.get("parameters", {}))
|
|
|
|
@staticmethod
|
|
def _parameters(parameters: dict) -> dict:
|
|
# Never record an experiment configuration that the transport silently ignores.
|
|
# Model-specific support is still checked by the endpoint; no guessed defaults.
|
|
if not isinstance(parameters, dict) or set(parameters) - {"temperature", "top_p", "stop_sequences"}:
|
|
raise ProviderError("Unsupported provider parameters; supported: temperature, top_p, stop_sequences", code="invalid_configuration")
|
|
if "temperature" in parameters and "top_p" in parameters:
|
|
raise ProviderError("Configure temperature or top_p, not both", code="invalid_configuration")
|
|
for key in ("temperature", "top_p"):
|
|
if key in parameters:
|
|
value = parameters[key]
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not 0 <= value <= 1:
|
|
raise ProviderError(f"{key} must be a finite number between 0 and 1", code="invalid_configuration")
|
|
if "stop_sequences" in parameters:
|
|
stops = parameters["stop_sequences"]
|
|
if not isinstance(stops, list) or len(stops) > 16 or any(not isinstance(s, str) or not s or len(s) > 256 for s in stops):
|
|
raise ProviderError("stop_sequences must contain at most 16 nonempty strings of at most 256 characters", code="invalid_configuration")
|
|
return dict(parameters)
|
|
|
|
def complete(self, messages: list, system: str, tools: list, max_tokens: int, timeout: float) -> dict:
|
|
profile = self.profile
|
|
endpoint = str(profile.get("endpoint") or os.getenv("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
|
|
parsed = urlsplit(endpoint)
|
|
if parsed.scheme not in {"https", "http"} or not parsed.hostname or (parsed.scheme == "http" and parsed.hostname not in {"127.0.0.1", "localhost"}):
|
|
raise ProviderError("Provider requires HTTPS (except a local test server)", code="invalid_configuration")
|
|
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
raise ProviderError("Provider endpoint must not contain credentials, query or fragment", code="invalid_configuration")
|
|
credential = os.getenv(profile.get("credential_env") or "ANTHROPIC_API_KEY")
|
|
if not credential:
|
|
raise ProviderError("Configured credential environment variable is missing", code="missing_credential")
|
|
url = endpoint if endpoint.endswith("/messages") else endpoint + ("/messages" if endpoint.endswith("/v1") else "/v1/messages")
|
|
headers = {"content-type": "application/json", "anthropic-version": "2023-06-01"}
|
|
if profile.get("metadata", {}).get("auth_scheme") == "bearer":
|
|
headers["Authorization"] = "Bearer " + credential
|
|
else:
|
|
headers["x-api-key"] = credential
|
|
payload = {"model": profile["model"], "system": system, "messages": messages,
|
|
"tools": tools, "max_tokens": max_tokens, **self.parameters}
|
|
request = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=headers)
|
|
try:
|
|
with urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) as response:
|
|
data = response.read(2_000_001)
|
|
except urllib.error.HTTPError as exc:
|
|
# Never persist provider bodies or headers: gateways may echo credentials.
|
|
raise ProviderError(f"Provider HTTP {exc.code}; request not automatically retried",
|
|
code="http_error", http_status=exc.code) from None
|
|
except urllib.error.URLError as exc:
|
|
raise transport_error(exc.reason) from None
|
|
except (OSError, http.client.HTTPException) as exc:
|
|
raise transport_error(exc) from None
|
|
if len(data) > 2_000_000:
|
|
raise ProviderError("Provider response exceeds size limit", code="response_too_large")
|
|
try:
|
|
result = json.loads(data)
|
|
except (ValueError, UnicodeError):
|
|
raise ProviderError("Provider response is not valid JSON", code="invalid_json") from None
|
|
if not isinstance(result, dict) or not isinstance(result.get("content"), list):
|
|
raise ProviderError("Provider response has no content blocks", code="invalid_response")
|
|
return result
|