v0.0.14 ebc5dad

anthropic.go RawHistoryFolder

1package provider
2
3import (
4	"bytes"
5	"context"
6	"encoding/base64"
7	"encoding/json"
8	"fmt"
9	"io"
10	"net/http"
11	"strings"
12	"time"
13)
14
15const anthropicDefaultBaseURL = "https://api.anthropic.com"
16const anthropicAPIVersion = "2023-06-01"
17
18// Stealth identity used when talking to Anthropic via subscription OAuth.
19// These values mimic the official Claude Code CLI so Anthropic's edge
20// accepts the request; diverging from them causes 429 rate_limit_error
21// or 403 on the very first request.
22const (
23	claudeCodeVersion  = "2.1.75"
24	claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
25)
26
27// Claude Code's canonical tool casing. When running under OAuth we must
28// advertise tool names that match this list (case-insensitive lookup),
29// because Anthropic's backend cross-checks them.
30var claudeCodeToolNames = map[string]string{
31	"read":  "Read",
32	"write": "Write",
33	"edit":  "Edit",
34	"bash":  "Bash",
35	"grep":  "Grep",
36	"glob":  "Glob",
37}
38
39func toClaudeCodeToolName(name string) string {
40	if cc, ok := claudeCodeToolNames[strings.ToLower(name)]; ok {
41		return cc
42	}
43	return name
44}
45
46func fromClaudeCodeToolName(name string, tools []Tool) string {
47	lower := strings.ToLower(name)
48	for _, t := range tools {
49		if strings.ToLower(t.Name) == lower {
50			return t.Name
51		}
52	}
53	return name
54}
55
56// anthropicClient implements Client against the Anthropic Messages API.
57type anthropicClient struct {
58	apiKey   string
59	baseURL  string
60	oauthTok string // when non-empty, send Bearer auth instead of x-api-key
61	http     *http.Client
62}
63
64// NewAnthropic creates an Anthropic client using an API key. baseURL may be empty.
65func NewAnthropic(apiKey, baseURL string) Client {
66	if baseURL == "" {
67		baseURL = anthropicDefaultBaseURL
68	}
69	return &anthropicClient{
70		apiKey:  apiKey,
71		baseURL: strings.TrimRight(baseURL, "/"),
72		http:    &http.Client{Timeout: 0},
73	}
74}
75
76// NewAnthropicOAuth creates an Anthropic client using a subscription OAuth access token.
77func NewAnthropicOAuth(accessToken, baseURL string) Client {
78	if baseURL == "" {
79		baseURL = anthropicDefaultBaseURL
80	}
81	return &anthropicClient{
82		oauthTok: accessToken,
83		baseURL:  strings.TrimRight(baseURL, "/"),
84		http:     &http.Client{Timeout: 0},
85	}
86}
87
88func (c *anthropicClient) Name() string { return "anthropic" }
89
90// ---- wire types ----
91
92type anthTextBlock struct {
93	Type         string         `json:"type"` // "text"
94	Text         string         `json:"text"`
95	CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
96}
97
98type anthImageSource struct {
99	Type      string `json:"type"` // "base64"
100	MediaType string `json:"media_type"`
101	Data      string `json:"data"`
102}
103
104type anthImageBlock struct {
105	Type         string          `json:"type"` // "image"
106	Source       anthImageSource `json:"source"`
107	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
108}
109
110type anthToolUseBlock struct {
111	Type         string          `json:"type"` // "tool_use"
112	ID           string          `json:"id"`
113	Name         string          `json:"name"`
114	Input        json.RawMessage `json:"input"`
115	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
116}
117
118type anthToolResultBlock struct {
119	Type         string          `json:"type"` // "tool_result"
120	ToolUseID    string          `json:"tool_use_id"`
121	Content      json.RawMessage `json:"content"` // string or array of blocks
122	IsError      bool            `json:"is_error,omitempty"`
123	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
124}
125
126type anthCacheCtrl struct {
127	Type string `json:"type"` // "ephemeral"
128	TTL  string `json:"ttl,omitempty"`
129}
130
131type anthMessage struct {
132	Role    string        `json:"role"`
133	Content []interface{} `json:"content"`
134}
135
136type anthSystemBlock struct {
137	Type         string         `json:"type"` // "text"
138	Text         string         `json:"text"`
139	CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
140}
141
142type anthTool struct {
143	Name         string          `json:"name"`
144	Description  string          `json:"description,omitempty"`
145	InputSchema  json.RawMessage `json:"input_schema"`
146	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
147}
148
149type anthThinking struct {
150	Type         string `json:"type"` // "enabled"
151	BudgetTokens int    `json:"budget_tokens"`
152}
153
154type anthRequest struct {
155	Model       string            `json:"model"`
156	MaxTokens   int               `json:"max_tokens"`
157	System      []anthSystemBlock `json:"system,omitempty"`
158	Messages    []anthMessage     `json:"messages"`
159	Tools       []anthTool        `json:"tools,omitempty"`
160	Temperature *float32          `json:"temperature,omitempty"`
161	Thinking    *anthThinking     `json:"thinking,omitempty"`
162	Stream      bool              `json:"stream"`
163}
164
165// ---- request building ----
166
167func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
168	m, err := FindModel("anthropic", req.Model)
169	if err != nil {
170		return nil, err
171	}
172	maxTok := req.MaxTokens
173	if maxTok <= 0 {
174		maxTok = m.MaxOutput
175	}
176
177	out := &anthRequest{
178		Model:       req.Model,
179		MaxTokens:   maxTok,
180		Temperature: req.Temperature,
181		Stream:      true,
182	}
183
184	// System prompt assembly differs between api-key and OAuth modes.
185	// OAuth requests MUST begin with the Claude Code identity line or
186	// Anthropic rejects them (429 rate_limit_error with zero tokens used).
187	if c.oauthTok != "" {
188		out.System = []anthSystemBlock{{
189			Type:         "text",
190			Text:         claudeCodeIdentity,
191			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
192		}}
193		if req.System != "" {
194			out.System = append(out.System, anthSystemBlock{
195				Type:         "text",
196				Text:         req.System,
197				CacheControl: &anthCacheCtrl{Type: "ephemeral"},
198			})
199		}
200	} else if req.System != "" {
201		out.System = []anthSystemBlock{{
202			Type:         "text",
203			Text:         req.System,
204			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
205		}}
206	}
207
208	if req.Reasoning != "" && m.Reasoning {
209		budget := anthropicReasoningBudget(req.Reasoning)
210		if budget > 0 {
211			out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
212			// Reasoning requires max_tokens > budget.
213			if out.MaxTokens <= budget {
214				out.MaxTokens = budget + 4096
215			}
216		}
217	}
218
219	for _, t := range req.Tools {
220		name := t.Name
221		if c.oauthTok != "" {
222			name = toClaudeCodeToolName(name)
223		}
224		out.Tools = append(out.Tools, anthTool{
225			Name:        name,
226			Description: t.Description,
227			InputSchema: t.Schema,
228		})
229	}
230	// Cache the last tool definition (applies cache breakpoint to the whole tools array).
231	if n := len(out.Tools); n > 0 {
232		out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"}
233	}
234
235	// Group messages: consecutive user/tool roles into one "user" message.
236	// Anthropic only has roles "user" and "assistant"; tool_result blocks live in user messages.
237	for _, msg := range req.Messages {
238		renameTools := c.oauthTok != ""
239		switch msg.Role {
240		case RoleUser:
241			out.Messages = append(out.Messages, anthMessage{
242				Role:    "user",
243				Content: convertAnthContent(msg.Content, renameTools),
244			})
245		case RoleTool:
246			// Attach tool_result blocks to a user message; merge with prior user msg if last.
247			blocks := convertAnthContent(msg.Content, renameTools)
248			if n := len(out.Messages); n > 0 && out.Messages[n-1].Role == "user" {
249				out.Messages[n-1].Content = append(out.Messages[n-1].Content, blocks...)
250			} else {
251				out.Messages = append(out.Messages, anthMessage{Role: "user", Content: blocks})
252			}
253		case RoleAssistant:
254			out.Messages = append(out.Messages, anthMessage{
255				Role:    "assistant",
256				Content: convertAnthContent(msg.Content, renameTools),
257			})
258		}
259	}
260
261	// Mark the last two user messages with cache_control so anthropic
262	// caches the running conversation prefix. Combined with the system
263	// + tools breakpoints above this is the recommended layout for
264	// multi-turn caching: turn N writes a cache that turn N+1 reads,
265	// dropping per-turn input tokens from "system + history" down to
266	// just the new user message. Anthropic allows up to 4 breakpoints
267	// per request; we use system + tools + 2 conversation = 4.
268	tagUserCache(out.Messages)
269
270	return out, nil
271}
272
273// tagUserCache attaches a cache_control marker to the last block of
274// the most recent (and second-most-recent, if any) user message in
275// msgs. The marker tells the api to checkpoint the prefix at that
276// point so subsequent requests can replay everything up to and
277// including that block as a cache hit.
278func tagUserCache(msgs []anthMessage) {
279	indexes := make([]int, 0, 2)
280	for i := len(msgs) - 1; i >= 0 && len(indexes) < 2; i-- {
281		if msgs[i].Role == "user" {
282			indexes = append(indexes, i)
283		}
284	}
285	for _, idx := range indexes {
286		markLastBlockEphemeral(msgs[idx].Content)
287	}
288}
289
290// markLastBlockEphemeral sets CacheControl on the last entry in blocks
291// regardless of whether it's a text, image, tool_use, or tool_result.
292// Each block type carries its own CacheControl pointer so we type-
293// switch + reassign the slice element.
294func markLastBlockEphemeral(blocks []interface{}) {
295	if len(blocks) == 0 {
296		return
297	}
298	i := len(blocks) - 1
299	cc := &anthCacheCtrl{Type: "ephemeral"}
300	switch v := blocks[i].(type) {
301	case anthTextBlock:
302		v.CacheControl = cc
303		blocks[i] = v
304	case anthImageBlock:
305		v.CacheControl = cc
306		blocks[i] = v
307	case anthToolUseBlock:
308		v.CacheControl = cc
309		blocks[i] = v
310	case anthToolResultBlock:
311		v.CacheControl = cc
312		blocks[i] = v
313	}
314}
315
316func anthropicReasoningBudget(level string) int {
317	switch strings.ToLower(level) {
318	case "low":
319		return 2048
320	case "medium":
321		return 8192
322	case "high":
323		return 16384
324	default:
325		return 0
326	}
327}
328
329func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
330	out := make([]interface{}, 0, len(blocks))
331	for _, b := range blocks {
332		switch v := b.(type) {
333		case TextBlock:
334			if v.Text == "" {
335				continue
336			}
337			out = append(out, anthTextBlock{Type: "text", Text: v.Text})
338		case ImageBlock:
339			out = append(out, anthImageBlock{
340				Type: "image",
341				Source: anthImageSource{
342					Type:      "base64",
343					MediaType: v.MimeType,
344					Data:      base64.StdEncoding.EncodeToString(v.Data),
345				},
346			})
347		case ToolCallBlock:
348			args := v.Arguments
349			if len(args) == 0 {
350				args = json.RawMessage("{}")
351			}
352			name := v.Name
353			if renameTools {
354				name = toClaudeCodeToolName(name)
355			}
356			out = append(out, anthToolUseBlock{
357				Type: "tool_use", ID: v.ID, Name: name, Input: args,
358			})
359		case ToolResultBlock:
360			// Flatten content to a string if all text; else array of blocks.
361			content, _ := anthBuildToolResultContent(v.Content)
362			out = append(out, anthToolResultBlock{
363				Type:      "tool_result",
364				ToolUseID: v.CallID,
365				Content:   content,
366				IsError:   v.IsError,
367			})
368		}
369	}
370	return out
371}
372
373func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) {
374	onlyText := true
375	var sb strings.Builder
376	for _, b := range blocks {
377		if tb, ok := b.(TextBlock); ok {
378			if sb.Len() > 0 {
379				sb.WriteString("\n")
380			}
381			sb.WriteString(tb.Text)
382		} else {
383			onlyText = false
384			break
385		}
386	}
387	if onlyText {
388		if sb.Len() == 0 {
389			return json.Marshal("")
390		}
391		return json.Marshal(sb.String())
392	}
393	// Array form: text + image blocks.
394	arr := make([]interface{}, 0, len(blocks))
395	for _, b := range blocks {
396		switch v := b.(type) {
397		case TextBlock:
398			arr = append(arr, anthTextBlock{Type: "text", Text: v.Text})
399		case ImageBlock:
400			arr = append(arr, anthImageBlock{
401				Type: "image",
402				Source: anthImageSource{
403					Type:      "base64",
404					MediaType: v.MimeType,
405					Data:      base64.StdEncoding.EncodeToString(v.Data),
406				},
407			})
408		}
409	}
410	return json.Marshal(arr)
411}
412
413// ---- streaming ----
414
415func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
416	wire, err := c.buildRequest(req)
417	if err != nil {
418		return nil, err
419	}
420	body, err := json.Marshal(wire)
421	if err != nil {
422		return nil, err
423	}
424
425	httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
426	if err != nil {
427		return nil, err
428	}
429	httpReq.Header.Set("content-type", "application/json")
430	httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
431	if c.oauthTok != "" {
432		// Claude-Code-shaped request: identical headers and values as the
433		// official CLI. Any drift triggers Anthropic's anti-abuse check and
434		// rate-limits (or outright blocks) the request.
435		httpReq.Header.Set("accept", "application/json")
436		httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
437		httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
438		httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
439		httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion+" (external, cli)")
440		httpReq.Header.Set("x-app", "cli")
441		// Remove x-api-key entirely by NOT setting it.
442	} else {
443		httpReq.Header.Set("accept", "text/event-stream")
444		httpReq.Header.Set("x-api-key", c.apiKey)
445	}
446
447	resp, err := c.http.Do(httpReq)
448	if err != nil {
449		return nil, fmt.Errorf("anthropic: %w", err)
450	}
451	if resp.StatusCode != http.StatusOK {
452		b, _ := io.ReadAll(resp.Body)
453		resp.Body.Close()
454		return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
455	}
456
457	out := make(chan Event, 16)
458	go c.runStream(ctx, resp, req, out)
459	return out, nil
460}
461
462func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
463	defer close(out)
464	defer resp.Body.Close()
465
466	model, _ := FindModel("anthropic", req.Model)
467	out <- EventStart{Model: req.Model, Provider: "anthropic"}
468
469	raw := make(chan sseEvent, 16)
470	go readSSE(resp.Body, raw)
471
472	// State for assembling the assistant message. Blocks are indexed
473	// by their `index` field from Anthropic so we can preserve the
474	// interleaved order the model emitted them in (text may come
475	// before OR after tool_use; mixing both happens frequently).
476	type blockEntry struct {
477		kind     string // "text" | "tool_use"
478		textBuf  strings.Builder
479		toolCall ToolCallBlock
480		toolArgs strings.Builder
481	}
482	var (
483		blocks     = map[int]*blockEntry{}
484		blockOrder []int // insertion order of indexes
485		activeIdx  = -1
486		usage      Usage
487		stop       StopReason = StopEnd
488		finalErr   error
489	)
490	_ = activeIdx // read-only indicator used for legacy parity
491
492	registerBlock := func(idx int, kind string) *blockEntry {
493		if be, ok := blocks[idx]; ok {
494			return be
495		}
496		be := &blockEntry{kind: kind}
497		blocks[idx] = be
498		blockOrder = append(blockOrder, idx)
499		return be
500	}
501
502	assembleMsg := func() Message {
503		content := []Content{}
504		for _, idx := range blockOrder {
505			be := blocks[idx]
506			switch be.kind {
507			case "text":
508				if be.textBuf.Len() > 0 {
509					content = append(content, TextBlock{Text: be.textBuf.String()})
510				}
511			case "tool_use":
512				tc := be.toolCall
513				args := be.toolArgs.String()
514				if args == "" {
515					args = "{}"
516				}
517				tc.Arguments = json.RawMessage(args)
518				content = append(content, tc)
519			}
520		}
521		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
522	}
523
524	sendDone := func() {
525		usage.CostUSD = ComputeCost(model, usage)
526		out <- EventUsage{Usage: usage}
527		out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
528	}
529
530	for {
531		select {
532		case <-ctx.Done():
533			stop = StopAborted
534			finalErr = ctx.Err()
535			sendDone()
536			return
537		case ev, ok := <-raw:
538			if !ok {
539				sendDone()
540				return
541			}
542			// Parse event payload based on event: type.
543			var payload map[string]json.RawMessage
544			if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
545				continue
546			}
547			switch ev.Event {
548			case "content_block_start":
549				var idx int
550				if b, ok := payload["index"]; ok {
551					_ = json.Unmarshal(b, &idx)
552				}
553				var block struct {
554					Type  string          `json:"type"`
555					ID    string          `json:"id"`
556					Name  string          `json:"name"`
557					Text  string          `json:"text"`
558					Input json.RawMessage `json:"input"`
559				}
560				if b, ok := payload["content_block"]; ok {
561					_ = json.Unmarshal(b, &block)
562				}
563				activeIdx = idx
564				switch block.Type {
565				case "tool_use":
566					name := block.Name
567					if c.oauthTok != "" {
568						name = fromClaudeCodeToolName(name, req.Tools)
569					}
570					be := registerBlock(idx, "tool_use")
571					be.toolCall = ToolCallBlock{ID: block.ID, Name: name}
572					out <- EventToolStart{ID: block.ID, Name: name}
573				case "text":
574					registerBlock(idx, "text")
575				case "thinking":
576					// not surfaced
577				}
578			case "content_block_delta":
579				var idx int
580				if b, ok := payload["index"]; ok {
581					_ = json.Unmarshal(b, &idx)
582				}
583				var d struct {
584					Type        string `json:"type"`
585					Text        string `json:"text"`
586					PartialJSON string `json:"partial_json"`
587					Thinking    string `json:"thinking"`
588				}
589				if b, ok := payload["delta"]; ok {
590					_ = json.Unmarshal(b, &d)
591				}
592				switch d.Type {
593				case "text_delta":
594					if be, ok := blocks[idx]; ok && be.kind == "text" {
595						be.textBuf.WriteString(d.Text)
596					}
597					out <- EventTextDelta{Delta: d.Text}
598				case "input_json_delta":
599					if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
600						be.toolArgs.WriteString(d.PartialJSON)
601						out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON}
602					}
603				case "thinking_delta":
604					// Not surfaced in v1.
605				}
606			case "content_block_stop":
607				var idx int
608				if b, ok := payload["index"]; ok {
609					_ = json.Unmarshal(b, &idx)
610				}
611				if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
612					out <- EventToolEnd{ID: be.toolCall.ID}
613				}
614				activeIdx = -1
615			case "message_start":
616				var m struct {
617					Message struct {
618						Usage struct {
619							InputTokens              int `json:"input_tokens"`
620							OutputTokens             int `json:"output_tokens"`
621							CacheReadInputTokens     int `json:"cache_read_input_tokens"`
622							CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
623						} `json:"usage"`
624					} `json:"message"`
625				}
626				_ = json.Unmarshal([]byte(ev.Data), &m)
627				usage.InputTokens += m.Message.Usage.InputTokens
628				usage.OutputTokens += m.Message.Usage.OutputTokens
629				usage.CacheReadTokens += m.Message.Usage.CacheReadInputTokens
630				usage.CacheWriteTokens += m.Message.Usage.CacheCreationInputTokens
631			case "message_delta":
632				var m struct {
633					Delta struct {
634						StopReason string `json:"stop_reason"`
635					} `json:"delta"`
636					Usage struct {
637						InputTokens              int `json:"input_tokens"`
638						OutputTokens             int `json:"output_tokens"`
639						CacheReadInputTokens     int `json:"cache_read_input_tokens"`
640						CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
641					} `json:"usage"`
642				}
643				_ = json.Unmarshal([]byte(ev.Data), &m)
644				usage.InputTokens += m.Usage.InputTokens
645				usage.OutputTokens += m.Usage.OutputTokens
646				usage.CacheReadTokens += m.Usage.CacheReadInputTokens
647				usage.CacheWriteTokens += m.Usage.CacheCreationInputTokens
648				switch m.Delta.StopReason {
649				case "end_turn", "stop_sequence":
650					stop = StopEnd
651				case "max_tokens":
652					stop = StopLength
653				case "tool_use":
654					stop = StopToolUse
655				}
656			case "message_stop":
657				sendDone()
658				return
659			case "error":
660				var e struct {
661					Error struct {
662						Type    string `json:"type"`
663						Message string `json:"message"`
664					} `json:"error"`
665				}
666				_ = json.Unmarshal([]byte(ev.Data), &e)
667				stop = StopError
668				finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message)
669				sendDone()
670				return
671			}
672			_ = activeIdx
673		}
674	}
675}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close