v0.0.17 36e0efe

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	//
188	// Cache budget: anthropic caps cache_control to 4 breakpoints per
189	// request. We spend them on (system prompt) + (tools tail) + (last
190	// two user messages). The claude-code identity line stays uncached
191	// because it's a few tokens and gets folded into the larger prefix
192	// implicitly anyway.
193	if c.oauthTok != "" {
194		out.System = []anthSystemBlock{{
195			Type: "text",
196			Text: claudeCodeIdentity,
197		}}
198		if req.System != "" {
199			out.System = append(out.System, anthSystemBlock{
200				Type:         "text",
201				Text:         req.System,
202				CacheControl: &anthCacheCtrl{Type: "ephemeral"},
203			})
204		}
205	} else if req.System != "" {
206		out.System = []anthSystemBlock{{
207			Type:         "text",
208			Text:         req.System,
209			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
210		}}
211	}
212
213	if req.Reasoning != "" && m.Reasoning {
214		budget := anthropicReasoningBudget(req.Reasoning)
215		if budget > 0 {
216			out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
217			// Reasoning requires max_tokens > budget.
218			if out.MaxTokens <= budget {
219				out.MaxTokens = budget + 4096
220			}
221		}
222	}
223
224	for _, t := range req.Tools {
225		name := t.Name
226		if c.oauthTok != "" {
227			name = toClaudeCodeToolName(name)
228		}
229		out.Tools = append(out.Tools, anthTool{
230			Name:        name,
231			Description: t.Description,
232			InputSchema: t.Schema,
233		})
234	}
235	// Cache the last tool definition (applies cache breakpoint to the whole tools array).
236	if n := len(out.Tools); n > 0 {
237		out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"}
238	}
239
240	// Group messages: consecutive user/tool roles into one "user" message.
241	// Anthropic only has roles "user" and "assistant"; tool_result blocks live in user messages.
242	for _, msg := range req.Messages {
243		renameTools := c.oauthTok != ""
244		switch msg.Role {
245		case RoleUser:
246			out.Messages = append(out.Messages, anthMessage{
247				Role:    "user",
248				Content: convertAnthContent(msg.Content, renameTools),
249			})
250		case RoleTool:
251			// Attach tool_result blocks to a user message; merge with prior user msg if last.
252			blocks := convertAnthContent(msg.Content, renameTools)
253			if n := len(out.Messages); n > 0 && out.Messages[n-1].Role == "user" {
254				out.Messages[n-1].Content = append(out.Messages[n-1].Content, blocks...)
255			} else {
256				out.Messages = append(out.Messages, anthMessage{Role: "user", Content: blocks})
257			}
258		case RoleAssistant:
259			out.Messages = append(out.Messages, anthMessage{
260				Role:    "assistant",
261				Content: convertAnthContent(msg.Content, renameTools),
262			})
263		}
264	}
265
266	// Mark the last two user messages with cache_control so anthropic
267	// caches the running conversation prefix. Combined with the system
268	// + tools breakpoints above this is the recommended layout for
269	// multi-turn caching: turn N writes a cache that turn N+1 reads,
270	// dropping per-turn input tokens from "system + history" down to
271	// just the new user message. Anthropic allows up to 4 breakpoints
272	// per request; we use system + tools + 2 conversation = 4.
273	tagUserCache(out.Messages)
274
275	return out, nil
276}
277
278// tagUserCache attaches a cache_control marker to the last block of
279// the most recent (and second-most-recent, if any) user message in
280// msgs. The marker tells the api to checkpoint the prefix at that
281// point so subsequent requests can replay everything up to and
282// including that block as a cache hit.
283func tagUserCache(msgs []anthMessage) {
284	indexes := make([]int, 0, 2)
285	for i := len(msgs) - 1; i >= 0 && len(indexes) < 2; i-- {
286		if msgs[i].Role == "user" {
287			indexes = append(indexes, i)
288		}
289	}
290	for _, idx := range indexes {
291		markLastBlockEphemeral(msgs[idx].Content)
292	}
293}
294
295// markLastBlockEphemeral sets CacheControl on the last entry in blocks
296// regardless of whether it's a text, image, tool_use, or tool_result.
297// Each block type carries its own CacheControl pointer so we type-
298// switch + reassign the slice element.
299func markLastBlockEphemeral(blocks []interface{}) {
300	if len(blocks) == 0 {
301		return
302	}
303	i := len(blocks) - 1
304	cc := &anthCacheCtrl{Type: "ephemeral"}
305	switch v := blocks[i].(type) {
306	case anthTextBlock:
307		v.CacheControl = cc
308		blocks[i] = v
309	case anthImageBlock:
310		v.CacheControl = cc
311		blocks[i] = v
312	case anthToolUseBlock:
313		v.CacheControl = cc
314		blocks[i] = v
315	case anthToolResultBlock:
316		v.CacheControl = cc
317		blocks[i] = v
318	}
319}
320
321func anthropicReasoningBudget(level string) int {
322	switch strings.ToLower(level) {
323	case "low":
324		return 2048
325	case "medium":
326		return 8192
327	case "high":
328		return 16384
329	default:
330		return 0
331	}
332}
333
334func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
335	out := make([]interface{}, 0, len(blocks))
336	for _, b := range blocks {
337		switch v := b.(type) {
338		case TextBlock:
339			if v.Text == "" {
340				continue
341			}
342			out = append(out, anthTextBlock{Type: "text", Text: v.Text})
343		case ImageBlock:
344			out = append(out, anthImageBlock{
345				Type: "image",
346				Source: anthImageSource{
347					Type:      "base64",
348					MediaType: v.MimeType,
349					Data:      base64.StdEncoding.EncodeToString(v.Data),
350				},
351			})
352		case ToolCallBlock:
353			args := v.Arguments
354			if len(args) == 0 {
355				args = json.RawMessage("{}")
356			}
357			name := v.Name
358			if renameTools {
359				name = toClaudeCodeToolName(name)
360			}
361			out = append(out, anthToolUseBlock{
362				Type: "tool_use", ID: v.ID, Name: name, Input: args,
363			})
364		case ToolResultBlock:
365			// Flatten content to a string if all text; else array of blocks.
366			content, _ := anthBuildToolResultContent(v.Content)
367			out = append(out, anthToolResultBlock{
368				Type:      "tool_result",
369				ToolUseID: v.CallID,
370				Content:   content,
371				IsError:   v.IsError,
372			})
373		}
374	}
375	return out
376}
377
378func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) {
379	onlyText := true
380	var sb strings.Builder
381	for _, b := range blocks {
382		if tb, ok := b.(TextBlock); ok {
383			if sb.Len() > 0 {
384				sb.WriteString("\n")
385			}
386			sb.WriteString(tb.Text)
387		} else {
388			onlyText = false
389			break
390		}
391	}
392	if onlyText {
393		if sb.Len() == 0 {
394			return json.Marshal("")
395		}
396		return json.Marshal(sb.String())
397	}
398	// Array form: text + image blocks.
399	arr := make([]interface{}, 0, len(blocks))
400	for _, b := range blocks {
401		switch v := b.(type) {
402		case TextBlock:
403			arr = append(arr, anthTextBlock{Type: "text", Text: v.Text})
404		case ImageBlock:
405			arr = append(arr, anthImageBlock{
406				Type: "image",
407				Source: anthImageSource{
408					Type:      "base64",
409					MediaType: v.MimeType,
410					Data:      base64.StdEncoding.EncodeToString(v.Data),
411				},
412			})
413		}
414	}
415	return json.Marshal(arr)
416}
417
418// ---- streaming ----
419
420func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
421	wire, err := c.buildRequest(req)
422	if err != nil {
423		return nil, err
424	}
425	body, err := json.Marshal(wire)
426	if err != nil {
427		return nil, err
428	}
429
430	httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
431	if err != nil {
432		return nil, err
433	}
434	httpReq.Header.Set("content-type", "application/json")
435	httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
436	if c.oauthTok != "" {
437		// Claude-Code-shaped request: identical headers and values as the
438		// official CLI. Any drift triggers Anthropic's anti-abuse check and
439		// rate-limits (or outright blocks) the request.
440		httpReq.Header.Set("accept", "application/json")
441		httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
442		httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
443		httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
444		httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion+" (external, cli)")
445		httpReq.Header.Set("x-app", "cli")
446		// Remove x-api-key entirely by NOT setting it.
447	} else {
448		httpReq.Header.Set("accept", "text/event-stream")
449		httpReq.Header.Set("x-api-key", c.apiKey)
450	}
451
452	resp, err := c.http.Do(httpReq)
453	if err != nil {
454		return nil, fmt.Errorf("anthropic: %w", err)
455	}
456	if resp.StatusCode != http.StatusOK {
457		b, _ := io.ReadAll(resp.Body)
458		resp.Body.Close()
459		return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
460	}
461
462	out := make(chan Event, 16)
463	go c.runStream(ctx, resp, req, out)
464	return out, nil
465}
466
467func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
468	defer close(out)
469	defer resp.Body.Close()
470
471	model, _ := FindModel("anthropic", req.Model)
472	out <- EventStart{Model: req.Model, Provider: "anthropic"}
473
474	raw := make(chan sseEvent, 16)
475	go readSSE(resp.Body, raw)
476
477	// State for assembling the assistant message. Blocks are indexed
478	// by their `index` field from Anthropic so we can preserve the
479	// interleaved order the model emitted them in (text may come
480	// before OR after tool_use; mixing both happens frequently).
481	type blockEntry struct {
482		kind     string // "text" | "tool_use"
483		textBuf  strings.Builder
484		toolCall ToolCallBlock
485		toolArgs strings.Builder
486	}
487	var (
488		blocks     = map[int]*blockEntry{}
489		blockOrder []int // insertion order of indexes
490		activeIdx  = -1
491		usage      Usage
492		stop       StopReason = StopEnd
493		finalErr   error
494	)
495	_ = activeIdx // read-only indicator used for legacy parity
496
497	registerBlock := func(idx int, kind string) *blockEntry {
498		if be, ok := blocks[idx]; ok {
499			return be
500		}
501		be := &blockEntry{kind: kind}
502		blocks[idx] = be
503		blockOrder = append(blockOrder, idx)
504		return be
505	}
506
507	assembleMsg := func() Message {
508		content := []Content{}
509		for _, idx := range blockOrder {
510			be := blocks[idx]
511			switch be.kind {
512			case "text":
513				if be.textBuf.Len() > 0 {
514					content = append(content, TextBlock{Text: be.textBuf.String()})
515				}
516			case "tool_use":
517				tc := be.toolCall
518				args := be.toolArgs.String()
519				if args == "" {
520					args = "{}"
521				}
522				tc.Arguments = json.RawMessage(args)
523				content = append(content, tc)
524			}
525		}
526		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
527	}
528
529	sendDone := func() {
530		usage.CostUSD = ComputeCost(model, usage)
531		out <- EventUsage{Usage: usage}
532		out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
533	}
534
535	for {
536		select {
537		case <-ctx.Done():
538			stop = StopAborted
539			finalErr = ctx.Err()
540			sendDone()
541			return
542		case ev, ok := <-raw:
543			if !ok {
544				sendDone()
545				return
546			}
547			// Parse event payload based on event: type.
548			var payload map[string]json.RawMessage
549			if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
550				continue
551			}
552			switch ev.Event {
553			case "content_block_start":
554				var idx int
555				if b, ok := payload["index"]; ok {
556					_ = json.Unmarshal(b, &idx)
557				}
558				var block struct {
559					Type  string          `json:"type"`
560					ID    string          `json:"id"`
561					Name  string          `json:"name"`
562					Text  string          `json:"text"`
563					Input json.RawMessage `json:"input"`
564				}
565				if b, ok := payload["content_block"]; ok {
566					_ = json.Unmarshal(b, &block)
567				}
568				activeIdx = idx
569				switch block.Type {
570				case "tool_use":
571					name := block.Name
572					if c.oauthTok != "" {
573						name = fromClaudeCodeToolName(name, req.Tools)
574					}
575					be := registerBlock(idx, "tool_use")
576					be.toolCall = ToolCallBlock{ID: block.ID, Name: name}
577					out <- EventToolStart{ID: block.ID, Name: name}
578				case "text":
579					registerBlock(idx, "text")
580				case "thinking":
581					// not surfaced
582				}
583			case "content_block_delta":
584				var idx int
585				if b, ok := payload["index"]; ok {
586					_ = json.Unmarshal(b, &idx)
587				}
588				var d struct {
589					Type        string `json:"type"`
590					Text        string `json:"text"`
591					PartialJSON string `json:"partial_json"`
592					Thinking    string `json:"thinking"`
593				}
594				if b, ok := payload["delta"]; ok {
595					_ = json.Unmarshal(b, &d)
596				}
597				switch d.Type {
598				case "text_delta":
599					if be, ok := blocks[idx]; ok && be.kind == "text" {
600						be.textBuf.WriteString(d.Text)
601					}
602					out <- EventTextDelta{Delta: d.Text}
603				case "input_json_delta":
604					if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
605						be.toolArgs.WriteString(d.PartialJSON)
606						out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON}
607					}
608				case "thinking_delta":
609					// Not surfaced in v1.
610				}
611			case "content_block_stop":
612				var idx int
613				if b, ok := payload["index"]; ok {
614					_ = json.Unmarshal(b, &idx)
615				}
616				if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
617					out <- EventToolEnd{ID: be.toolCall.ID}
618				}
619				activeIdx = -1
620			case "message_start":
621				var m struct {
622					Message struct {
623						Usage struct {
624							InputTokens              int `json:"input_tokens"`
625							OutputTokens             int `json:"output_tokens"`
626							CacheReadInputTokens     int `json:"cache_read_input_tokens"`
627							CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
628						} `json:"usage"`
629					} `json:"message"`
630				}
631				_ = json.Unmarshal([]byte(ev.Data), &m)
632				usage.InputTokens += m.Message.Usage.InputTokens
633				usage.OutputTokens += m.Message.Usage.OutputTokens
634				usage.CacheReadTokens += m.Message.Usage.CacheReadInputTokens
635				usage.CacheWriteTokens += m.Message.Usage.CacheCreationInputTokens
636			case "message_delta":
637				var m struct {
638					Delta struct {
639						StopReason string `json:"stop_reason"`
640					} `json:"delta"`
641					Usage struct {
642						InputTokens              int `json:"input_tokens"`
643						OutputTokens             int `json:"output_tokens"`
644						CacheReadInputTokens     int `json:"cache_read_input_tokens"`
645						CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
646					} `json:"usage"`
647				}
648				_ = json.Unmarshal([]byte(ev.Data), &m)
649				usage.InputTokens += m.Usage.InputTokens
650				usage.OutputTokens += m.Usage.OutputTokens
651				usage.CacheReadTokens += m.Usage.CacheReadInputTokens
652				usage.CacheWriteTokens += m.Usage.CacheCreationInputTokens
653				switch m.Delta.StopReason {
654				case "end_turn", "stop_sequence":
655					stop = StopEnd
656				case "max_tokens":
657					stop = StopLength
658				case "tool_use":
659					stop = StopToolUse
660				}
661			case "message_stop":
662				sendDone()
663				return
664			case "error":
665				var e struct {
666					Error struct {
667						Type    string `json:"type"`
668						Message string `json:"message"`
669					} `json:"error"`
670				}
671				_ = json.Unmarshal([]byte(ev.Data), &e)
672				stop = StopError
673				finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message)
674				sendDone()
675				return
676			}
677			_ = activeIdx
678		}
679	}
680}

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