package provider import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) const anthropicDefaultBaseURL = "https://api.anthropic.com" const anthropicAPIVersion = "2023-06-01" // Stealth identity used when talking to Anthropic via subscription OAuth. // These values mimic the official Claude Code CLI so Anthropic's edge // accepts the request; diverging from them causes 429 rate_limit_error // or 403 on the very first request. const ( claudeCodeVersion = "2.1.75" claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." ) // Claude Code's canonical tool casing. When running under OAuth we must // advertise tool names that match this list (case-insensitive lookup), // because Anthropic's backend cross-checks them. var claudeCodeToolNames = map[string]string{ "read": "Read", "write": "Write", "edit": "Edit", "bash": "Bash", "grep": "Grep", "glob": "Glob", } func toClaudeCodeToolName(name string) string { if cc, ok := claudeCodeToolNames[strings.ToLower(name)]; ok { return cc } return name } func fromClaudeCodeToolName(name string, tools []Tool) string { lower := strings.ToLower(name) for _, t := range tools { if strings.ToLower(t.Name) == lower { return t.Name } } return name } // anthropicClient implements Client against the Anthropic Messages API. type anthropicClient struct { apiKey string baseURL string oauthTok string // when non-empty, send Bearer auth instead of x-api-key http *http.Client // name overrides the default "anthropic" identity. Anthropic-Messages- // compatible third-party endpoints (kimi-coding, fireworks, minimax, // vercel-ai-gateway, etc.) set this so cost lookup / logging / image- // stripping logic can route on a stable provider id. name string // headers carries extra request headers (e.g. Kimi Code's X-Msh-*). headers map[string]string } // NewAnthropic creates an Anthropic client using an API key. baseURL may be empty. func NewAnthropic(apiKey, baseURL string) Client { if baseURL == "" { baseURL = anthropicDefaultBaseURL } return &anthropicClient{ apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{Timeout: 0}, } } // NewAnthropicOAuth creates an Anthropic client using a subscription OAuth access token. func NewAnthropicOAuth(accessToken, baseURL string) Client { if baseURL == "" { baseURL = anthropicDefaultBaseURL } return &anthropicClient{ oauthTok: accessToken, baseURL: strings.TrimRight(baseURL, "/"), http: &http.Client{Timeout: 0}, } } func (c *anthropicClient) Name() string { if c.name != "" { return c.name } return "anthropic" } // ---- wire types ---- type anthTextBlock struct { Type string `json:"type"` // "text" Text string `json:"text"` CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` } type anthImageSource struct { Type string `json:"type"` // "base64" MediaType string `json:"media_type"` Data string `json:"data"` } type anthImageBlock struct { Type string `json:"type"` // "image" Source anthImageSource `json:"source"` CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` } type anthToolUseBlock struct { Type string `json:"type"` // "tool_use" ID string `json:"id"` Name string `json:"name"` Input json.RawMessage `json:"input"` CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` } type anthToolResultBlock struct { Type string `json:"type"` // "tool_result" ToolUseID string `json:"tool_use_id"` Content json.RawMessage `json:"content"` // string or array of blocks IsError bool `json:"is_error,omitempty"` CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` } type anthCacheCtrl struct { Type string `json:"type"` // "ephemeral" TTL string `json:"ttl,omitempty"` } type anthMessage struct { Role string `json:"role"` Content []interface{} `json:"content"` } type anthSystemBlock struct { Type string `json:"type"` // "text" Text string `json:"text"` CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` } type anthTool struct { Name string `json:"name"` Description string `json:"description,omitempty"` InputSchema json.RawMessage `json:"input_schema"` CacheControl *anthCacheCtrl `json:"cache_control,omitempty"` } type anthThinking struct { // Type is "enabled" for budget-based thinking (Opus 4.6 and // earlier) or "adaptive" for adaptive-thinking models (Opus 4.7+). Type string `json:"type"` // BudgetTokens is only sent for Type=="enabled". Adaptive models // reject it (400), so it is omitted for them. BudgetTokens int `json:"budget_tokens,omitempty"` } // anthOutputConfig carries the effort knob used by adaptive-thinking // models to control reasoning depth ("low"|"medium"|"high"|"xhigh"). type anthOutputConfig struct { Effort string `json:"effort,omitempty"` } type anthRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` System []anthSystemBlock `json:"system,omitempty"` Messages []anthMessage `json:"messages"` Tools []anthTool `json:"tools,omitempty"` Temperature *float32 `json:"temperature,omitempty"` Thinking *anthThinking `json:"thinking,omitempty"` OutputConfig *anthOutputConfig `json:"output_config,omitempty"` Stream bool `json:"stream"` } // usesAdaptiveThinking reports whether a model only supports the // adaptive thinking mode (Opus 4.7 and later). These models reject // explicit thinking budgets and non-default sampling parameters. The // catalog flag is authoritative; the id-substring fallback catches // the same family when reached through an Anthropic-Messages- // compatible proxy whose catalog row predates the flag. func usesAdaptiveThinking(m Model) bool { if m.AdaptiveThinking { return true } id := strings.ToLower(m.ID) for _, marker := range []string{"opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"} { if strings.Contains(id, marker) { return true } } return false } // ---- request building ---- func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { // Look up under the client's actual provider id, which may be "anthropic" // (default) or a third party that speaks the Anthropic Messages API // (kimi, fireworks, minimax, vercel-ai-gateway, ...). Falling back to a // provider-agnostic lookup keeps things working for catalog-less // configurations (e.g. user passes --model on an obscure third party). m, err := FindModel(c.Name(), req.Model) if err != nil { if m2, err2 := FindModel("", req.Model); err2 == nil { m = m2 err = nil } } if err != nil { return nil, err } maxTok := req.MaxTokens if maxTok <= 0 { maxTok = m.MaxOutput } adaptive := usesAdaptiveThinking(m) out := &anthRequest{ Model: req.Model, MaxTokens: maxTok, Stream: true, } // Adaptive-thinking models reject non-default sampling params // (temperature/top_p/top_k -> 400). Only forward temperature for // models that accept it. if !adaptive { out.Temperature = req.Temperature } // System prompt assembly differs between api-key and OAuth modes. // OAuth requests MUST begin with the Claude Code identity line or // Anthropic rejects them (429 rate_limit_error with zero tokens used). // // Cache budget: anthropic caps cache_control to 4 breakpoints per // request. We spend them on: // 1. claude-code identity (OAuth only; stable forever) // 2. user system prompt (changes per-session at most) // 3. last tool definition (tools change rarely) // 4. last message block (advances every turn) // // The identity line gets its OWN cache_control so the prefix // [identity] is cacheable independently of the user system // prompt. Without that, the cache prefix starts after block 2 // and any drift in the user prompt (e.g. the Current date // line flipping at midnight) invalidates everything, including // the 17 identity tokens we have to re-send every request // forever. if c.oauthTok != "" { out.System = []anthSystemBlock{{ Type: "text", Text: claudeCodeIdentity, CacheControl: &anthCacheCtrl{Type: "ephemeral"}, }} if req.System != "" { out.System = append(out.System, anthSystemBlock{ Type: "text", Text: req.System, CacheControl: &anthCacheCtrl{Type: "ephemeral"}, }) } } else if req.System != "" { out.System = []anthSystemBlock{{ Type: "text", Text: req.System, CacheControl: &anthCacheCtrl{Type: "ephemeral"}, }} } if req.Reasoning != "" && m.Reasoning { if adaptive { // Adaptive-thinking models (Opus 4.7+): the model decides when // and how much to think; depth is steered by output_config.effort. // Explicit budgets are rejected with a 400, so none is sent. out.Thinking = &anthThinking{Type: "adaptive"} if effort := AnthropicAdaptiveEffort(req.Reasoning); effort != "" { out.OutputConfig = &anthOutputConfig{Effort: effort} } } else { budget := anthropicReasoningBudget(req.Reasoning) if budget > 0 { // Reasoning requires max_tokens > budget. Keep at least a small // answer budget while respecting the model's advertised output cap. const minAnswerTokens = 1024 if m.MaxOutput > minAnswerTokens && budget >= m.MaxOutput { budget = m.MaxOutput - minAnswerTokens } out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget} if out.MaxTokens <= budget { out.MaxTokens = budget + minAnswerTokens if m.MaxOutput > 0 && out.MaxTokens > m.MaxOutput { out.MaxTokens = m.MaxOutput } } } } } for _, t := range req.Tools { name := t.Name if c.oauthTok != "" { name = toClaudeCodeToolName(name) } out.Tools = append(out.Tools, anthTool{ Name: name, Description: t.Description, InputSchema: t.Schema, }) } // Cache the last tool definition (applies cache breakpoint to the whole tools array). if n := len(out.Tools); n > 0 { out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"} } // Convert messages. Anthropic's wire format has only "user" and // "assistant" roles; tool_result blocks live inside user messages. // Some image-capable providers (for example Gemini image generation) // can emit assistant image blocks. Anthropic only accepts image blocks // in user messages, so strip assistant-side images when switching // providers mid-session. The saved-path TextBlock emitted beside the // image keeps the artifact reachable in chat. // // CRITICAL: tool_result blocks go into their OWN new user // message, they are NOT merged into the preceding user message. // Merging would mutate the prior user message's content array // between turn N and turn N+1: turn N caches the prefix ending at // [user: "read sample.ts"], turn N+1 sends // [user: "read sample.ts" + tool_result=...] which is a // different block sequence, busting the cache prefix match. // Anthropic's API happily accepts consecutive user messages, and // emitting them separately keeps each message bit-stable across // turns, so the cache prefix matches for the entire history up // to the newest block. req.Messages = RepairOrphanedToolResults(req.Messages) for _, msg := range req.Messages { renameTools := c.oauthTok != "" switch msg.Role { case RoleUser: out.Messages = append(out.Messages, anthMessage{ Role: "user", Content: convertAnthContent(msg.Content, renameTools), }) case RoleTool: out.Messages = append(out.Messages, anthMessage{ Role: "user", Content: convertAnthContent(msg.Content, renameTools), }) case RoleAssistant: out.Messages = append(out.Messages, anthMessage{ Role: "assistant", Content: convertAnthContent(filterAnthAssistantContent(msg.Content), renameTools), }) } } // Tag the LAST user message with cache_control. Spends the 4th // breakpoint. For prefixes under ~1024 tokens (Anthropic's // minimum cacheable block size for Opus), no cache is written. tagLastUserCache(out.Messages) return out, nil } // tagLastUserCache marks the last block of the most recent user // message. One marker; combined with identity + systemPrompt + // last-tool, spends Anthropic's 4-breakpoint budget. func tagLastUserCache(msgs []anthMessage) { for i := len(msgs) - 1; i >= 0; i-- { if msgs[i].Role == "user" { markLastBlockEphemeral(msgs[i].Content) return } } } // markLastBlockEphemeral sets CacheControl on the last entry in blocks // regardless of whether it's a text, image, tool_use, or tool_result. // Each block type carries its own CacheControl pointer so we type- // switch + reassign the slice element. func markLastBlockEphemeral(blocks []interface{}) { if len(blocks) == 0 { return } i := len(blocks) - 1 cc := &anthCacheCtrl{Type: "ephemeral"} switch v := blocks[i].(type) { case anthTextBlock: v.CacheControl = cc blocks[i] = v case anthImageBlock: v.CacheControl = cc blocks[i] = v case anthToolUseBlock: v.CacheControl = cc blocks[i] = v case anthToolResultBlock: v.CacheControl = cc blocks[i] = v } } func anthropicReasoningBudget(level string) int { return ReasoningBudget(level) } func filterAnthAssistantContent(blocks []Content) []Content { filtered := make([]Content, 0, len(blocks)) for _, b := range blocks { if _, ok := b.(ImageBlock); ok { continue } filtered = append(filtered, b) } return filtered } func convertAnthContent(blocks []Content, renameTools bool) []interface{} { out := make([]interface{}, 0, len(blocks)) for _, b := range blocks { switch v := b.(type) { case TextBlock: if v.Text == "" { continue } out = append(out, anthTextBlock{Type: "text", Text: v.Text}) case ImageBlock: data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType) out = append(out, anthImageBlock{ Type: "image", Source: anthImageSource{ Type: "base64", MediaType: mime, Data: base64.StdEncoding.EncodeToString(data), }, }) case ToolCallBlock: args := v.Arguments if len(args) == 0 || !json.Valid(args) { args = json.RawMessage("{}") } name := v.Name if renameTools { name = toClaudeCodeToolName(name) } out = append(out, anthToolUseBlock{ Type: "tool_use", ID: v.ID, Name: name, Input: args, }) case ToolResultBlock: // Flatten content to a string if all text; else array of blocks. content, _ := anthBuildToolResultContent(v.Content) out = append(out, anthToolResultBlock{ Type: "tool_result", ToolUseID: v.CallID, Content: content, IsError: v.IsError, }) } } return out } func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) { onlyText := true var sb strings.Builder for _, b := range blocks { if tb, ok := b.(TextBlock); ok { if sb.Len() > 0 { sb.WriteString("\n") } sb.WriteString(tb.Text) } else { onlyText = false break } } if onlyText { if sb.Len() == 0 { return json.Marshal("") } return json.Marshal(sb.String()) } // Array form: text + image blocks. arr := make([]interface{}, 0, len(blocks)) for _, b := range blocks { switch v := b.(type) { case TextBlock: arr = append(arr, anthTextBlock{Type: "text", Text: v.Text}) case ImageBlock: data, mime := anthShrinkImageBytesIfTooBig(v.Data, v.MimeType) arr = append(arr, anthImageBlock{ Type: "image", Source: anthImageSource{ Type: "base64", MediaType: mime, Data: base64.StdEncoding.EncodeToString(data), }, }) } } return json.Marshal(arr) } // ---- streaming ---- func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { wire, err := c.buildRequest(req) if err != nil { return nil, err } body, err := json.Marshal(wire) if err != nil { return nil, err } // Optional debug dump: when $ZOT_DEBUG_ANTHROPIC is a file path // we append every outgoing request body to it, one JSON object // per line. Useful for diffing turn N vs turn N+1 to understand // why the cache prefix isn't matching. if dump := os.Getenv("ZOT_DEBUG_ANTHROPIC"); dump != "" { if f, derr := os.OpenFile(dump, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); derr == nil { _, _ = f.Write(body) _, _ = f.Write([]byte{'\n'}) _ = f.Close() } } newReq := func() (*http.Request, error) { httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("content-type", "application/json") httpReq.Header.Set("anthropic-version", anthropicAPIVersion) if c.oauthTok != "" { // Claude-Code-shaped request: identical headers and values as the // official CLI. Any drift triggers Anthropic's anti-abuse check and // rate-limits (or outright blocks) the request. httpReq.Header.Set("accept", "application/json") httpReq.Header.Set("authorization", "Bearer "+c.oauthTok) httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14") httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true") httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion) httpReq.Header.Set("x-app", "cli") // Remove x-api-key entirely by NOT setting it. } else { httpReq.Header.Set("accept", "text/event-stream") httpReq.Header.Set("x-api-key", c.apiKey) } // Extra headers (set by anthropic-messages-compatible third parties // — kimi-coding's X-Msh-*, copilot's Editor-Plugin-Version, etc.). // Applied last so callers can override defaults if they really need to. for k, v := range c.headers { httpReq.Header.Set(k, v) } return httpReq, nil } resp, err := doStreamWithRetry(ctx, c.http, newReq) if err != nil { return nil, fmt.Errorf("anthropic: %w", err) } if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } out := make(chan Event, 16) go c.runStream(ctx, resp, req, out) return out, nil } func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) { defer close(out) defer resp.Body.Close() // Same lookup-by-actual-provider-id pattern as buildRequest, so cost // calculation works for third-party Anthropic-Messages endpoints. model, _ := FindModel(c.Name(), req.Model) if model.ID == "" { model, _ = FindModel("", req.Model) } out <- EventStart{Model: req.Model, Provider: c.Name()} raw := make(chan sseEvent, 16) go readSSE(resp.Body, raw) // State for assembling the assistant message. Blocks are indexed // by their `index` field from Anthropic so we can preserve the // interleaved order the model emitted them in (text may come // before OR after tool_use; mixing both happens frequently). type blockEntry struct { kind string // "text" | "tool_use" textBuf strings.Builder toolCall ToolCallBlock toolArgs strings.Builder } var ( blocks = map[int]*blockEntry{} blockOrder []int // insertion order of indexes activeIdx = -1 usage Usage stop StopReason = StopEnd finalErr error ) _ = activeIdx // read-only indicator used for legacy parity registerBlock := func(idx int, kind string) *blockEntry { if be, ok := blocks[idx]; ok { return be } be := &blockEntry{kind: kind} blocks[idx] = be blockOrder = append(blockOrder, idx) return be } assembleMsg := func() Message { content := []Content{} for _, idx := range blockOrder { be := blocks[idx] switch be.kind { case "text": if be.textBuf.Len() > 0 { content = append(content, TextBlock{Text: be.textBuf.String()}) } case "tool_use": tc := be.toolCall args := be.toolArgs.String() if args == "" { args = "{}" } tc.Arguments = json.RawMessage(args) content = append(content, tc) } } return Message{Role: RoleAssistant, Content: content, Time: time.Now()} } sendDone := func() { usage.CostUSD = ComputeCost(model, usage) out <- EventUsage{Usage: usage} out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()} } for { select { case <-ctx.Done(): stop = StopAborted finalErr = ctx.Err() sendDone() return case ev, ok := <-raw: if !ok { sendDone() return } // Parse event payload based on event: type. var payload map[string]json.RawMessage if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil { continue } switch ev.Event { case "content_block_start": var idx int if b, ok := payload["index"]; ok { _ = json.Unmarshal(b, &idx) } var block struct { Type string `json:"type"` ID string `json:"id"` Name string `json:"name"` Text string `json:"text"` Input json.RawMessage `json:"input"` } if b, ok := payload["content_block"]; ok { _ = json.Unmarshal(b, &block) } activeIdx = idx switch block.Type { case "tool_use": name := block.Name if c.oauthTok != "" { name = fromClaudeCodeToolName(name, req.Tools) } be := registerBlock(idx, "tool_use") be.toolCall = ToolCallBlock{ID: block.ID, Name: name} out <- EventToolStart{ID: block.ID, Name: name} case "text": registerBlock(idx, "text") case "thinking": // not surfaced } case "content_block_delta": var idx int if b, ok := payload["index"]; ok { _ = json.Unmarshal(b, &idx) } var d struct { Type string `json:"type"` Text string `json:"text"` PartialJSON string `json:"partial_json"` Thinking string `json:"thinking"` } if b, ok := payload["delta"]; ok { _ = json.Unmarshal(b, &d) } switch d.Type { case "text_delta": if be, ok := blocks[idx]; ok && be.kind == "text" { be.textBuf.WriteString(d.Text) } out <- EventTextDelta{Delta: d.Text} case "input_json_delta": if be, ok := blocks[idx]; ok && be.kind == "tool_use" { be.toolArgs.WriteString(d.PartialJSON) out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON} } case "thinking_delta": // Not surfaced in v1. } case "content_block_stop": var idx int if b, ok := payload["index"]; ok { _ = json.Unmarshal(b, &idx) } if be, ok := blocks[idx]; ok && be.kind == "tool_use" { out <- EventToolEnd{ID: be.toolCall.ID} } activeIdx = -1 case "message_start": var m struct { Message struct { Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` } `json:"usage"` } `json:"message"` } _ = json.Unmarshal([]byte(ev.Data), &m) // Anthropic sends cumulative values on message_start and // again on message_delta (refreshed), so assign, don't // accumulate. Accumulating doubles cache_creation_input // which can be 50-70% of cost. usage.InputTokens = m.Message.Usage.InputTokens usage.OutputTokens = m.Message.Usage.OutputTokens usage.CacheReadTokens = m.Message.Usage.CacheReadInputTokens usage.CacheWriteTokens = m.Message.Usage.CacheCreationInputTokens case "message_delta": var m struct { Delta struct { StopReason string `json:"stop_reason"` } `json:"delta"` Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` } `json:"usage"` } _ = json.Unmarshal([]byte(ev.Data), &m) // Refresh usage from the latest cumulative totals // Anthropic provides. Only apply non-zero fields in case // a given delta only carries output tokens. if m.Usage.InputTokens > 0 { usage.InputTokens = m.Usage.InputTokens } if m.Usage.OutputTokens > 0 { usage.OutputTokens = m.Usage.OutputTokens } if m.Usage.CacheReadInputTokens > 0 { usage.CacheReadTokens = m.Usage.CacheReadInputTokens } if m.Usage.CacheCreationInputTokens > 0 { usage.CacheWriteTokens = m.Usage.CacheCreationInputTokens } switch m.Delta.StopReason { case "end_turn", "stop_sequence": stop = StopEnd case "max_tokens": stop = StopLength case "tool_use": stop = StopToolUse } case "message_stop": sendDone() return case "error": var e struct { Error struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } _ = json.Unmarshal([]byte(ev.Data), &e) stop = StopError finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message) sendDone() return } _ = activeIdx } } }