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