// 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"
"fmt"
)
// Roles and block types, spelled out rather than inlined as string literals.
const (
RoleUser = "user"
RoleAssistant = "assistant"
BlockText = "text"
BlockThinking = "thinking"
BlockRedactedThinking = "redacted_thinking"
BlockToolUse = "tool_use"
BlockToolResult = "tool_result"
)
// 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 the harness 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"`
// 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
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
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} }
// 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 {
return err
}
*b = ContentBlock(a)
b.raw = append(json.RawMessage(nil), data...)
return nil
}
// 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 BlockThinking:
m["thinking"] = b.Thinking
if b.Signature != "" {
m["signature"] = b.Signature
}
case BlockRedactedThinking:
m["data"] = b.Data
case BlockToolUse:
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:
if len(b.raw) > 0 {
return b.raw, nil
}
return nil, fmt.Errorf("llm: cannot marshal unknown content block %q", b.Type)
}
if b.CacheControl != nil {
m["cache_control"] = b.CacheControl
}
return json.Marshal(m)
}
// 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.
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
// 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"`
}
// 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 tool calls in this response, in order.
func (r *Response) ToolUses() []ContentBlock {
var out []ContentBlock
for _, b := range r.Content {
if b.Type == BlockToolUse {
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
}