Vectrum
Sign In
451 lines · 16.4 KB · Go
// 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"
	"strings"
)

// 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 the text of the result — what every reader
	// that only understands text gets. Blocks is set when the result is
	// more than text can carry, an image above all: then the API is sent the
	// blocks and Content holds their text so the transcript, the log and the
	// staleness check still have something to show. A web_search_tool_result
	// carries an array of a different shape here, which is left to raw (see
	// UnmarshalJSON) rather than modelled.
	ToolUseID string         `json:"tool_use_id,omitempty"`
	Content   string         `json:"content,omitempty"`
	Blocks    []ContentBlock `json:"-"`
	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}
}

// ToolResultBlocks builds a successful reply whose content is blocks rather
// than a string — a caption and an image, typically. Content is filled with
// the text blocks so the transcript and the log have words where the model
// has a picture. With no blocks it is an ordinary empty result.
func ToolResultBlocks(toolUseID string, blocks []ContentBlock) ContentBlock {
	if len(blocks) == 0 {
		return ToolResultBlock(toolUseID, "", false)
	}
	var text []string
	for _, b := range blocks {
		if b.Type == BlockText && b.Text != "" {
			text = append(text, b.Text)
		}
	}
	return ContentBlock{Type: BlockToolResult, ToolUseID: toolUseID, Content: strings.Join(text, "\n"), Blocks: blocks}
}

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...)
	if b.Type == BlockToolResult {
		// Our own tool_result with block content, read back from a saved
		// session. Recover the blocks and the caption; only a well-formed
		// array counts, anything else stays as it was in raw.
		var probe struct {
			Content json.RawMessage `json:"content"`
		}
		if json.Unmarshal(data, &probe) == nil && len(probe.Content) > 0 && probe.Content[0] == '[' {
			var blocks []ContentBlock
			if json.Unmarshal(probe.Content, &blocks) == nil {
				rebuilt := ToolResultBlocks(b.ToolUseID, blocks)
				b.Blocks, b.Content = rebuilt.Blocks, rebuilt.Content
			}
		}
	}
	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
		if len(b.Blocks) > 0 {
			m["content"] = b.Blocks
		} else {
			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
}