first commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user