03 — Client wrappers¶
Go and Python HTTP wrappers for both Anthropic-native and OpenAI-compatible (LiteLLM) APIs, plus a unified adapter interface.
All wrappers follow existing codebase patterns:
- Go: matches rev-sci-vanguard PyICEClient — config struct,
circuit breaker → retry → HTTP, slog structured logging, interface
for testability.
- Python: matches BFF RevSciClient / PyICEClient — constructor
takes base_url + api_key, httpx.AsyncClient lifecycle, Pydantic
validation.
Go — direct Anthropic API¶
// internal/clients/anthropic/client.go
package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// -- Wire types (Claude Messages API) ----------------------------------
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"` // tool_use block id
Name string `json:"name,omitempty"` // tool_use tool name
Input json.RawMessage `json:"input,omitempty"` // tool_use arguments
ToolUseID string `json:"tool_use_id,omitempty"` // tool_result ref
Content string `json:"content,omitempty"` // tool_result payload
}
type Message struct {
Role string `json:"role"`
Content []ContentBlock `json:"content"`
}
type MessagesRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []Message `json:"messages"`
Tools []Tool `json:"tools,omitempty"`
}
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
}
type MessagesResponse struct {
ID string `json:"id"`
Content []ContentBlock `json:"content"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Usage Usage `json:"usage"`
}
// -- Client -------------------------------------------------------------
type LLMClient interface {
Messages(ctx context.Context, req MessagesRequest) (*MessagesResponse, error)
}
type ClientConfig struct {
BaseURL string
APIKey string
Model string
HTTPClient *http.Client
Logger *slog.Logger
Timeout time.Duration
}
type Client struct {
baseURL string
apiKey string
model string
httpClient *http.Client
logger *slog.Logger
cb *CircuitBreaker
}
func NewClient(cfg ClientConfig) LLMClient {
if cfg.APIKey == "" {
return &NoOpClient{logger: cfg.Logger}
}
if cfg.BaseURL == "" {
cfg.BaseURL = "https://api.anthropic.com"
}
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: cfg.Timeout}
}
return &Client{
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
model: cfg.Model,
httpClient: cfg.HTTPClient,
logger: cfg.Logger,
cb: NewCircuitBreaker(5, 30*time.Second),
}
}
func (c *Client) Messages(ctx context.Context, req MessagesRequest) (*MessagesResponse, error) {
if req.Model == "" {
req.Model = c.model
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("anthropic: marshal: %w", err)
}
var result MessagesResponse
err = c.cb.Execute(func() error {
return RetryWithBackoff(ctx, DefaultRetryConfig(), c.logger, func() error {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/v1/messages", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("anthropic: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("X-API-Key", c.apiKey)
httpReq.Header.Set("Anthropic-Version", "2023-06-01")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("anthropic: http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
return fmt.Errorf("anthropic: retryable status %d", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("anthropic: status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("anthropic: decode: %w", err)
}
return nil
})
})
if err != nil {
return nil, err
}
c.logger.Info("anthropic",
"model", result.Model,
"input_tokens", result.Usage.InputTokens,
"output_tokens", result.Usage.OutputTokens,
"cache_read", result.Usage.CacheReadInputTokens,
"stop_reason", result.StopReason,
)
return &result, nil
}
// -- No-op stub (env-gated: no ANTHROPIC_API_KEY → no-op) ---------------
type NoOpClient struct{ logger *slog.Logger }
func (n *NoOpClient) Messages(ctx context.Context, req MessagesRequest) (*MessagesResponse, error) {
n.logger.Warn("anthropic: no-op — ANTHROPIC_API_KEY not set")
return &MessagesResponse{StopReason: "no_op"}, nil
}
Go — via LiteLLM proxy (OpenAI-compatible)¶
When LiteLLM is the gateway, the Go client speaks OpenAI wire format.
Tool definitions use {"type": "function", "function": {...}}
wrapping, and responses arrive in choices[] with tool_calls[].
// internal/clients/llm/openai_client.go
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// -- Wire types (OpenAI-compatible, as emitted by LiteLLM) --------------
type FunctionDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
type ToolDef struct {
Type string `json:"type"` // "function"
Function FunctionDef `json:"function"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON string
} `json:"function"`
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Tools []ToolDef `json:"tools,omitempty"`
}
type ChatChoice struct {
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
type ChatUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type ChatResponse struct {
ID string `json:"id"`
Choices []ChatChoice `json:"choices"`
Usage ChatUsage `json:"usage"`
Model string `json:"model"`
}
// -- Client (targets LiteLLM or any OpenAI-compatible endpoint) ---------
type OpenAIClient struct {
baseURL string
apiKey string
httpClient *http.Client
logger *slog.Logger
cb *CircuitBreaker
}
type OpenAIClientConfig struct {
BaseURL string // e.g. "http://litellm:4000"
APIKey string // LiteLLM master key
HTTPClient *http.Client
Logger *slog.Logger
Timeout time.Duration
}
func NewOpenAIClient(cfg OpenAIClientConfig) *OpenAIClient {
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: cfg.Timeout}
}
return &OpenAIClient{
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
httpClient: cfg.HTTPClient,
logger: cfg.Logger,
cb: NewCircuitBreaker(5, 30*time.Second),
}
}
func (c *OpenAIClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("llm: marshal: %w", err)
}
var result ChatResponse
err = c.cb.Execute(func() error {
return RetryWithBackoff(ctx, DefaultRetryConfig(), c.logger, func() error {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == 429 || resp.StatusCode >= 500 {
return fmt.Errorf("llm: retryable status %d", resp.StatusCode)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("llm: status %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(&result)
})
})
if err != nil {
return nil, err
}
c.logger.Info("llm",
"model", result.Model,
"prompt_tokens", result.Usage.PromptTokens,
"completion_tokens", result.Usage.CompletionTokens,
"finish_reason", result.Choices[0].FinishReason,
)
return &result, nil
}
Python — direct Anthropic API¶
# bff/app/clients/anthropic_client.py
from __future__ import annotations
import httpx
from pydantic import BaseModel
class Tool(BaseModel):
name: str
description: str
input_schema: dict
class ContentBlock(BaseModel):
type: str
text: str | None = None
id: str | None = None # tool_use
name: str | None = None # tool_use
input: dict | None = None # tool_use
tool_use_id: str | None = None # tool_result
content: str | None = None # tool_result
class Message(BaseModel):
role: str
content: str | list[ContentBlock]
class MessagesRequest(BaseModel):
model: str
max_tokens: int
system: str | None = None
messages: list[Message]
tools: list[Tool] | None = None
class Usage(BaseModel):
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int = 0
cache_read_input_tokens: int = 0
class MessagesResponse(BaseModel):
id: str
content: list[ContentBlock]
model: str
stop_reason: str
usage: Usage
class AnthropicClient:
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0):
self._base_url = base_url or "https://api.anthropic.com"
self._api_key = api_key
self._http = httpx.AsyncClient(
base_url=self._base_url,
timeout=timeout,
headers={
"X-API-Key": api_key,
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
},
)
async def messages(self, req: MessagesRequest) -> MessagesResponse:
resp = await self._http.post("/v1/messages", content=req.model_dump_json())
resp.raise_for_status()
return MessagesResponse.model_validate(resp.json())
async def aclose(self) -> None:
await self._http.aclose()
class NoOpAnthropicClient:
"""Returned when ANTHROPIC_API_KEY is unset."""
async def messages(self, req: MessagesRequest) -> MessagesResponse:
return MessagesResponse(
id="noop", content=[], model="noop",
stop_reason="no_op", usage=Usage(input_tokens=0, output_tokens=0),
)
async def aclose(self) -> None:
pass
def create_anthropic_client(
api_key: str | None, base_url: str = "",
) -> AnthropicClient | NoOpAnthropicClient:
if not api_key:
return NoOpAnthropicClient()
return AnthropicClient(base_url=base_url, api_key=api_key)
Python — via LiteLLM proxy¶
Same httpx pattern, OpenAI-format wire shapes.
# bff/app/clients/llm_client.py
from __future__ import annotations
import httpx
from pydantic import BaseModel
class FunctionDef(BaseModel):
name: str
description: str
parameters: dict
class ToolDef(BaseModel):
type: str = "function"
function: FunctionDef
class FunctionCallResult(BaseModel):
name: str
arguments: str # JSON string
class ToolCall(BaseModel):
id: str
type: str
function: FunctionCallResult
class ChatMessage(BaseModel):
role: str
content: str | None = None
tool_calls: list[ToolCall] | None = None
tool_call_id: str | None = None
class ChatRequest(BaseModel):
model: str
messages: list[ChatMessage]
tools: list[ToolDef] | None = None
class ChatChoice(BaseModel):
message: ChatMessage
finish_reason: str
class ChatUsage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class ChatResponse(BaseModel):
id: str
choices: list[ChatChoice]
usage: ChatUsage
model: str
class LLMClient:
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0):
self._http = httpx.AsyncClient(
base_url=base_url,
timeout=timeout,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
)
async def chat(self, req: ChatRequest) -> ChatResponse:
resp = await self._http.post(
"/v1/chat/completions", content=req.model_dump_json(),
)
resp.raise_for_status()
return ChatResponse.model_validate(resp.json())
async def aclose(self) -> None:
await self._http.aclose()
Unified adapter interface¶
Application code should not care which provider is behind the wall.
// internal/clients/llm/adapter.go
package llm
import "context"
type ToolResult struct {
ToolName string
Input json.RawMessage
CallID string
}
type LLMResponse struct {
Text string
ToolCalls []ToolResult
StopReason string
InputTokens int
OutputTokens int
}
type LLM interface {
Call(ctx context.Context, system string, messages []Msg, tools []ToolSchema) (*LLMResponse, error)
}
Both Client (Anthropic-native) and OpenAIClient (LiteLLM)
implement this interface by mapping their wire types into the common
LLMResponse. Activity code imports llm.LLM and never sees the
provider difference. DI wiring in cmd/server/ picks the
implementation based on config.