Files
BlackBean/pigo/internal/provider/anthropic.go
T
2026-08-14 23:41:57 +08:00

335 lines
10 KiB
Go

// This file implements the Anthropic Messages API streaming decoder (US-008).
// It is a stateful Decoder (see transport.go) that translates Anthropic SSE
// event payloads into the provider-agnostic AssistantMessageEvent set,
// accumulating a partial AssistantMessage as deltas arrive.
//
// Anthropic streams a fixed event sequence:
//
// message_start → seeds id/model and initial usage (input tokens)
// content_block_start → opens a text / thinking / tool_use block at an index
// content_block_delta → text_delta / thinking_delta / signature_delta /
// input_json_delta append to the open block
// content_block_stop → closes the block (tool_use JSON is parsed here)
// message_delta → carries the final stop_reason and output-token usage
// message_stop → terminal; the accumulated message is emitted as done
// error → a runtime error payload → terminal error event
//
// Per the dual failure model (FR-13) the decoder never panics: malformed
// payloads and Anthropic `error` events are surfaced as a returned error (which
// the transport turns into a terminal StreamErrorEvent) rather than crashing.
package provider
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/smallnest/pigo/internal/agentcore"
)
// anthropicBlock accumulates one content block's streaming state, keyed by its
// Anthropic content-block index. text/thinking append their deltas; tool_use
// accumulates a partial JSON string parsed lazily when the block is realized.
type anthropicBlock struct {
kind string // "text" | "thinking" | "tool_use" | "redacted_thinking"
text strings.Builder
thinking strings.Builder
thinkingSig string
textSig string
toolID string
toolName string
toolJSON strings.Builder
redacted bool
}
// AnthropicDecoder is the stateful SSE decoder for the Anthropic Messages API.
// It implements the transport Decoder interface. It is not safe for concurrent
// use — the transport drives it from a single goroutine.
type AnthropicDecoder struct {
blocks map[int]*anthropicBlock
order []int // content-block indices in first-seen order
responseID string
responseModel string
inputTokens int
outputTokens int
stopReason string // mapped pigo stop reason (empty until message_delta)
done bool // message_stop / done already emitted
}
// NewAnthropicDecoder builds a fresh decoder for one streamed response.
func NewAnthropicDecoder() *AnthropicDecoder {
return &AnthropicDecoder{blocks: make(map[int]*anthropicBlock)}
}
// anthropicEvent is the discriminated envelope shared by every Anthropic SSE
// data payload; fields are populated selectively by event type.
type anthropicEvent struct {
Type string `json:"type"`
Index int `json:"index"`
Message *struct {
ID string `json:"id"`
Model string `json:"model"`
Usage *anthropicUsage `json:"usage"`
} `json:"message"`
ContentBlock *struct {
Type string `json:"type"`
// text
Text string `json:"text"`
// thinking
Thinking string `json:"thinking"`
// tool_use
ID string `json:"id"`
Name string `json:"name"`
} `json:"content_block"`
Delta *struct {
Type string `json:"type"`
// text_delta
Text string `json:"text"`
// thinking_delta
Thinking string `json:"thinking"`
// signature_delta
Signature string `json:"signature"`
// input_json_delta
PartialJSON string `json:"partial_json"`
// message_delta
StopReason string `json:"stop_reason"`
} `json:"delta"`
Usage *anthropicUsage `json:"usage"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// Decode turns one Anthropic SSE data payload into zero or more StreamEvents.
func (d *AnthropicDecoder) Decode(payload []byte) ([]StreamEvent, error) {
var ev anthropicEvent
if err := json.Unmarshal(payload, &ev); err != nil {
return nil, fmt.Errorf("anthropic: parse event: %w", err)
}
switch ev.Type {
case "message_start":
return d.onMessageStart(ev), nil
case "content_block_start":
return d.onBlockStart(ev), nil
case "content_block_delta":
return d.onBlockDelta(ev), nil
case "content_block_stop":
// Nothing to emit on stop; the block is already reflected in the partial.
return nil, nil
case "message_delta":
return d.onMessageDelta(ev), nil
case "message_stop":
return d.finishDone(), nil
case "ping":
return nil, nil
case "error":
msg := "anthropic stream error"
if ev.Error != nil {
if ev.Error.Type != "" {
msg = "anthropic " + ev.Error.Type
}
if ev.Error.Message != "" {
msg += ": " + ev.Error.Message
}
}
return nil, fmt.Errorf("%s", msg)
default:
// Unknown event types are ignored (forward-compatible).
return nil, nil
}
}
// Finish flushes a terminal done event if the stream ended without an explicit
// message_stop (e.g. a clean EOF mid-stream), so a partial response is still
// delivered rather than lost.
func (d *AnthropicDecoder) Finish() ([]StreamEvent, error) {
if d.done {
return nil, nil
}
return d.finishDone(), nil
}
func (d *AnthropicDecoder) onMessageStart(ev anthropicEvent) []StreamEvent {
if ev.Message != nil {
d.responseID = ev.Message.ID
d.responseModel = ev.Message.Model
if ev.Message.Usage != nil {
d.inputTokens = ev.Message.Usage.InputTokens
d.outputTokens = ev.Message.Usage.OutputTokens
}
}
return []StreamEvent{StreamStartEvent{Partial: d.partial()}}
}
func (d *AnthropicDecoder) onBlockStart(ev anthropicEvent) []StreamEvent {
if ev.ContentBlock == nil {
return nil
}
b := &anthropicBlock{kind: ev.ContentBlock.Type}
switch ev.ContentBlock.Type {
case "text":
b.text.WriteString(ev.ContentBlock.Text)
case "thinking":
b.thinking.WriteString(ev.ContentBlock.Thinking)
case "redacted_thinking":
b.redacted = true
case "tool_use":
b.toolID = ev.ContentBlock.ID
b.toolName = ev.ContentBlock.Name
}
d.putBlock(ev.Index, b)
switch ev.ContentBlock.Type {
case "thinking", "redacted_thinking":
return []StreamEvent{StreamThinkingEvent{Partial: d.partial()}}
case "tool_use":
return []StreamEvent{StreamToolCallEvent{Partial: d.partial()}}
default:
return []StreamEvent{StreamTextEvent{Partial: d.partial()}}
}
}
func (d *AnthropicDecoder) onBlockDelta(ev anthropicEvent) []StreamEvent {
if ev.Delta == nil {
return nil
}
b := d.blocks[ev.Index]
if b == nil {
// A delta for an unseen index: open a bare block so we don't drop data.
b = &anthropicBlock{}
d.putBlock(ev.Index, b)
}
switch ev.Delta.Type {
case "text_delta":
b.text.WriteString(ev.Delta.Text)
return []StreamEvent{StreamTextEvent{Partial: d.partial()}}
case "thinking_delta":
b.thinking.WriteString(ev.Delta.Thinking)
return []StreamEvent{StreamThinkingEvent{Partial: d.partial()}}
case "signature_delta":
// Signature belongs to the thinking block it rides on.
b.thinkingSig += ev.Delta.Signature
return []StreamEvent{StreamThinkingEvent{Partial: d.partial()}}
case "input_json_delta":
b.toolJSON.WriteString(ev.Delta.PartialJSON)
return []StreamEvent{StreamToolCallEvent{Partial: d.partial()}}
default:
return nil
}
}
func (d *AnthropicDecoder) onMessageDelta(ev anthropicEvent) []StreamEvent {
if ev.Delta != nil && ev.Delta.StopReason != "" {
d.stopReason = mapAnthropicStopReason(ev.Delta.StopReason)
}
if ev.Usage != nil {
// message_delta reports cumulative output tokens (and sometimes input).
if ev.Usage.OutputTokens != 0 {
d.outputTokens = ev.Usage.OutputTokens
}
if ev.Usage.InputTokens != 0 {
d.inputTokens = ev.Usage.InputTokens
}
}
// No standalone event kind for usage/stop-reason accumulation; the values
// surface in the terminal done message.
return nil
}
// finishDone builds the terminal assistant message and marks the decoder done.
func (d *AnthropicDecoder) finishDone() []StreamEvent {
if d.done {
return nil
}
d.done = true
msg := d.partial()
if msg.StopReason == "" {
msg.StopReason = agentcore.StopReasonEndTurn
}
return []StreamEvent{StreamDoneEvent{Message: msg}}
}
// putBlock records a block at index, tracking first-seen order.
func (d *AnthropicDecoder) putBlock(index int, b *anthropicBlock) {
if _, seen := d.blocks[index]; !seen {
d.order = append(d.order, index)
}
d.blocks[index] = b
}
// partial materializes the accumulated state into an AssistantMessage. Content
// blocks are emitted in content-block index order. Tool-use JSON that has not
// yet parsed cleanly is passed through as-is (raw partial), which is valid for
// a still-streaming partial and finalized once the block completes.
func (d *AnthropicDecoder) partial() agentcore.AssistantMessage {
msg := agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
API: "anthropic",
Provider: "anthropic",
StopReason: d.stopReason,
ResponseID: d.responseID,
ResponseModel: d.responseModel,
}
if d.inputTokens != 0 || d.outputTokens != 0 {
msg.Usage = &agentcore.Usage{InputTokens: d.inputTokens, OutputTokens: d.outputTokens}
}
idx := make([]int, len(d.order))
copy(idx, d.order)
sort.Ints(idx)
for _, i := range idx {
b := d.blocks[i]
if b == nil {
continue
}
switch b.kind {
case "thinking", "redacted_thinking":
tc := agentcore.NewThinkingContent(b.thinking.String())
tc.ThinkingSignature = b.thinkingSig
tc.Redacted = b.redacted
msg.Content = append(msg.Content, tc)
case "tool_use":
args := json.RawMessage(strings.TrimSpace(b.toolJSON.String()))
if len(args) == 0 {
args = json.RawMessage("{}")
}
msg.Content = append(msg.Content, agentcore.NewToolCallContent(b.toolID, b.toolName, args))
default: // text
tc := agentcore.NewTextContent(b.text.String())
tc.TextSignature = b.textSig
msg.Content = append(msg.Content, tc)
}
}
return msg
}
// mapAnthropicStopReason maps an Anthropic stop_reason to the pigo StopReason
// set. Unknown reasons default to end_turn (a natural, non-error stop).
func mapAnthropicStopReason(reason string) string {
switch reason {
case "max_tokens":
return agentcore.StopReasonLength
case "tool_use":
return agentcore.StopReasonToolUse
case "end_turn", "stop_sequence":
return agentcore.StopReasonEndTurn
default:
return agentcore.StopReasonEndTurn
}
}