// Package llm is a hand-rolled client for the Anthropic Messages API.
//
// The official anthropic-sdk-go would cover most of this; the types are written
// out here because understanding the wire format is the point of the project,
// and Phase 5 hand-accumulates streaming tool-call JSON.
package llm
import (
"encoding/json"
"errors"
"fmt"
)
// Roles and block types, spelled out rather than inlined as string literals.
const (
RoleUser = "user"
RoleAssistant = "assistant"
BlockText = "text"
BlockImage = "image"
BlockThinking = "thinking"
BlockRedactedThinking = "redacted_thinking"
BlockToolUse = "tool_use"
BlockToolResult = "tool_result"
// BlockServerToolUse and BlockWebSearchToolResult are the pair a hosted
// web search produces. The API runs the tool and supplies both blocks;
// Codey only has to show them and send them back unchanged. They are kept
// distinct from tool_use/tool_result so nothing local ever dispatches one.
BlockServerToolUse = "server_tool_use"
BlockWebSearchToolResult = "web_search_tool_result"
)
// WebSearchToolType and WebSearchToolName identify Anthropic's hosted web
// search in the tools array. The type carries a date because the API versions
// hosted tools by it; the name is fixed by the API and is what comes back in
// server_tool_use.name.
//
// This is deliberately the oldest version rather than the newest. Later ones
// (web_search_20260209 onwards) add dynamic filtering, where search results are
// reduced by code the model writes before they reach the context window — worth
// having, and gated on Opus 4.6 and later. Codey sends whatever model id the
// user names, including ones only a gateway knows about, so pinning the newest
// here would 400 the entire request for anyone on an older model. Choosing the
// version from the model in force is the fix; until that exists, this is the
// one that works everywhere the hosted tool works at all.
const (
WebSearchToolType = "web_search_20250305"
WebSearchToolName = "web_search"
)
// Stop reasons the loop has to distinguish.
const (
StopEndTurn = "end_turn"
StopMaxTokens = "max_tokens"
StopToolUse = "tool_use"
StopPauseTurn = "pause_turn"
StopRefusal = "refusal"
)
// Message is one turn of conversation.
type Message struct {
Role string `json:"role"`
Content []ContentBlock `json:"content"`
}
// ContentBlock is the flattened union of every block type Codey handles.
// Only the fields belonging to Type are serialized, so blocks survive the
// round-trip back to the API — which matters most for thinking blocks, whose
// signature must be echoed back unchanged.
type ContentBlock struct {
Type string `json:"type"`
// text
Text string `json:"text,omitempty"`
// image
Source *ImageSource `json:"source,omitempty"`
// thinking / redacted_thinking. Thinking is deliberately not omitempty:
// with display omitted (the default on Opus 5) the text is empty but the
// field must still be sent back.
Thinking string `json:"thinking"`
Signature string `json:"signature,omitempty"`
Data string `json:"data,omitempty"`
// tool_use, and server_tool_use for a hosted tool the API ran itself.
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result. Content is a string because that is all Codey ever sends;
// a web_search_tool_result carries an array here instead, which is left
// to raw (see UnmarshalJSON) rather than widening the type for every
// caller that reads a local tool's output.
ToolUseID string `json:"tool_use_id,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
// CacheControl marks this block as a cache breakpoint.
CacheControl *CacheControl `json:"cache_control,omitempty"`
// raw holds the original bytes of a block type this struct does not model,
// so an unfamiliar block still round-trips instead of being flattened away.
raw json.RawMessage
}
func TextBlock(s string) ContentBlock { return ContentBlock{Type: BlockText, Text: s} }
// ImageSource is where the bytes of an image block come from. Only base64 is
// modelled: a URL source would have the API fetch the image itself, which is
// the wrong shape for pictures pasted off a local clipboard.
type ImageSource struct {
Type string `json:"type"`
MediaType string `json:"media_type"`
Data string `json:"data"`
}
// ImageBlock wraps already-encoded base64 bytes. mediaType is one of the four
// the API accepts — image/png, image/jpeg, image/gif, image/webp.
func ImageBlock(mediaType, b64 string) ContentBlock {
return ContentBlock{Type: BlockImage, Source: &ImageSource{Type: "base64", MediaType: mediaType, Data: b64}}
}
// ToolResultBlock builds the reply to one tool_use. Every tool_use id must get
// one of these, including failures — a missing result is an API error.
func ToolResultBlock(toolUseID, content string, isError bool) ContentBlock {
if content == "" {
// An empty content field is rejected; say something instead.
content = "(no output)"
}
return ContentBlock{Type: BlockToolResult, ToolUseID: toolUseID, Content: content, IsError: isError}
}
func (b *ContentBlock) UnmarshalJSON(data []byte) error {
type alias ContentBlock
var a alias
if err := json.Unmarshal(data, &a); err != nil {
// A web_search_tool_result carries "content" as an array of results
// or an error object, not the string this struct models. Rejecting it
// would take the whole response down with it, and the bytes are kept
// in raw for the round trip anyway, so a type mismatch on that one
// field is tolerated. Anything else is still a malformed block.
var typeErr *json.UnmarshalTypeError
if !errors.As(err, &typeErr) || typeErr.Field != "content" {
return err
}
}
*b = ContentBlock(a)
b.raw = append(json.RawMessage(nil), data...)
return nil
}
// Raw returns the block exactly as it arrived from the API, or nil for a block
// built locally. It exists for block types this struct does not model field by
// field — a web_search_tool_result's content, above all — so a caller who wants
// to look inside one can, without the struct growing a field per hosted tool.
func (b ContentBlock) Raw() json.RawMessage { return b.raw }
// MarshalJSON emits exactly the fields that belong to Type. Marshaling the
// whole struct would send stray keys (an empty "thinking" on every text block)
// and the API rejects some of those.
func (b ContentBlock) MarshalJSON() ([]byte, error) {
m := map[string]any{"type": b.Type}
switch b.Type {
case BlockText:
m["text"] = b.Text
case BlockImage:
if b.Source == nil {
return nil, fmt.Errorf("llm: image block has no source")
}
m["source"] = b.Source
case BlockThinking:
m["thinking"] = b.Thinking
if b.Signature != "" {
m["signature"] = b.Signature
}
case BlockRedactedThinking:
m["data"] = b.Data
case BlockToolUse, BlockServerToolUse:
// server_tool_use is deliberately not left to the raw path below.
// When streamed, raw is the content_block_start snapshot in which
// "input" is still {}; the query arrives afterwards as input_json_delta
// fragments and lands in Input. Re-sending the snapshot after
// pause_turn would hand the API a search with no query.
m["id"] = b.ID
m["name"] = b.Name
if len(b.Input) == 0 {
m["input"] = json.RawMessage("{}")
} else {
m["input"] = b.Input
}
case BlockToolResult:
m["tool_use_id"] = b.ToolUseID
m["content"] = b.Content
if b.IsError {
m["is_error"] = true
}
default:
// Everything else — web_search_tool_result included — goes back as it
// came. That block arrives whole, with no deltas, so raw is complete;
// and its content is the API's, encrypted, and not ours to reshape.
if len(b.raw) == 0 {
return nil, fmt.Errorf("llm: cannot marshal unknown content block %q", b.Type)
}
if b.CacheControl == nil {
return b.raw, nil
}
return withCacheControl(b.raw, b.CacheControl)
}
if b.CacheControl != nil {
m["cache_control"] = b.CacheControl
}
return json.Marshal(m)
}
// withCacheControl attaches a cache breakpoint to a raw block. The loop puts
// its rolling breakpoint on the last block of the last message, and after a
// pause_turn that block is usually a web_search_tool_result — returning raw
// unchanged there would silently drop the breakpoint and miss the cache on
// every search turn. Values are kept as json.RawMessage so nothing inside the
// block (numbers, encrypted payloads) is reinterpreted on the way through.
func withCacheControl(raw json.RawMessage, cc *CacheControl) ([]byte, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil {
return nil, fmt.Errorf("llm: raw content block is not an object: %w", err)
}
ccJSON, err := json.Marshal(cc)
if err != nil {
return nil, err
}
obj["cache_control"] = ccJSON
return json.Marshal(obj)
}
// SystemBlock is a system-prompt segment. It is a list rather than a string so
// a cache breakpoint can be attached in Phase 4.
type SystemBlock struct {
Type string `json:"type"`
Text string `json:"text"`
CacheControl *CacheControl `json:"cache_control,omitempty"`
}
type CacheControl struct {
Type string `json:"type"`
TTL string `json:"ttl,omitempty"`
}
func Ephemeral() *CacheControl { return &CacheControl{Type: "ephemeral"} }
// ToolDef is one entry of the request's tools array. It covers two shapes: a
// local tool, declared by name, description and input_schema; and a hosted
// tool, declared by type and name alone, which the API runs itself.
//
// Description and InputSchema are omitempty because of the second shape. A
// hosted tool has no schema — the API owns it — and a non-omitempty
// InputSchema would send "input_schema":null, which is a 400. Local tools
// always fill both, so they lose nothing.
type ToolDef struct {
// Type is set only for a hosted tool, e.g. WebSearchToolType. A local
// tool leaves it empty and the API infers the custom-tool shape.
Type string `json:"type,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"`
// The remaining fields are hosted web search knobs. MaxUses caps the
// searches in one response; zero means the API default rather than none,
// hence omitempty. AllowedDomains and BlockedDomains are mutually
// exclusive on the API side, and it is a 400 to send both.
MaxUses int `json:"max_uses,omitempty"`
AllowedDomains []string `json:"allowed_domains,omitempty"`
BlockedDomains []string `json:"blocked_domains,omitempty"`
}
// WebSearchTool is the tools-array entry for Anthropic's hosted web search.
// maxUses <= 0 leaves the cap to the API default. The definition is constant
// for a session, which is what lets it sit in the cached prompt prefix.
func WebSearchTool(maxUses int) ToolDef {
def := ToolDef{Type: WebSearchToolType, Name: WebSearchToolName}
if maxUses > 0 {
def.MaxUses = maxUses
}
return def
}
// Thinking configures extended thinking. Adaptive is the only mode current
// models accept; budget_tokens is a 400 on Opus 5.
type Thinking struct {
Type string `json:"type"`
Display string `json:"display,omitempty"`
}
func Adaptive() *Thinking { return &Thinking{Type: "adaptive"} }
// OutputConfig carries the effort knob. Omitted means the default, "high".
type OutputConfig struct {
Effort string `json:"effort,omitempty"`
}
// EffortLevels are the values output_config.effort accepts, cheapest first.
// Anything else is a 400 from the API, so callers check before sending.
var EffortLevels = []string{"low", "medium", "high", "xhigh", "max"}
// ValidEffort reports whether level is one the API accepts. The empty string
// is not: it means "say nothing", which callers handle by omitting the field.
func ValidEffort(level string) bool {
for _, l := range EffortLevels {
if l == level {
return true
}
}
return false
}
// Request is the body of POST /v1/messages.
type Request struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System []SystemBlock `json:"system,omitempty"`
Messages []Message `json:"messages"`
Tools []ToolDef `json:"tools,omitempty"`
Thinking *Thinking `json:"thinking,omitempty"`
OutputConfig *OutputConfig `json:"output_config,omitempty"`
Stream bool `json:"stream,omitempty"`
}
// Usage is the token accounting returned with every response.
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
}
// StopDetails is populated only when StopReason is "refusal".
type StopDetails struct {
Type string `json:"type"`
Category string `json:"category"`
Explanation string `json:"explanation"`
}
// Response is one assistant turn.
type Response struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
Model string `json:"model"`
Content []ContentBlock `json:"content"`
StopReason string `json:"stop_reason"`
StopDetails *StopDetails `json:"stop_details"`
Usage Usage `json:"usage"`
}
// Message converts the response into a history entry.
func (r *Response) Message() Message {
return Message{Role: RoleAssistant, Content: r.Content}
}
// ToolUses returns the local tool calls in this response, in order. It matches
// tool_use alone, never server_tool_use: everything it returns is dispatched
// to the local registry and owed a tool_result, and a hosted call is neither.
func (r *Response) ToolUses() []ContentBlock {
var out []ContentBlock
for _, b := range r.Content {
if b.Type == BlockToolUse {
out = append(out, b)
}
}
return out
}
// ServerToolUses returns the hosted tool calls the API ran on this turn, in
// order. They are for showing, not dispatching: the API has already executed
// them and put each result in the same response.
func (r *Response) ServerToolUses() []ContentBlock {
var out []ContentBlock
for _, b := range r.Content {
if b.Type == BlockServerToolUse {
out = append(out, b)
}
}
return out
}
// Text concatenates the response's text blocks.
func (r *Response) Text() string {
var s string
for _, b := range r.Content {
if b.Type == BlockText {
s += b.Text
}
}
return s
}
// Thinking concatenates the reasoning the model showed.
//
// Redacted blocks contribute nothing. They are encrypted bytes that have to go
// back to the API untouched on the next request, and there is no text in them
// for anyone to read.
func (r *Response) Thinking() string {
var s string
for _, b := range r.Content {
if b.Type == BlockThinking {
s += b.Thinking
}
}
return s
}