Vectrum
Sign In
254 lines · 7.6 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"
	"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 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"`

	// 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"`
}

// 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 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
}