Skip to content

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.