mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
The loop no longer classifies a tool-call turn that arrived without reasoning, retries it, or keeps a file-backed cooldown for it. Removes the watch and the cooldown state, the deferred stream sink they needed, the provider policy interfaces and their implementations, the descriptor field on the extension wire (schema and SDK regenerated), the state-dir path, the run-metrics counters and the protocol-recovery kinds. The protocol-recovery audit channel itself stays for now.
832 lines
29 KiB
Go
832 lines
29 KiB
Go
// Package anthropic implements the Anthropic Messages API provider (POST
|
|
// /v1/messages, SSE streaming) with a hand-written net/http client — no SDK. It
|
|
// self-registers under the "anthropic" kind, so any Claude model is a config
|
|
// instance rather than code.
|
|
//
|
|
// Two notes, both rooted in the transport-agnostic provider.Message abstraction:
|
|
//
|
|
// - Extended thinking is opt-in (provider config thinking="adaptive"). Anthropic
|
|
// requires the *signed* thinking block be replayed on the next turn when a tool
|
|
// call followed thinking, so Message carries ReasoningSignature alongside
|
|
// ReasoningContent and this provider replays the signed block on the next
|
|
// request. DeepSeek's Anthropic endpoint instead uses unsigned thinking blocks,
|
|
// thinking.type enabled|disabled, and output_config.effort; requests carrying
|
|
// tools must replay all provider reasoning. Some other compatible gateways such
|
|
// as LongCat use the binary toggle without output_config. (redacted_thinking
|
|
// blocks are not yet captured/replayed.)
|
|
// - Native Anthropic requests omit temperature/top_p. Current Claude models
|
|
// (Opus 4.8/4.7) reject sampling parameters with a 400; Anthropic steers
|
|
// behavior via prompting instead. DeepSeek's compatible endpoint accepts the
|
|
// caller's temperature, so that field is preserved only for DeepSeek.
|
|
package anthropic
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/model/openai"
|
|
)
|
|
|
|
// defaultStreamIdleTimeout caps how long a started SSE stream may go silent before
|
|
// it's treated as a dropped connection — a half-open TCP connection (proxy switched
|
|
// mid-stream) sends no RST, so scanner.Scan() would block forever. Generous on
|
|
// purpose; live streams emit far more often. Stored per-client (client.idleTimeout)
|
|
// so a test can shorten it without a shared global that races other watchdogs.
|
|
const defaultStreamIdleTimeout = provider.StreamIdleTimeout
|
|
|
|
const (
|
|
// anthropicVersion is the required API version header value.
|
|
anthropicVersion = "2023-06-01"
|
|
// defaultBaseURL is the first-party endpoint; config may override it (e.g. a
|
|
// gateway). Bedrock/Vertex use a different request shape and are out of scope.
|
|
defaultBaseURL = "https://api.anthropic.com"
|
|
// defaultMaxTokens is the mandatory Anthropic fallback when neither config
|
|
// nor request supplies max_tokens. Ordinary turns use 16K; reasoning-capable
|
|
// paths raise via AutoOutputBudget. 128K is never automatic.
|
|
defaultMaxTokens = provider.DefaultOrdinaryOutputTokens
|
|
)
|
|
|
|
func init() {
|
|
provider.Register("anthropic", New)
|
|
}
|
|
|
|
// New builds an Anthropic provider from a resolved config.
|
|
func New(cfg provider.Config) (provider.Provider, error) {
|
|
if cfg.Model == "" {
|
|
return nil, fmt.Errorf("anthropic: model is required for provider %q", cfg.Name)
|
|
}
|
|
name := cfg.Name
|
|
if name == "" {
|
|
name = "anthropic"
|
|
}
|
|
baseURL := cfg.BaseURL
|
|
if baseURL == "" {
|
|
baseURL = defaultBaseURL
|
|
}
|
|
// Anthropic's API surface is at {root}/v1/messages, so c.baseURL stores
|
|
// the *root* -- without any trailing /v1. The setup wizard, however, lets
|
|
// users paste a full OpenAI-compatible URL (e.g.
|
|
// "https://proxy.example.com/v1") because that's what /models probes
|
|
// expect. Stripping the trailing /v1 here makes both forms land on the
|
|
// same endpoint without forcing users to remember Anthropic's quirky
|
|
// root-vs-versioned split. Without this, a user pasting
|
|
// "https://proxy.example.com/v1" would probe /v1/models successfully
|
|
// but get the chat client concatenating onto
|
|
// "https://proxy.example.com/v1/v1/messages" -- a 404.
|
|
root := strings.TrimRight(baseURL, "/")
|
|
root = strings.TrimSuffix(root, "/v1")
|
|
if root == "" {
|
|
root = defaultBaseURL
|
|
}
|
|
requestURL, _ := cfg.Extra["request_url"].(string)
|
|
requestURL = strings.TrimSpace(requestURL)
|
|
if requestURL == "" || provider.EndpointOverrideRepeatsBase(requestURL, root, root+"/v1") {
|
|
requestURL = root + "/v1/messages"
|
|
}
|
|
officialDeepSeek := openai.IsDeepSeek(root)
|
|
keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors
|
|
keySource, _ := cfg.Extra["api_key_source"].(string)
|
|
thinking, effort := resolveReasoning(root, cfg.Extra)
|
|
thinking, effort, err := resolveMiniMaxReasoning(cfg, thinking, effort)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vision, _ := cfg.Extra["vision"].(bool)
|
|
// DeepSeek's Anthropic-compatible endpoint documents image source blocks, so
|
|
// the constraint is the model's rather than the endpoint's. Defense in depth,
|
|
// independent of config or extension capability metadata.
|
|
vision = vision && (!officialDeepSeek || openai.DeepSeekTakesImages(cfg.Model))
|
|
webSearch, _ := cfg.Extra["web_search"].(bool)
|
|
headers, _ := cfg.Extra["headers"].(map[string]string)
|
|
authHeader, _ := cfg.Extra["auth_header"].(bool)
|
|
maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
|
|
if maxOutputTokens <= 0 {
|
|
// Messages requires max_tokens. 0 = automatic; negative also falls back
|
|
// because the wire field is mandatory.
|
|
reasoningOn := officialDeepSeek &&
|
|
!strings.EqualFold(thinking, "disabled") &&
|
|
!strings.EqualFold(effort, "disabled") &&
|
|
!strings.EqualFold(effort, "off") &&
|
|
!strings.EqualFold(effort, "none")
|
|
if officialDeepSeek {
|
|
maxOutputTokens = provider.AutoOutputBudget(reasoningOn, effort)
|
|
} else {
|
|
// Native Anthropic and unknown gateways: conservative ordinary default.
|
|
maxOutputTokens = defaultMaxTokens
|
|
if thinking == "adaptive" || thinking == "enabled" || effort == "enabled" {
|
|
maxOutputTokens = provider.AutoOutputBudget(true, effort)
|
|
}
|
|
}
|
|
}
|
|
httpClient, err := newHTTPClient(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("anthropic: network: %w", err)
|
|
}
|
|
return &client{
|
|
name: name,
|
|
apiKey: cfg.APIKeyResolver(),
|
|
keyEnv: keyEnv,
|
|
keySource: keySource,
|
|
baseURL: root,
|
|
requestURL: requestURL,
|
|
model: cfg.Model,
|
|
nativeAnthropic: provider.IsNativeAnthropic(root),
|
|
deepseek: speaksDeepSeekContract(root, cfg.Extra),
|
|
thinking: thinking,
|
|
effort: effort,
|
|
vision: vision,
|
|
mimo: provider.IsMiMoEndpoint(root),
|
|
webSearch: webSearch,
|
|
headers: requestHeaders{custom: cleanCustomHeaders(headers), openCodeSession: provider.NewOpenCodeSessionID()},
|
|
authHeader: authHeader,
|
|
defaultMaxTokens: maxOutputTokens,
|
|
http: httpClient, // no overall timeout; lifecycle is ctx-driven
|
|
idleTimeout: provider.IdleTimeoutFromExtra(cfg.Extra),
|
|
}, nil
|
|
}
|
|
|
|
type client struct {
|
|
name string
|
|
apiKey func() string
|
|
keyEnv string // api_key_env name, surfaced in auth errors
|
|
keySource string // source of keyEnv, surfaced in auth errors
|
|
baseURL string
|
|
requestURL string
|
|
model string
|
|
nativeAnthropic bool // first-party endpoint: documented default-5m cache-write pricing applies
|
|
deepseek bool // official DeepSeek Anthropic endpoint: unsigned reasoning replay + automatic cache
|
|
thinking string // resolved wire mode: adaptive (Anthropic contract) | enabled | disabled | "" = off
|
|
effort string // output_config.effort: low|medium|high|xhigh|max; "" = provider default
|
|
vision bool // model accepts image input — embed attached images as base64 image blocks
|
|
mimo bool // true for MiMo — upgrades legacy tuple schemas to Draft 2020-12
|
|
webSearch bool // enable server-side web_search tool (DeepSeek Anthropic API)
|
|
headers requestHeaders
|
|
authHeader bool // send Authorization: Bearer instead of Anthropic's x-api-key header
|
|
defaultMaxTokens int
|
|
http *http.Client
|
|
idleTimeout time.Duration // SSE stall watchdog window; per-provider idle_timeout_seconds, else defaultStreamIdleTimeout
|
|
authed atomic.Bool // a request has succeeded — gate transient-401 retry
|
|
}
|
|
|
|
func (c *client) Name() string { return c.name }
|
|
|
|
func (c *client) deepSeekThinkingEnabled() bool {
|
|
return c != nil && c.deepseek && c.thinking != "disabled" && c.effort != "disabled"
|
|
}
|
|
|
|
// deepSeekAnthropicUsesProEffortMapping mirrors DeepSeek's model routing for the
|
|
// Anthropic endpoint. Opus aliases route to V4 Pro; Sonnet/Haiku aliases and
|
|
// unsupported model names route to V4 Flash.
|
|
func deepSeekAnthropicUsesProEffortMapping(model string) bool {
|
|
model = strings.ToLower(strings.TrimSpace(model))
|
|
return model == "deepseek-v4-pro" || strings.HasPrefix(model, "claude-opus")
|
|
}
|
|
|
|
func normalizeDeepSeekAnthropicEffort(model, effort string) string {
|
|
switch effort {
|
|
case "low":
|
|
if deepSeekAnthropicUsesProEffortMapping(model) {
|
|
return "high"
|
|
}
|
|
return "low"
|
|
case "medium":
|
|
return "high"
|
|
case "xhigh":
|
|
if deepSeekAnthropicUsesProEffortMapping(model) {
|
|
return "max"
|
|
}
|
|
return "high"
|
|
case "high", "max":
|
|
return effort
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func (c *client) RequiresToolCallReasoning() bool {
|
|
return c.deepSeekThinkingEnabled()
|
|
}
|
|
|
|
func (c *client) sendOpts() provider.SendOptions {
|
|
return provider.SendOptions{
|
|
Provider: c.name,
|
|
KeyEnv: c.keyEnv,
|
|
KeySource: c.keySource,
|
|
KeyPresent: c.apiKey() != "",
|
|
RetryAuth: c.authed.Load(), HeaderTimeout: c.idleTimeout,
|
|
}
|
|
}
|
|
|
|
func cleanCustomHeaders(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for name, value := range in {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || reservedCustomHeader(name) {
|
|
continue
|
|
}
|
|
out[name] = strings.TrimSpace(value)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func reservedCustomHeader(name string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "content-type", "accept", "x-api-key", "authorization", "anthropic-version":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// requestHeaders is what a client adds to every request beyond auth and
|
|
// content negotiation, fixed for the client's lifetime.
|
|
type requestHeaders struct {
|
|
custom map[string]string
|
|
openCodeSession string
|
|
}
|
|
|
|
func (h requestHeaders) apply(req *http.Request) {
|
|
applyCustomHeaders(req.Header, h.custom)
|
|
provider.ApplyOpenCodeGoIdentity(req, h.openCodeSession)
|
|
provider.ApplyClientIdentity(req)
|
|
}
|
|
|
|
func applyCustomHeaders(h http.Header, headers map[string]string) {
|
|
for name, value := range cleanCustomHeaders(headers) {
|
|
h.Set(name, value)
|
|
}
|
|
}
|
|
|
|
// bufPool reuses byte buffers for JSON-marshalled request bodies, reducing GC
|
|
// churn from repeated alloc/free of ~10-100KB buffers per turn.
|
|
var bufPool = sync.Pool{
|
|
New: func() any { return new(bytes.Buffer) },
|
|
}
|
|
|
|
func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
|
requestCtx := provider.WithRequestAttemptCounter(ctx)
|
|
buf := bufPool.Get().(*bytes.Buffer)
|
|
buf.Reset()
|
|
wireReq := c.buildRequest(requestCtx, req)
|
|
if err := json.NewEncoder(buf).Encode(wireReq); err != nil {
|
|
bufPool.Put(buf)
|
|
return nil, fmt.Errorf("%s: marshal request: %w", c.name, err)
|
|
}
|
|
body := make([]byte, buf.Len())
|
|
copy(body, buf.Bytes())
|
|
bufPool.Put(buf)
|
|
|
|
newReq := func(ctx context.Context) (*http.Request, error) {
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.requestURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("Accept", "text/event-stream")
|
|
if c.authHeader {
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey())
|
|
} else {
|
|
httpReq.Header.Set("x-api-key", c.apiKey())
|
|
}
|
|
httpReq.Header.Set("anthropic-version", anthropicVersion)
|
|
c.headers.apply(httpReq)
|
|
return httpReq, nil
|
|
}
|
|
resp, err := provider.SendWithRetry(requestCtx, c.http, c.sendOpts(), newReq)
|
|
if err != nil {
|
|
return nil, provider.AnnotateToolSchemaError(err, req.Tools)
|
|
}
|
|
c.authed.Store(true)
|
|
|
|
out := make(chan provider.Chunk)
|
|
go c.readStream(requestCtx, resp, out)
|
|
return out, nil
|
|
}
|
|
|
|
// buildRequest converts the transport-agnostic Request into the Messages API shape:
|
|
// RoleSystem messages lift to the top-level `system` field; assistant tool calls
|
|
// become `tool_use` blocks; RoleTool results become `tool_result` blocks in a user
|
|
// turn. Consecutive same-role messages are coalesced because the API requires
|
|
// alternating user/assistant turns (tool results are user turns).
|
|
func (c *client) buildRequest(_ context.Context, req provider.Request) anthRequest {
|
|
var system []textBlock
|
|
var msgs []anthMessage
|
|
|
|
// appendBlocks adds blocks under role, merging into the previous message when
|
|
// it shares the role (keeps user/assistant strictly alternating).
|
|
appendBlocks := func(role string, blocks ...contentBlock) {
|
|
if len(blocks) == 0 {
|
|
return
|
|
}
|
|
if n := len(msgs); n > 0 && msgs[n-1].Role == role {
|
|
msgs[n-1].Content = append(msgs[n-1].Content, blocks...)
|
|
return
|
|
}
|
|
msgs = append(msgs, anthMessage{Role: role, Content: blocks})
|
|
}
|
|
|
|
for _, m := range provider.SanitizeToolPairing(provider.WithoutVideosAll(req.Messages, provider.VideoNotSentNote)) {
|
|
switch m.Role {
|
|
case provider.RoleSystem:
|
|
if m.Content != "" {
|
|
system = append(system, textBlock{Type: "text", Text: m.Content})
|
|
}
|
|
case provider.RoleUser:
|
|
if m.Content != "" {
|
|
appendBlocks("user", contentBlock{Type: "text", Text: m.Content})
|
|
}
|
|
if c.vision {
|
|
for _, url := range m.Images {
|
|
if mt, data, ok := provider.ParseImageDataURL(url); ok {
|
|
appendBlocks("user", contentBlock{Type: "image", Source: &imageSource{Type: "base64", MediaType: mt, Data: data}})
|
|
}
|
|
}
|
|
}
|
|
case provider.RoleTool:
|
|
content := m.Content
|
|
if content == "" {
|
|
content = "(no output)" // tool_result content must be non-empty
|
|
}
|
|
block := contentBlock{Type: "tool_result", ToolUseID: m.ToolCallID, Content: content}
|
|
if c.vision {
|
|
if blocks := toolResultBlocks(content, m.Images); blocks != nil {
|
|
block.Content = blocks
|
|
}
|
|
}
|
|
appendBlocks("user", block)
|
|
case provider.RoleAssistant:
|
|
var blocks []contentBlock
|
|
if block := c.replayThinking(m); block != nil {
|
|
blocks = append(blocks, *block)
|
|
}
|
|
if m.Content != "" {
|
|
blocks = append(blocks, contentBlock{Type: "text", Text: m.Content})
|
|
}
|
|
for _, tc := range m.ToolCalls {
|
|
input := json.RawMessage(tc.Arguments)
|
|
if len(input) == 0 {
|
|
input = json.RawMessage("{}") // input is required, even when empty
|
|
}
|
|
blocks = append(blocks, contentBlock{Type: "tool_use", ID: tc.ID, Name: tc.Name, Input: input})
|
|
}
|
|
appendBlocks("assistant", blocks...)
|
|
}
|
|
}
|
|
|
|
var tools []anthTool
|
|
if c.webSearch {
|
|
tools = append(tools, anthTool{Type: "web_search_20250305", Name: "web_search"})
|
|
}
|
|
for _, t := range req.Tools {
|
|
schema := t.Parameters
|
|
if len(schema) == 0 {
|
|
schema = json.RawMessage(`{"type":"object","properties":{}}`)
|
|
}
|
|
if c.mimo {
|
|
schema = provider.NormalizeLegacyTupleItemsForDraft202012(schema)
|
|
}
|
|
tools = append(tools, anthTool{Name: t.Name, Description: t.Description, InputSchema: schema})
|
|
}
|
|
|
|
// Prompt-cache breakpoints (ephemeral, prefix-match). DeepSeek ignores
|
|
// cache_control and manages prefix caching automatically, so keep those fields
|
|
// off its wire entirely. Render order for native Anthropic is
|
|
// tools → system → messages, so a marker on the last system block caches
|
|
// tools+system together; with no system, mark the last tool. A marker on the
|
|
// last block of the last message caches the conversation prefix, accruing hits
|
|
// incrementally as turns are appended. Max 4 breakpoints; we use ≤2. Keep
|
|
// Anthropic's default 5m TTL by omitting the ttl field. Besides being cheaper
|
|
// than the opt-in 1h write, this keeps provider-visible request bytes stable
|
|
// across turns, retries, and wall-clock timing.
|
|
if !c.deepseek {
|
|
if n := len(system); n > 0 {
|
|
system[n-1].CacheControl = ephemeral()
|
|
} else if n := len(tools); n > 0 {
|
|
tools[n-1].CacheControl = ephemeral()
|
|
}
|
|
if n := len(msgs); n > 0 {
|
|
if k := len(msgs[n-1].Content); k > 0 {
|
|
msgs[n-1].Content[k-1].CacheControl = ephemeral()
|
|
}
|
|
}
|
|
}
|
|
|
|
maxTokens := req.MaxTokens
|
|
if maxTokens <= 0 {
|
|
maxTokens = c.defaultMaxTokens
|
|
if maxTokens <= 0 {
|
|
maxTokens = defaultMaxTokens
|
|
}
|
|
}
|
|
r := anthRequest{
|
|
Model: c.model,
|
|
MaxTokens: maxTokens,
|
|
System: system,
|
|
Messages: msgs,
|
|
Tools: tools,
|
|
Stream: true,
|
|
}
|
|
// Extended thinking is provider-specific. DeepSeek defaults to enabled and
|
|
// accepts output_config.effort alongside its binary toggle. Adaptive reaches
|
|
// here only for endpoints under Anthropic's contract (resolveReasoning);
|
|
// LongCat-style gateways take enabled|disabled and reject output_config.
|
|
if c.deepseek {
|
|
r.Temperature = req.Temperature
|
|
t := c.thinking
|
|
if t != "disabled" {
|
|
t = "enabled"
|
|
}
|
|
if c.effort == "disabled" {
|
|
t = "disabled"
|
|
}
|
|
r.Thinking = &thinkingConfig{Type: t}
|
|
if t != "disabled" {
|
|
effort := normalizeDeepSeekAnthropicEffort(c.model, c.effort)
|
|
switch effort {
|
|
case "low", "high", "max":
|
|
r.OutputConfig = &outputConfig{Effort: effort}
|
|
}
|
|
}
|
|
} else {
|
|
t := c.thinking
|
|
if c.effort == "enabled" || c.effort == "disabled" {
|
|
t = c.effort // on a binary endpoint /effort is the toggle itself
|
|
}
|
|
switch t {
|
|
case "adaptive":
|
|
r.Thinking = c.adaptiveThinkingConfig()
|
|
if c.effort != "" {
|
|
r.OutputConfig = &outputConfig{Effort: c.effort}
|
|
}
|
|
case "enabled", "disabled":
|
|
r.Thinking = &thinkingConfig{Type: t}
|
|
}
|
|
}
|
|
return r.forSummary(req.Summary)
|
|
}
|
|
|
|
// readStream parses the Messages API SSE stream into Chunks. Text deltas emit live;
|
|
// each tool_use content block emits a ChunkToolCallStart when its id+name are known
|
|
// and a complete ChunkToolCall when the block closes; usage is assembled from
|
|
// message_start/message_delta usage (compatible gateways may put every counter
|
|
// in the final delta) and emitted once before ChunkDone.
|
|
func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {
|
|
defer resp.Body.Close()
|
|
defer close(out)
|
|
|
|
// Close the body if the stream stalls past c.idleTimeout so scanner.Scan()
|
|
// unblocks instead of hanging on a half-open connection. The watchdog owns the
|
|
// timer; the read loop only pings the buffered activity channel (no Timer.Reset
|
|
// race). A context cancel already unblocks the scan via the transport.
|
|
idleTimeout := c.idleTimeout
|
|
if idleTimeout <= 0 { // zero-value client (constructed without New)
|
|
idleTimeout = defaultStreamIdleTimeout
|
|
}
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
activity := make(chan struct{}, 1)
|
|
var stalled atomic.Bool
|
|
go func() {
|
|
idle := time.NewTimer(idleTimeout)
|
|
defer idle.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
resp.Body.Close()
|
|
return
|
|
case <-idle.C:
|
|
stalled.Store(true)
|
|
resp.Body.Close()
|
|
return
|
|
case <-activity:
|
|
if !idle.Stop() {
|
|
select {
|
|
case <-idle.C:
|
|
default:
|
|
}
|
|
}
|
|
idle.Reset(idleTimeout)
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
sink := &chunkSink{ctx: ctx, out: out}
|
|
send := sink.send
|
|
|
|
tools := map[int]*provider.ToolCall{} // tool_use blocks, keyed by content index
|
|
argBuckets := map[int]int{} // last emitted 2KB progress bucket per block
|
|
server := serverBlock{index: -1} // the provider-run call a result block answers
|
|
var usage streamUsage
|
|
var stopReason string
|
|
finishUsage := func(partial bool) bool {
|
|
return usage.finish(ctx, c.nativeAnthropic, stopReason, partial, send)
|
|
}
|
|
// Explicit errors flush usage first because consumers may stop at ChunkError.
|
|
// Other exits (including canceled/abandoned consumers) make one best-effort
|
|
// delivery through the same cancellation-aware sink, never an unbounded wait.
|
|
defer func() { finishUsage(true) }()
|
|
fail := func(err error) {
|
|
if finishUsage(true) {
|
|
send(provider.Chunk{Type: provider.ChunkError, Err: err})
|
|
}
|
|
}
|
|
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
select { // ping the idle watchdog; non-blocking so a full buffer is fine
|
|
case activity <- struct{}{}:
|
|
default:
|
|
}
|
|
line := strings.TrimSpace(scanner.Text())
|
|
// SSE carries `event:` and `data:` lines; the data JSON's own `type` field
|
|
// is authoritative, so we only need the data payloads.
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
if data == "" {
|
|
continue
|
|
}
|
|
|
|
var ev streamEvent
|
|
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
|
fail(provider.StreamDecodeError(c.name, data, err))
|
|
return
|
|
}
|
|
|
|
switch ev.Type {
|
|
case "message_start":
|
|
if ev.Message != nil && ev.Message.Usage != nil {
|
|
usage.merge(ev.Message.Usage)
|
|
}
|
|
case "content_block_start":
|
|
if ev.ContentBlock != nil {
|
|
switch ev.ContentBlock.Type {
|
|
case "tool_use":
|
|
tc := &provider.ToolCall{ID: ev.ContentBlock.ID, Name: ev.ContentBlock.Name}
|
|
tools[ev.Index] = tc
|
|
if !send(provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: tc.ID, Name: tc.Name}}) {
|
|
return
|
|
}
|
|
default:
|
|
if !server.begin(ev.Index, ev.ContentBlock, send) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
case "content_block_delta":
|
|
if ev.Delta == nil {
|
|
continue
|
|
}
|
|
switch ev.Delta.Type {
|
|
case "text_delta":
|
|
if ev.Delta.Text != "" {
|
|
if !send(provider.Chunk{Type: provider.ChunkText, Text: ev.Delta.Text}) {
|
|
return
|
|
}
|
|
}
|
|
case "thinking_delta":
|
|
if ev.Delta.Thinking != "" {
|
|
if !send(provider.Chunk{Type: provider.ChunkReasoning, Text: ev.Delta.Thinking}) {
|
|
return
|
|
}
|
|
}
|
|
case "signature_delta":
|
|
if ev.Delta.Signature != "" {
|
|
if !send(provider.Chunk{Type: provider.ChunkReasoning, Signature: ev.Delta.Signature}) {
|
|
return
|
|
}
|
|
}
|
|
case "web_search_tool_result_delta":
|
|
if !server.resultDelta(ev.Index, ev.Delta.WebSearchResults, send) {
|
|
return
|
|
}
|
|
case "input_json_delta":
|
|
if server.args(ev.Index, ev.Delta.PartialJSON) { // no progress ticks: nothing is waiting on us
|
|
continue
|
|
}
|
|
if tc := tools[ev.Index]; tc != nil {
|
|
tc.Arguments += ev.Delta.PartialJSON
|
|
// Progress ticks for large streaming argument payloads, one
|
|
// per 2KB bucket (see the openai provider for rationale).
|
|
if bucket := len(tc.Arguments) / 2048; bucket > argBuckets[ev.Index] {
|
|
argBuckets[ev.Index] = bucket
|
|
if !send(provider.Chunk{Type: provider.ChunkToolCallArgsDelta, ToolCall: &provider.ToolCall{ID: tc.ID, Name: tc.Name}, ArgChars: len(tc.Arguments)}) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
case "content_block_stop":
|
|
if tc := tools[ev.Index]; tc != nil {
|
|
if !send(provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}) {
|
|
return
|
|
}
|
|
delete(tools, ev.Index)
|
|
}
|
|
case "message_delta":
|
|
if ev.Delta != nil && ev.Delta.StopReason != "" {
|
|
stopReason = ev.Delta.StopReason
|
|
}
|
|
usage.merge(ev.Usage)
|
|
case "message_stop":
|
|
// Anthropic's terminal event. Tool blocks may already have closed;
|
|
// without this, the attempt stays speculative and is not committed.
|
|
// Stop reading immediately so a post-terminal connection reset cannot
|
|
// reclassify a complete response as interrupted.
|
|
goto finalize
|
|
case "error":
|
|
// Classify before usage delivery: accounting alone must not disable
|
|
// the existing before-output retry route.
|
|
fail(anthropicStreamError(c.name, ev.Error, sink.emitted))
|
|
return
|
|
}
|
|
}
|
|
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if stalled.Load() {
|
|
err := fmt.Errorf("%s: stream stalled — no data for %s, connection likely dropped; raise idle_timeout_seconds to allow a longer silence", c.name, idleTimeout)
|
|
fail(provider.StreamInterrupt(err, provider.StreamInterruptIdleTimeout))
|
|
return
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
fail(provider.ReadCut(c.name, err))
|
|
return
|
|
}
|
|
// EOF / clean close before message_stop is an uncommitted attempt. Complete
|
|
// ChunkToolCall blocks that arrived earlier remain speculative.
|
|
fail(provider.StreamInterrupt(
|
|
fmt.Errorf("%s: stream ended before message_stop: %w", c.name, io.ErrUnexpectedEOF),
|
|
provider.StreamInterruptPrematureEOF,
|
|
))
|
|
return
|
|
|
|
finalize:
|
|
if !finishUsage(false) {
|
|
return
|
|
}
|
|
send(provider.Chunk{Type: provider.ChunkDone})
|
|
}
|
|
|
|
// mapStopReason translates Anthropic stop reasons to the OpenAI-style finish
|
|
// reasons the agent already recognises (it surfaces abnormal ones like "length").
|
|
func mapStopReason(s string) string {
|
|
switch s {
|
|
case "end_turn", "stop_sequence":
|
|
return "stop"
|
|
case "tool_use":
|
|
return "tool_calls"
|
|
case "max_tokens":
|
|
return "length"
|
|
default:
|
|
return s // "refusal", "pause_turn", "" — pass through
|
|
}
|
|
}
|
|
|
|
// Messages API wire protocol
|
|
|
|
const cacheWrite5MinuteInputMultiplier = 1.25
|
|
|
|
func ephemeral() *cacheControl { return &cacheControl{Type: "ephemeral"} }
|
|
|
|
type cacheControl struct {
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type anthRequest struct {
|
|
Model string `json:"model"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
System []textBlock `json:"system,omitempty"`
|
|
Messages []anthMessage `json:"messages"`
|
|
Tools []anthTool `json:"tools,omitempty"`
|
|
ToolChoice *toolChoice `json:"tool_choice,omitempty"`
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
Thinking *thinkingConfig `json:"thinking,omitempty"`
|
|
OutputConfig *outputConfig `json:"output_config,omitempty"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type thinkingConfig struct {
|
|
Type string `json:"type"` // "adaptive"
|
|
Display string `json:"display,omitempty"` // "summarized" to stream the reasoning text
|
|
}
|
|
|
|
type outputConfig struct {
|
|
Effort string `json:"effort,omitempty"` // low | high | max
|
|
}
|
|
|
|
type textBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
CacheControl *cacheControl `json:"cache_control,omitempty"`
|
|
}
|
|
|
|
type anthMessage struct {
|
|
Role string `json:"role"`
|
|
Content []contentBlock `json:"content"`
|
|
}
|
|
|
|
// contentBlock is the union of the block kinds we emit in a request: text,
|
|
// tool_use (echoing a prior assistant call), and tool_result. Unused fields are
|
|
// omitted so each block serialises to its canonical shape.
|
|
type contentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"` // text
|
|
Thinking string `json:"thinking,omitempty"` // thinking
|
|
Signature string `json:"signature,omitempty"` // thinking
|
|
ID string `json:"id,omitempty"` // tool_use
|
|
Name string `json:"name,omitempty"` // tool_use
|
|
Input json.RawMessage `json:"input,omitempty"` // tool_use
|
|
ToolUseID string `json:"tool_use_id,omitempty"` // tool_result
|
|
Content any `json:"content,omitempty"` // tool_result: string, or []contentBlock when the result carries images
|
|
Source *imageSource `json:"source,omitempty"` // image
|
|
CacheControl *cacheControl `json:"cache_control,omitempty"`
|
|
}
|
|
|
|
type imageSource struct {
|
|
Type string `json:"type"` // "base64"
|
|
MediaType string `json:"media_type"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
// toolResultBlocks builds array content for a tool_result whose message carries
|
|
// images: the text first, then one image block per parseable data URL. It
|
|
// returns nil when nothing parses, so text-only results keep plain string
|
|
// content — byte-identical serialization to previous releases.
|
|
func toolResultBlocks(text string, images []string) []contentBlock {
|
|
var imgs []contentBlock
|
|
for _, url := range images {
|
|
if mt, data, ok := provider.ParseImageDataURL(url); ok {
|
|
imgs = append(imgs, contentBlock{Type: "image", Source: &imageSource{Type: "base64", MediaType: mt, Data: data}})
|
|
}
|
|
}
|
|
if imgs == nil {
|
|
return nil
|
|
}
|
|
return append([]contentBlock{{Type: "text", Text: text}}, imgs...)
|
|
}
|
|
|
|
type anthTool struct {
|
|
Type string `json:"type,omitempty"` // "web_search" for server-side search; empty for named tools
|
|
Name string `json:"name,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
InputSchema json.RawMessage `json:"input_schema,omitempty"`
|
|
CacheControl *cacheControl `json:"cache_control,omitempty"`
|
|
}
|
|
|
|
// streamEvent is the discriminated SSE event; read the fields matching Type.
|
|
type streamEvent struct {
|
|
Type string `json:"type"`
|
|
Index int `json:"index"`
|
|
Message *struct {
|
|
Usage *wireUsage `json:"usage"`
|
|
} `json:"message"`
|
|
ContentBlock *blockStart `json:"content_block"`
|
|
Delta *struct {
|
|
Type string `json:"type"` // text_delta | thinking_delta | signature_delta | input_json_delta | web_search_tool_result_delta
|
|
Text string `json:"text"` // text_delta
|
|
Thinking string `json:"thinking"` // thinking_delta
|
|
Signature string `json:"signature"` // signature_delta
|
|
PartialJSON string `json:"partial_json"` // input_json_delta
|
|
StopReason string `json:"stop_reason"` // message_delta
|
|
WebSearchResults json.RawMessage `json:"results"` // web_search_tool_result_delta
|
|
} `json:"delta"`
|
|
Usage *wireUsage `json:"usage"` // message_delta (cumulative output_tokens)
|
|
Error *streamWireError `json:"error"`
|
|
}
|
|
|
|
type wireUsage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
|
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
|
// Set when the endpoint ran its own tools. Their fetched pages are billed as
|
|
// input without ever passing through our request, so input_tokens on such a
|
|
// turn is not a measure of what we sent.
|
|
ServerToolUse struct {
|
|
WebSearchRequests int `json:"web_search_requests"`
|
|
} `json:"server_tool_use"`
|
|
}
|