返回 DeepSeek-Reasonix
anthropic.go
根目录 / internal / provider / anthropic / anthropic.go
1 // Package anthropic implements the Anthropic Messages API provider (POST
2 // /v1/messages, SSE streaming) with a hand-written net/http client — no SDK. It
3 // self-registers under the "anthropic" kind, so any Claude model is a config
4 // instance rather than code.
5 //
6 // Two notes, both rooted in the transport-agnostic provider.Message abstraction:
7 //
8 // - Extended thinking is opt-in (provider config thinking="adaptive"). Anthropic
9 // requires the *signed* thinking block be replayed on the next turn when a tool
10 // call followed thinking, so Message carries ReasoningSignature alongside
11 // ReasoningContent and this provider replays the signed block on the next
12 // request. DeepSeek's Anthropic endpoint instead uses unsigned thinking blocks,
13 // thinking.type enabled|disabled, and output_config.effort; requests carrying
14 // tools must replay all provider reasoning. Some other compatible gateways such
15 // as LongCat use the binary toggle without output_config. (redacted_thinking
16 // blocks are not yet captured/replayed.)
17 // - Native Anthropic requests omit temperature/top_p. Current Claude models
18 // (Opus 4.8/4.7) reject sampling parameters with a 400; Anthropic steers
19 // behavior via prompting instead. DeepSeek's compatible endpoint accepts the
20 // caller's temperature, so that field is preserved only for DeepSeek.
21 package anthropic
22
23 import (
24 "bytes"
25 "context"
26 "encoding/json"
27 "fmt"
28 "net/http"
29 "strings"
30 "sync"
31 "sync/atomic"
32 "time"
33
34 "reasonix/internal/netclient"
35 "reasonix/internal/provider"
36 "reasonix/internal/provider/openai"
37 )
38
39 // defaultStreamIdleTimeout caps how long a started SSE stream may go silent before
40 // it's treated as a dropped connection — a half-open TCP connection (proxy switched
41 // mid-stream) sends no RST, so scanner.Scan() would block forever. Generous on
42 // purpose; live streams emit far more often. Stored per-client (client.idleTimeout)
43 // so a test can shorten it without a shared global that races other watchdogs.
44 const defaultStreamIdleTimeout = 300 * time.Second
45
46 const (
47 // anthropicVersion is the required API version header value.
48 anthropicVersion = "2023-06-01"
49 // defaultBaseURL is the first-party endpoint; config may override it (e.g. a
50 // gateway). Bedrock/Vertex use a different request shape and are out of scope.
51 defaultBaseURL = "https://api.anthropic.com"
52 // defaultMaxTokens is the mandatory Anthropic fallback when neither config
53 // nor request supplies max_tokens. Native Anthropic stays at 16K; official
54 // DeepSeek sends the documented 384K ceiling because budget_tokens is ignored.
55 defaultMaxTokens = provider.DefaultOrdinaryOutputTokens
56 )
57
58 func init() {
59 provider.RegisterReasoning("anthropic", ReasoningForConfig)
60 provider.Register("anthropic", New)
61 }
62
63 // New builds an Anthropic provider from a resolved config.
64 func New(cfg provider.Config) (provider.Provider, error) {
65 cfg = provider.ApplyOpenCodeGoContract("anthropic", cfg)
66 if cfg.Model == "" {
67 return nil, fmt.Errorf("anthropic: model is required for provider %q", cfg.Name)
68 }
69 name := cfg.Name
70 if name == "" {
71 name = "anthropic"
72 }
73 baseURL := cfg.BaseURL
74 if baseURL == "" {
75 baseURL = defaultBaseURL
76 }
77 // Anthropic's API surface is at {root}/v1/messages, so c.baseURL stores
78 // the *root* -- without any trailing /v1. The setup wizard, however, lets
79 // users paste a full OpenAI-compatible URL (e.g.
80 // "https://proxy.example.com/v1") because that's what /models probes
81 // expect. Stripping the trailing /v1 here makes both forms land on the
82 // same endpoint without forcing users to remember Anthropic's quirky
83 // root-vs-versioned split. Without this, a user pasting
84 // "https://proxy.example.com/v1" would probe /v1/models successfully
85 // but get the chat client concatenating onto
86 // "https://proxy.example.com/v1/v1/messages" -- a 404.
87 root := strings.TrimRight(baseURL, "/")
88 root = strings.TrimSuffix(root, "/v1")
89 if root == "" {
90 root = defaultBaseURL
91 }
92 requestURL, _ := cfg.Extra["request_url"].(string)
93 requestURL = strings.TrimSpace(requestURL)
94 if requestURL == "" {
95 requestURL = root + "/v1/messages"
96 }
97 officialDeepSeek := openai.IsDeepSeek(root)
98 reasoningProtocol, _ := cfg.Extra["reasoning_protocol"].(string)
99 reasoningProtocol = strings.ToLower(strings.TrimSpace(reasoningProtocol))
100 deepSeekReplay := officialDeepSeek
101 switch reasoningProtocol {
102 case "deepseek":
103 deepSeekReplay = true
104 case "none":
105 deepSeekReplay = false
106 }
107 keyEnv, _ := cfg.Extra["api_key_env"].(string) // for actionable auth errors
108 keySource, _ := cfg.Extra["api_key_source"].(string)
109 thinking, _ := cfg.Extra["thinking"].(string)
110 thinking = strings.ToLower(strings.TrimSpace(thinking))
111 effort, err := configuredEffort(cfg)
112 if err != nil {
113 return nil, err
114 }
115 vision, _ := cfg.Extra["vision"].(bool)
116 modelInfo := provider.ModelInfo{ID: cfg.Model, InputModalities: []provider.ModelModality{provider.ModalityText}}
117 if cfg.ModelInfo != nil {
118 modelInfo = *cfg.ModelInfo
119 modelInfo.ID = cfg.Model
120 }
121 if cfg.ModelInfo != nil {
122 vision = modelInfo.SupportsInput(provider.ModalityImage)
123 }
124 // Official DeepSeek image input is pinned to one SKU even when metadata
125 // claims otherwise.
126 vision = openai.DeepSeekImageInputAllowed(officialDeepSeek, requestURL, cfg.Model, cfg.ModelInfo != nil, vision)
127 if vision {
128 modelInfo.InputModalities = []provider.ModelModality{provider.ModalityText, provider.ModalityImage}
129 } else if modelInfo.SupportsInput(provider.ModalityImage) {
130 modelInfo.InputModalities = []provider.ModelModality{provider.ModalityText}
131 }
132 webSearch, _ := cfg.Extra["web_search"].(bool)
133 clientWebSearch, _ := cfg.Extra["client_web_search"].(bool)
134 headers, _ := cfg.Extra["headers"].(map[string]string)
135 authHeader, _ := cfg.Extra["auth_header"].(bool)
136 maxOutputTokens, _ := cfg.Extra["max_output_tokens"].(int)
137 if maxOutputTokens <= 0 {
138 // Messages requires max_tokens. 0 = automatic; negative also falls back
139 // because the wire field is mandatory.
140 if officialDeepSeek {
141 maxOutputTokens = provider.DeepSeekMaxOutputTokens
142 } else {
143 // Native Anthropic and unknown gateways: conservative ordinary default.
144 maxOutputTokens = defaultMaxTokens
145 if strings.EqualFold(thinking, "adaptive") || strings.EqualFold(thinking, "enabled") {
146 maxOutputTokens = provider.AutoOutputBudget(true, effort)
147 }
148 }
149 }
150 httpClient, err := newHTTPClient(cfg)
151 if err != nil {
152 return nil, fmt.Errorf("anthropic: network: %w", err)
153 }
154 if reject, _ := cfg.Extra["reject_redirects"].(bool); reject {
155 httpClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
156 }
157 return &client{
158 identityHeaders: provider.NewClientIdentityHeaders(),
159 reasoning: ReasoningForConfig(cfg),
160 name: name,
161 identity: provider.RequestIdentity{Provider: name, DisplayName: cfg.DisplayName, Protocol: cfg.Protocol},
162 apiKey: cfg.APIKey,
163 keyEnv: keyEnv,
164 keySource: keySource,
165 baseURL: root,
166 requestURL: requestURL,
167 model: cfg.Model,
168 nativeAnthropic: strings.EqualFold(root, defaultBaseURL),
169 deepseek: deepSeekReplay,
170 thinking: thinking,
171 effort: effort,
172 vision: vision,
173 modelInfo: modelInfo,
174 mimo: provider.IsMiMoEndpoint(root),
175 search: provider.SearchPolicy{NativeEnabled: webSearch, ClientEnabled: clientWebSearch},
176 headers: cleanCustomHeaders(headers),
177 authHeader: authHeader,
178 defaultMaxTokens: maxOutputTokens,
179 http: httpClient, // no overall timeout; lifecycle is ctx-driven
180 idleTimeout: defaultStreamIdleTimeout,
181 }, nil
182 }
183
184 func newHTTPClient(cfg provider.Config) (*http.Client, error) {
185 if cfg.HTTPClient != nil {
186 return cfg.HTTPClient, nil
187 }
188 spec, _ := cfg.Extra["proxy_spec"].(netclient.ProxySpec)
189 return netclient.NewHTTPClient(spec, netclient.TransportOptions{
190 DialTimeout: 30 * time.Second,
191 KeepAlive: 30 * time.Second,
192 TLSHandshakeTimeout: 15 * time.Second,
193 ResponseHeaderTimeout: 300 * time.Second,
194 })
195 }
196
197 type client struct {
198 identityHeaders http.Header
199 reasoning provider.ReasoningCapability
200 name string
201 identity provider.RequestIdentity
202 apiKey string
203 keyEnv string // api_key_env name, surfaced in auth errors
204 keySource string // source of keyEnv, surfaced in auth errors
205 baseURL string
206 requestURL string
207 model string
208 nativeAnthropic bool // first-party endpoint: documented default-5m cache-write pricing applies
209 deepseek bool // official DeepSeek Anthropic endpoint: unsigned reasoning replay + automatic cache
210 thinking string // "adaptive" enables extended thinking; "" = off (config-driven)
211 effort string // output_config.effort: low|medium|high|xhigh|max; "" = provider default
212 vision bool // model accepts image input — embed attached images as base64 image blocks
213 modelInfo provider.ModelInfo
214 mimo bool // true for MiMo — upgrades legacy tuple schemas to Draft 2020-12
215 search provider.SearchPolicy
216 headers map[string]string
217 authHeader bool // send Authorization: Bearer instead of Anthropic's x-api-key header
218 defaultMaxTokens int
219 http *http.Client
220 idleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides
221 authed atomic.Bool // a request has succeeded — gate transient-401 retry
222 }
223
224 func (c *client) Name() string { return c.name }
225
226 func (c *client) ModelInfo() provider.ModelInfo {
227 if c == nil {
228 return provider.ModelInfo{}
229 }
230 info := c.modelInfo
231 info.InputModalities = append([]provider.ModelModality(nil), info.InputModalities...)
232 return info
233 }
234
235 func (c *client) deepSeekThinkingEnabled() bool {
236 return c != nil && c.deepseek && c.thinking != "disabled" && c.effort != "disabled"
237 }
238
239 func (c *client) RequiresAssistantReasoningReplay(m provider.Message) bool {
240 if c == nil {
241 return false
242 }
243 if !c.deepseek {
244 return c.requiresReceivedReasoning(m)
245 }
246 // A turn carrying provider-issued reasoning must replay it, tools or not:
247 // DeepSeek's thinking mode 400s when a stored thinking block is not passed
248 // back. Without stored reasoning there is nothing to replay — plain text
249 // turns stay out of projection so healthy histories keep their backing.
250 if strings.TrimSpace(m.ReasoningContent) != "" {
251 return true
252 }
253 activity := len(m.ToolCalls) > 0 || len(m.ServerSearch) > 0
254 return activity && c.deepSeekThinkingEnabled()
255 }
256
257 func (c *client) AllowsEmptyReasoningFallback() bool { return false }
258
259 func (c *client) MissingToolCallReasoningWarningIdentity() string {
260 if c == nil {
261 return ""
262 }
263 protocol := "anthropic"
264 if c.deepseek {
265 protocol = "deepseek-anthropic"
266 }
267 return strings.Join([]string{
268 "anthropic", strings.TrimSpace(c.name), strings.TrimSpace(c.requestURL),
269 strings.TrimSpace(c.model), protocol, strings.TrimSpace(c.thinking), strings.TrimSpace(c.effort),
270 }, "\x00")
271 }
272
273 func (c *client) sendOpts() provider.SendOptions {
274 return provider.SendOptions{
275 Provider: c.name,
276 ProviderDisplayName: c.identity.DisplayName,
277 Protocol: c.identity.Protocol,
278 KeyEnv: c.keyEnv,
279 KeySource: c.keySource,
280 KeyPresent: c.apiKey != "",
281 RetryAuth: c.authed.Load(),
282 }
283 }
284
285 func cleanCustomHeaders(in map[string]string) map[string]string {
286 if len(in) == 0 {
287 return nil
288 }
289 out := make(map[string]string, len(in))
290 for name, value := range in {
291 name = strings.TrimSpace(name)
292 if name == "" || reservedCustomHeader(name) {
293 continue
294 }
295 out[name] = strings.TrimSpace(value)
296 }
297 if len(out) == 0 {
298 return nil
299 }
300 return out
301 }
302
303 func reservedCustomHeader(name string) bool {
304 switch strings.ToLower(strings.TrimSpace(name)) {
305 case "content-type", "accept", "x-api-key", "authorization", "anthropic-version", "anthropic-beta":
306 return true
307 default:
308 return false
309 }
310 }
311
312 func applyCustomHeaders(h http.Header, headers map[string]string) {
313 for name, value := range cleanCustomHeaders(headers) {
314 h.Set(name, value)
315 }
316 }
317
318 // bufPool reuses byte buffers for JSON-marshalled request bodies, reducing GC
319 // churn from repeated alloc/free of ~10-100KB buffers per turn.
320 var bufPool = sync.Pool{
321 New: func() any { return new(bytes.Buffer) },
322 }
323
324 func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
325 if err := c.reasoning.Validate(c.model, req.EffortOverride); err != nil {
326 return nil, err
327 }
328 requestCtx := provider.WithRequestAttemptCounter(ctx)
329 buf := bufPool.Get().(*bytes.Buffer)
330 buf.Reset()
331 if err := json.NewEncoder(buf).Encode(c.buildRequest(requestCtx, req)); err != nil {
332 bufPool.Put(buf)
333 return nil, fmt.Errorf("%s: marshal request: %w", c.name, err)
334 }
335 body := make([]byte, buf.Len())
336 copy(body, buf.Bytes())
337 bufPool.Put(buf)
338
339 newReq := func(ctx context.Context) (*http.Request, error) {
340 httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.requestURL, bytes.NewReader(body))
341 if err != nil {
342 return nil, err
343 }
344 httpReq.Header.Set("Content-Type", "application/json")
345 httpReq.Header.Set("Accept", "text/event-stream")
346 if c.authHeader {
347 httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
348 } else {
349 httpReq.Header.Set("x-api-key", c.apiKey)
350 }
351 httpReq.Header.Set("anthropic-version", anthropicVersion)
352 if visionRequestUsesFileID(req) {
353 httpReq.Header.Set("anthropic-beta", "files-api-2025-04-14")
354 }
355 applyCustomHeaders(httpReq.Header, c.headers)
356 provider.ApplyOpenCodeGoHeaders(httpReq, c.baseURL, c.identityHeaders)
357 return httpReq, nil
358 }
359 resp, err := provider.SendWithRetry(requestCtx, c.http, c.sendOpts(), newReq)
360 if err != nil {
361 return nil, provider.AnnotateToolSchemaError(err, req.Tools)
362 }
363 c.authed.Store(true)
364
365 out := make(chan provider.Chunk)
366 go c.readStream(requestCtx, resp, out)
367 return out, nil
368 }
369
370 // buildRequest converts the transport-agnostic Request into the Messages API shape:
371 // RoleSystem messages lift to the top-level `system` field; assistant tool calls
372 // become `tool_use` blocks; RoleTool results become `tool_result` blocks in a user
373 // turn. Consecutive same-role messages are coalesced because the API requires
374 // alternating user/assistant turns (tool results are user turns).
375 func (c *client) buildRequest(ctx context.Context, req provider.Request) anthRequest {
376 system, msgs := c.buildMessages(req.Messages)
377
378 tools := encodeAnthTools(c, req)
379 if !c.deepseek {
380 markPromptCacheBreakpoints(system, tools, msgs)
381 }
382
383 maxTokens := req.MaxTokens
384 if maxTokens <= 0 {
385 maxTokens = c.defaultMaxTokens
386 if maxTokens <= 0 {
387 maxTokens = defaultMaxTokens
388 }
389 }
390 r := anthRequest{
391 Model: c.model,
392 MaxTokens: maxTokens,
393 System: system,
394 Messages: msgs,
395 Tools: tools,
396 Stream: true,
397 }
398 effort := c.effort
399 if req.EffortOverride != "" {
400 effort = req.EffortOverride
401 }
402 // Extended thinking is provider-specific. DeepSeek defaults to enabled and
403 // accepts output_config.effort alongside its binary toggle. Anthropic proper
404 // uses type=adaptive plus display/output_config. LongCat-style compatible
405 // gateways use the simpler enabled|disabled knob and reject output_config.
406 if c.deepseek {
407 c.applyDeepSeekThinking(&r, req)
408 } else {
409 thinking := c.thinking
410 if effort != "" && thinking == "" {
411 thinking = "adaptive"
412 }
413 switch thinking {
414 case "adaptive":
415 r.Thinking = &thinkingConfig{Type: "adaptive", Display: "summarized"}
416 if effort != "" {
417 r.OutputConfig = &outputConfig{Effort: effort}
418 }
419 case "enabled", "disabled":
420 t := c.thinking
421 if effort == "enabled" || effort == "disabled" {
422 t = effort
423 }
424 r.Thinking = &thinkingConfig{Type: t}
425 }
426 }
427 return r
428 }
429
430 // readStream parses the Messages API SSE stream into Chunks. Text deltas emit live;
431 // each tool_use content block emits a ChunkToolCallStart when its id+name are known
432 // and a complete ChunkToolCall when the block closes; usage is assembled from
433 // message_start/message_delta usage (compatible gateways may put every counter
434 // in the final delta) and emitted once before ChunkDone.
435 func (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {
436 defer resp.Body.Close()
437 defer close(out)
438
439 // Close the body if the stream stalls past c.idleTimeout so scanner.Scan()
440 // unblocks instead of hanging on a half-open connection. The watchdog owns the
441 // timer; the read loop only pings the buffered activity channel (no Timer.Reset
442 // race). A context cancel already unblocks the scan via the transport.
443 idleTimeout := c.idleTimeout
444 if idleTimeout <= 0 { // zero-value client (constructed without New)
445 idleTimeout = defaultStreamIdleTimeout
446 }
447 done := make(chan struct{})
448 defer close(done)
449 activity := make(chan struct{}, 1)
450 var stalled atomic.Bool
451 go func() {
452 idle := time.NewTimer(idleTimeout)
453 defer idle.Stop()
454 for {
455 select {
456 case <-ctx.Done():
457 resp.Body.Close()
458 return
459 case <-idle.C:
460 stalled.Store(true)
461 resp.Body.Close()
462 return
463 case <-activity:
464 if !idle.Stop() {
465 select {
466 case <-idle.C:
467 default:
468 }
469 }
470 idle.Reset(idleTimeout)
471 case <-done:
472 return
473 }
474 }
475 }()
476
477 send := func(chunk provider.Chunk) bool {
478 return sendChunk(ctx, out, chunk)
479 }
480
481 tools := map[int]*provider.ToolCall{} // tool_use blocks, keyed by content index
482 searches := newSearchStream()
483 thinking := map[int]*provider.ThinkingBlock{}
484 argBuckets := map[int]int{} // last emitted 2KB progress bucket per block
485 var inTok, outTok, cacheCreate, cacheRead int
486 var stopReason string
487 haveUsage := false
488 mergeUsage := func(usage *wireUsage) {
489 if usage == nil {
490 return
491 }
492 // The native Anthropic stream reports input/cache counters in
493 // message_start and output_tokens in message_delta. Compatible gateways
494 // such as LongCat report all counters in message_delta instead. Counters
495 // are cumulative and non-negative, so retaining the largest value also
496 // tolerates gateways that repeat partial usage in both events.
497 inTok = max(inTok, usage.InputTokens)
498 outTok = max(outTok, usage.OutputTokens)
499 cacheCreate = max(cacheCreate, usage.CacheCreationInputTokens)
500 cacheRead = max(cacheRead, usage.CacheReadInputTokens)
501 haveUsage = true
502 }
503
504 scanner := provider.NewStreamScanner(resp.Body, 1024*1024)
505
506 for scanner.Scan() {
507 select { // ping the idle watchdog; non-blocking so a full buffer is fine
508 case activity <- struct{}{}:
509 default:
510 }
511 line := strings.TrimSpace(scanner.Text())
512 // SSE carries `event:` and `data:` lines; the data JSON's own `type` field
513 // is authoritative, so we only need the data payloads.
514 if !strings.HasPrefix(line, "data:") {
515 continue
516 }
517 data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
518 if data == "" {
519 continue
520 }
521
522 var ev streamEvent
523 if err := json.Unmarshal([]byte(data), &ev); err != nil {
524 send(provider.Chunk{Type: provider.ChunkError, Err: scanner.DecodeError(c.name, data, err)})
525 return
526 }
527
528 if !updateThinkingStream(thinking, ev, send) {
529 return
530 }
531 switch ev.Type {
532 case "message_start":
533 if ev.Message != nil && ev.Message.Usage != nil {
534 mergeUsage(ev.Message.Usage)
535 }
536 case "content_block_start":
537 if chunk := beginContentBlock(ev.Index, ev.ContentBlock, tools, searches); chunk != nil {
538 if !send(*chunk) {
539 return
540 }
541 }
542 case "content_block_delta":
543 if ev.Delta == nil {
544 continue
545 }
546 switch ev.Delta.Type {
547 case "text_delta":
548 if ev.Delta.Text != "" {
549 if !send(provider.Chunk{Type: provider.ChunkText, Text: ev.Delta.Text}) {
550 return
551 }
552 }
553 case "input_json_delta":
554 if tc := tools[ev.Index]; tc != nil {
555 tc.Arguments += ev.Delta.PartialJSON
556 // Progress ticks for large streaming argument payloads, one
557 // per 2KB bucket (see the openai provider for rationale).
558 if bucket := len(tc.Arguments) / 2048; bucket > argBuckets[ev.Index] {
559 argBuckets[ev.Index] = bucket
560 if !send(provider.Chunk{Type: provider.ChunkToolCallArgsDelta, ToolCall: &provider.ToolCall{ID: tc.ID, Name: tc.Name}, ArgChars: len(tc.Arguments)}) {
561 return
562 }
563 }
564 }
565 if next := searches.argsDelta(ev.Index, ev.Delta.PartialJSON); next != nil {
566 if !send(provider.Chunk{Type: provider.ChunkServerSearch, ServerSearch: next}) {
567 return
568 }
569 }
570 case "web_search_tool_result_delta":
571 // Some DeepSeek-compatible streams deliver the result array in a
572 // delta instead of the block-start content; without this the card
573 // stays empty and the model-written source list is all that shows.
574 if next := searches.resultsDelta(ev.Index, ev.Delta.WebSearchResults); next != nil {
575 if !send(provider.Chunk{Type: provider.ChunkServerSearch, ServerSearch: next}) {
576 return
577 }
578 }
579 }
580 case "content_block_stop":
581 if tc := tools[ev.Index]; tc != nil {
582 if !send(provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}) {
583 return
584 }
585 delete(tools, ev.Index)
586 }
587 case "message_delta":
588 if ev.Delta != nil && ev.Delta.StopReason != "" {
589 stopReason = ev.Delta.StopReason
590 }
591 mergeUsage(ev.Usage)
592 case "message_stop":
593 // Anthropic's terminal event. Tool blocks may already have closed;
594 // without this, the attempt stays speculative and is not committed.
595 // Stop reading immediately so a post-terminal connection reset cannot
596 // reclassify a complete response as interrupted.
597 goto finalize
598 case "error":
599 msg := "stream error"
600 if ev.Error != nil && ev.Error.Message != "" {
601 msg = ev.Error.Message
602 }
603 send(provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("%s: %s", c.name, msg)})
604 return
605 }
606 }
607
608 if ctx.Err() != nil {
609 return
610 }
611 if err := streamScanEndError(c.name, idleTimeout, stalled.Load(), scanner.Err(), stopReason); err != nil {
612 send(provider.Chunk{Type: provider.ChunkError, Err: err})
613 return
614 }
615 goto finalize
616
617 finalize:
618 if len(thinking) > 0 {
619 send(provider.Chunk{Type: provider.ChunkReasoning, ReasoningState: provider.ReasoningIncomplete})
620 }
621 if haveUsage {
622 cacheWriteBilledTokens := 0.0
623 if cacheCreate > 0 && c.nativeAnthropic {
624 cacheWriteBilledTokens = float64(cacheCreate) * cacheWrite5MinuteInputMultiplier
625 }
626 usage := &provider.Usage{
627 PromptTokens: inTok + cacheCreate + cacheRead,
628 CompletionTokens: outTok,
629 TotalTokens: inTok + cacheCreate + cacheRead + outTok,
630 CacheHitTokens: cacheRead,
631 CacheMissTokens: inTok + cacheCreate,
632 CacheWriteTokens: cacheCreate,
633 CacheWriteBilledTokens: cacheWriteBilledTokens,
634 FinishReason: mapStopReason(stopReason),
635 }
636 provider.ApplyRequestAttemptCount(ctx, usage)
637 if !send(provider.Chunk{Type: provider.ChunkUsage, Usage: usage}) {
638 return
639 }
640 }
641 send(provider.Chunk{Type: provider.ChunkDone})
642 }
643
644 func sendChunk(ctx context.Context, out chan<- provider.Chunk, chunk provider.Chunk) bool {
645 select {
646 case out <- chunk:
647 return true
648 default:
649 }
650 select {
651 case <-ctx.Done():
652 return false
653 case out <- chunk:
654 return true
655 }
656 }
657
658 // mapStopReason translates Anthropic stop reasons to the OpenAI-style finish
659 // reasons the agent already recognises (it surfaces abnormal ones like "length").
660 func mapStopReason(s string) string {
661 switch s {
662 case "end_turn", "stop_sequence":
663 return "stop"
664 case "tool_use":
665 return "tool_calls"
666 case "max_tokens":
667 return "length"
668 default:
669 return s // "refusal", "pause_turn", "" — pass through
670 }
671 }
672
673 // Messages API wire protocol
674
675 const cacheWrite5MinuteInputMultiplier = 1.25
676
677 func ephemeral() *cacheControl { return &cacheControl{Type: "ephemeral"} }
678
679 type cacheControl struct {
680 Type string `json:"type"`
681 }
682
683 type anthRequest struct {
684 Model string `json:"model"`
685 MaxTokens int `json:"max_tokens"`
686 System []textBlock `json:"system,omitempty"`
687 Messages []anthMessage `json:"messages"`
688 Tools []anthTool `json:"tools,omitempty"`
689 Temperature *float64 `json:"temperature,omitempty"`
690 Thinking *thinkingConfig `json:"thinking,omitempty"`
691 OutputConfig *outputConfig `json:"output_config,omitempty"`
692 Stream bool `json:"stream"`
693 }
694
695 type thinkingConfig struct {
696 Type string `json:"type"` // "adaptive"
697 Display string `json:"display,omitempty"` // "summarized" to stream the reasoning text
698 }
699
700 type outputConfig struct {
701 Effort string `json:"effort,omitempty"` // low | high | max
702 }
703
704 type textBlock struct {
705 Type string `json:"type"`
706 Text string `json:"text"`
707 CacheControl *cacheControl `json:"cache_control,omitempty"`
708 }
709
710 type anthMessage struct {
711 Role string `json:"role"`
712 Content []contentBlock `json:"content"`
713 }
714
715 // contentBlock is the union of the block kinds we emit in a request: text,
716 // tool_use (echoing a prior assistant call), and tool_result. Unused fields are
717 // omitted so each block serialises to its canonical shape.
718 type contentBlock struct {
719 Data string `json:"data,omitempty"`
720 Type string `json:"type"`
721 Text string `json:"text,omitempty"` // text
722 Thinking string `json:"thinking,omitempty"` // thinking
723 Signature string `json:"signature,omitempty"` // thinking
724 ID string `json:"id,omitempty"` // tool_use
725 Name string `json:"name,omitempty"` // tool_use
726 Input json.RawMessage `json:"input,omitempty"` // tool_use
727 ToolUseID string `json:"tool_use_id,omitempty"` // tool_result
728 Content any `json:"content,omitempty"` // tool_result: string, or []contentBlock when the result carries images
729 Source *imageSource `json:"source,omitempty"` // image
730 CacheControl *cacheControl `json:"cache_control,omitempty"`
731 }
732
733 type imageSource struct {
734 Type string `json:"type"` // base64 | url | file
735 MediaType string `json:"media_type,omitempty"`
736 Data string `json:"data,omitempty"`
737 URL string `json:"url,omitempty"`
738 FileID string `json:"file_id,omitempty"`
739 }
740
741 // toolResultBlocks builds array content for a tool_result whose message carries
742 // images: the text first, then one image block per parseable data URL. It
743 // returns nil when nothing parses, so text-only results keep plain string
744 // content — byte-identical serialization to previous releases.
745 func toolResultBlocks(text string, images []string) []contentBlock {
746 var imgs []contentBlock
747 for _, url := range images {
748 if mt, data, ok := provider.ParseImageDataURL(url); ok {
749 imgs = append(imgs, contentBlock{Type: "image", Source: &imageSource{Type: "base64", MediaType: mt, Data: data}})
750 }
751 }
752 if imgs == nil {
753 return nil
754 }
755 return append([]contentBlock{{Type: "text", Text: text}}, imgs...)
756 }
757
758 type anthTool struct {
759 Type string `json:"type,omitempty"` // "web_search" for server-side search; empty for named tools
760 Name string `json:"name,omitempty"`
761 Description string `json:"description,omitempty"`
762 InputSchema json.RawMessage `json:"input_schema,omitempty"`
763 Strict bool `json:"strict,omitempty"`
764 DeferLoading bool `json:"defer_loading,omitempty"`
765 CacheControl *cacheControl `json:"cache_control,omitempty"`
766 }
767
768 // streamEvent is the discriminated SSE event; read the fields matching Type.
769 type streamEvent struct {
770 Type string `json:"type"`
771 Index int `json:"index"`
772 Message *struct {
773 Usage *wireUsage `json:"usage"`
774 } `json:"message"`
775 ContentBlock *streamContentBlock `json:"content_block"`
776 Delta *struct {
777 Type string `json:"type"` // text_delta | thinking_delta | signature_delta | input_json_delta | web_search_tool_result_delta
778 Text string `json:"text"` // text_delta
779 Thinking string `json:"thinking"` // thinking_delta
780 Signature string `json:"signature"` // signature_delta
781 PartialJSON string `json:"partial_json"` // input_json_delta
782 StopReason string `json:"stop_reason"` // message_delta
783 WebSearchResults json.RawMessage `json:"results"` // web_search_tool_result_delta
784 } `json:"delta"`
785 Usage *wireUsage `json:"usage"` // message_delta (cumulative output_tokens)
786 Error *struct {
787 Type string `json:"type"`
788 Message string `json:"message"`
789 } `json:"error"`
790 }
791
792 type wireUsage struct {
793 InputTokens int `json:"input_tokens"`
794 OutputTokens int `json:"output_tokens"`
795 CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
796 CacheReadInputTokens int `json:"cache_read_input_tokens"`
797 }
798
798 lines GO