v0.0.10 0a69274

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}
108
109type anthToolUseBlock struct {
110	Type  string          `json:"type"` // "tool_use"
111	ID    string          `json:"id"`
112	Name  string          `json:"name"`
113	Input json.RawMessage `json:"input"`
114}
115
116type anthToolResultBlock struct {
117	Type      string          `json:"type"` // "tool_result"
118	ToolUseID string          `json:"tool_use_id"`
119	Content   json.RawMessage `json:"content"` // string or array of blocks
120	IsError   bool            `json:"is_error,omitempty"`
121}
122
123type anthCacheCtrl struct {
124	Type string `json:"type"` // "ephemeral"
125	TTL  string `json:"ttl,omitempty"`
126}
127
128type anthMessage struct {
129	Role    string        `json:"role"`
130	Content []interface{} `json:"content"`
131}
132
133type anthSystemBlock struct {
134	Type         string         `json:"type"` // "text"
135	Text         string         `json:"text"`
136	CacheControl *anthCacheCtrl `json:"cache_control,omitempty"`
137}
138
139type anthTool struct {
140	Name         string          `json:"name"`
141	Description  string          `json:"description,omitempty"`
142	InputSchema  json.RawMessage `json:"input_schema"`
143	CacheControl *anthCacheCtrl  `json:"cache_control,omitempty"`
144}
145
146type anthThinking struct {
147	Type         string `json:"type"` // "enabled"
148	BudgetTokens int    `json:"budget_tokens"`
149}
150
151type anthRequest struct {
152	Model       string            `json:"model"`
153	MaxTokens   int               `json:"max_tokens"`
154	System      []anthSystemBlock `json:"system,omitempty"`
155	Messages    []anthMessage     `json:"messages"`
156	Tools       []anthTool        `json:"tools,omitempty"`
157	Temperature *float32          `json:"temperature,omitempty"`
158	Thinking    *anthThinking     `json:"thinking,omitempty"`
159	Stream      bool              `json:"stream"`
160}
161
162// ---- request building ----
163
164func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
165	m, err := FindModel("anthropic", req.Model)
166	if err != nil {
167		return nil, err
168	}
169	maxTok := req.MaxTokens
170	if maxTok <= 0 {
171		maxTok = m.MaxOutput
172	}
173
174	out := &anthRequest{
175		Model:       req.Model,
176		MaxTokens:   maxTok,
177		Temperature: req.Temperature,
178		Stream:      true,
179	}
180
181	// System prompt assembly differs between api-key and OAuth modes.
182	// OAuth requests MUST begin with the Claude Code identity line or
183	// Anthropic rejects them (429 rate_limit_error with zero tokens used).
184	if c.oauthTok != "" {
185		out.System = []anthSystemBlock{{
186			Type:         "text",
187			Text:         claudeCodeIdentity,
188			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
189		}}
190		if req.System != "" {
191			out.System = append(out.System, anthSystemBlock{
192				Type:         "text",
193				Text:         req.System,
194				CacheControl: &anthCacheCtrl{Type: "ephemeral"},
195			})
196		}
197	} else if req.System != "" {
198		out.System = []anthSystemBlock{{
199			Type:         "text",
200			Text:         req.System,
201			CacheControl: &anthCacheCtrl{Type: "ephemeral"},
202		}}
203	}
204
205	if req.Reasoning != "" && m.Reasoning {
206		budget := anthropicReasoningBudget(req.Reasoning)
207		if budget > 0 {
208			out.Thinking = &anthThinking{Type: "enabled", BudgetTokens: budget}
209			// Reasoning requires max_tokens > budget.
210			if out.MaxTokens <= budget {
211				out.MaxTokens = budget + 4096
212			}
213		}
214	}
215
216	for _, t := range req.Tools {
217		name := t.Name
218		if c.oauthTok != "" {
219			name = toClaudeCodeToolName(name)
220		}
221		out.Tools = append(out.Tools, anthTool{
222			Name:        name,
223			Description: t.Description,
224			InputSchema: t.Schema,
225		})
226	}
227	// Cache the last tool definition (applies cache breakpoint to the whole tools array).
228	if n := len(out.Tools); n > 0 {
229		out.Tools[n-1].CacheControl = &anthCacheCtrl{Type: "ephemeral"}
230	}
231
232	// Group messages: consecutive user/tool roles into one "user" message.
233	// Anthropic only has roles "user" and "assistant"; tool_result blocks live in user messages.
234	for _, msg := range req.Messages {
235		renameTools := c.oauthTok != ""
236		switch msg.Role {
237		case RoleUser:
238			out.Messages = append(out.Messages, anthMessage{
239				Role:    "user",
240				Content: convertAnthContent(msg.Content, renameTools),
241			})
242		case RoleTool:
243			// Attach tool_result blocks to a user message; merge with prior user msg if last.
244			blocks := convertAnthContent(msg.Content, renameTools)
245			if n := len(out.Messages); n > 0 && out.Messages[n-1].Role == "user" {
246				out.Messages[n-1].Content = append(out.Messages[n-1].Content, blocks...)
247			} else {
248				out.Messages = append(out.Messages, anthMessage{Role: "user", Content: blocks})
249			}
250		case RoleAssistant:
251			out.Messages = append(out.Messages, anthMessage{
252				Role:    "assistant",
253				Content: convertAnthContent(msg.Content, renameTools),
254			})
255		}
256	}
257
258	return out, nil
259}
260
261func anthropicReasoningBudget(level string) int {
262	switch strings.ToLower(level) {
263	case "low":
264		return 2048
265	case "medium":
266		return 8192
267	case "high":
268		return 16384
269	default:
270		return 0
271	}
272}
273
274func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
275	out := make([]interface{}, 0, len(blocks))
276	for _, b := range blocks {
277		switch v := b.(type) {
278		case TextBlock:
279			if v.Text == "" {
280				continue
281			}
282			out = append(out, anthTextBlock{Type: "text", Text: v.Text})
283		case ImageBlock:
284			out = append(out, anthImageBlock{
285				Type: "image",
286				Source: anthImageSource{
287					Type:      "base64",
288					MediaType: v.MimeType,
289					Data:      base64.StdEncoding.EncodeToString(v.Data),
290				},
291			})
292		case ToolCallBlock:
293			args := v.Arguments
294			if len(args) == 0 {
295				args = json.RawMessage("{}")
296			}
297			name := v.Name
298			if renameTools {
299				name = toClaudeCodeToolName(name)
300			}
301			out = append(out, anthToolUseBlock{
302				Type: "tool_use", ID: v.ID, Name: name, Input: args,
303			})
304		case ToolResultBlock:
305			// Flatten content to a string if all text; else array of blocks.
306			content, _ := anthBuildToolResultContent(v.Content)
307			out = append(out, anthToolResultBlock{
308				Type:      "tool_result",
309				ToolUseID: v.CallID,
310				Content:   content,
311				IsError:   v.IsError,
312			})
313		}
314	}
315	return out
316}
317
318func anthBuildToolResultContent(blocks []Content) (json.RawMessage, error) {
319	onlyText := true
320	var sb strings.Builder
321	for _, b := range blocks {
322		if tb, ok := b.(TextBlock); ok {
323			if sb.Len() > 0 {
324				sb.WriteString("\n")
325			}
326			sb.WriteString(tb.Text)
327		} else {
328			onlyText = false
329			break
330		}
331	}
332	if onlyText {
333		if sb.Len() == 0 {
334			return json.Marshal("")
335		}
336		return json.Marshal(sb.String())
337	}
338	// Array form: text + image blocks.
339	arr := make([]interface{}, 0, len(blocks))
340	for _, b := range blocks {
341		switch v := b.(type) {
342		case TextBlock:
343			arr = append(arr, anthTextBlock{Type: "text", Text: v.Text})
344		case ImageBlock:
345			arr = append(arr, anthImageBlock{
346				Type: "image",
347				Source: anthImageSource{
348					Type:      "base64",
349					MediaType: v.MimeType,
350					Data:      base64.StdEncoding.EncodeToString(v.Data),
351				},
352			})
353		}
354	}
355	return json.Marshal(arr)
356}
357
358// ---- streaming ----
359
360func (c *anthropicClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
361	wire, err := c.buildRequest(req)
362	if err != nil {
363		return nil, err
364	}
365	body, err := json.Marshal(wire)
366	if err != nil {
367		return nil, err
368	}
369
370	httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/messages", bytes.NewReader(body))
371	if err != nil {
372		return nil, err
373	}
374	httpReq.Header.Set("content-type", "application/json")
375	httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
376	if c.oauthTok != "" {
377		// Claude-Code-shaped request: identical headers and values as the
378		// official CLI. Any drift triggers Anthropic's anti-abuse check and
379		// rate-limits (or outright blocks) the request.
380		httpReq.Header.Set("accept", "application/json")
381		httpReq.Header.Set("authorization", "Bearer "+c.oauthTok)
382		httpReq.Header.Set("anthropic-beta", "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14")
383		httpReq.Header.Set("anthropic-dangerous-direct-browser-access", "true")
384		httpReq.Header.Set("user-agent", "claude-cli/"+claudeCodeVersion+" (external, cli)")
385		httpReq.Header.Set("x-app", "cli")
386		// Remove x-api-key entirely by NOT setting it.
387	} else {
388		httpReq.Header.Set("accept", "text/event-stream")
389		httpReq.Header.Set("x-api-key", c.apiKey)
390	}
391
392	resp, err := c.http.Do(httpReq)
393	if err != nil {
394		return nil, fmt.Errorf("anthropic: %w", err)
395	}
396	if resp.StatusCode != http.StatusOK {
397		b, _ := io.ReadAll(resp.Body)
398		resp.Body.Close()
399		return nil, fmt.Errorf("anthropic: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
400	}
401
402	out := make(chan Event, 16)
403	go c.runStream(ctx, resp, req, out)
404	return out, nil
405}
406
407func (c *anthropicClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
408	defer close(out)
409	defer resp.Body.Close()
410
411	model, _ := FindModel("anthropic", req.Model)
412	out <- EventStart{Model: req.Model, Provider: "anthropic"}
413
414	raw := make(chan sseEvent, 16)
415	go readSSE(resp.Body, raw)
416
417	// State for assembling the assistant message. Blocks are indexed
418	// by their `index` field from Anthropic so we can preserve the
419	// interleaved order the model emitted them in (text may come
420	// before OR after tool_use; mixing both happens frequently).
421	type blockEntry struct {
422		kind     string // "text" | "tool_use"
423		textBuf  strings.Builder
424		toolCall ToolCallBlock
425		toolArgs strings.Builder
426	}
427	var (
428		blocks     = map[int]*blockEntry{}
429		blockOrder []int // insertion order of indexes
430		activeIdx  = -1
431		usage      Usage
432		stop       StopReason = StopEnd
433		finalErr   error
434	)
435	_ = activeIdx // read-only indicator used for legacy parity
436
437	registerBlock := func(idx int, kind string) *blockEntry {
438		if be, ok := blocks[idx]; ok {
439			return be
440		}
441		be := &blockEntry{kind: kind}
442		blocks[idx] = be
443		blockOrder = append(blockOrder, idx)
444		return be
445	}
446
447	assembleMsg := func() Message {
448		content := []Content{}
449		for _, idx := range blockOrder {
450			be := blocks[idx]
451			switch be.kind {
452			case "text":
453				if be.textBuf.Len() > 0 {
454					content = append(content, TextBlock{Text: be.textBuf.String()})
455				}
456			case "tool_use":
457				tc := be.toolCall
458				args := be.toolArgs.String()
459				if args == "" {
460					args = "{}"
461				}
462				tc.Arguments = json.RawMessage(args)
463				content = append(content, tc)
464			}
465		}
466		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
467	}
468
469	sendDone := func() {
470		usage.CostUSD = ComputeCost(model, usage)
471		out <- EventUsage{Usage: usage}
472		out <- EventDone{Stop: stop, Err: finalErr, Message: assembleMsg()}
473	}
474
475	for {
476		select {
477		case <-ctx.Done():
478			stop = StopAborted
479			finalErr = ctx.Err()
480			sendDone()
481			return
482		case ev, ok := <-raw:
483			if !ok {
484				sendDone()
485				return
486			}
487			// Parse event payload based on event: type.
488			var payload map[string]json.RawMessage
489			if err := json.Unmarshal([]byte(ev.Data), &payload); err != nil {
490				continue
491			}
492			switch ev.Event {
493			case "content_block_start":
494				var idx int
495				if b, ok := payload["index"]; ok {
496					_ = json.Unmarshal(b, &idx)
497				}
498				var block struct {
499					Type  string          `json:"type"`
500					ID    string          `json:"id"`
501					Name  string          `json:"name"`
502					Text  string          `json:"text"`
503					Input json.RawMessage `json:"input"`
504				}
505				if b, ok := payload["content_block"]; ok {
506					_ = json.Unmarshal(b, &block)
507				}
508				activeIdx = idx
509				switch block.Type {
510				case "tool_use":
511					name := block.Name
512					if c.oauthTok != "" {
513						name = fromClaudeCodeToolName(name, req.Tools)
514					}
515					be := registerBlock(idx, "tool_use")
516					be.toolCall = ToolCallBlock{ID: block.ID, Name: name}
517					out <- EventToolStart{ID: block.ID, Name: name}
518				case "text":
519					registerBlock(idx, "text")
520				case "thinking":
521					// not surfaced
522				}
523			case "content_block_delta":
524				var idx int
525				if b, ok := payload["index"]; ok {
526					_ = json.Unmarshal(b, &idx)
527				}
528				var d struct {
529					Type        string `json:"type"`
530					Text        string `json:"text"`
531					PartialJSON string `json:"partial_json"`
532					Thinking    string `json:"thinking"`
533				}
534				if b, ok := payload["delta"]; ok {
535					_ = json.Unmarshal(b, &d)
536				}
537				switch d.Type {
538				case "text_delta":
539					if be, ok := blocks[idx]; ok && be.kind == "text" {
540						be.textBuf.WriteString(d.Text)
541					}
542					out <- EventTextDelta{Delta: d.Text}
543				case "input_json_delta":
544					if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
545						be.toolArgs.WriteString(d.PartialJSON)
546						out <- EventToolArgs{ID: be.toolCall.ID, Delta: d.PartialJSON}
547					}
548				case "thinking_delta":
549					// Not surfaced in v1.
550				}
551			case "content_block_stop":
552				var idx int
553				if b, ok := payload["index"]; ok {
554					_ = json.Unmarshal(b, &idx)
555				}
556				if be, ok := blocks[idx]; ok && be.kind == "tool_use" {
557					out <- EventToolEnd{ID: be.toolCall.ID}
558				}
559				activeIdx = -1
560			case "message_start":
561				var m struct {
562					Message struct {
563						Usage struct {
564							InputTokens              int `json:"input_tokens"`
565							OutputTokens             int `json:"output_tokens"`
566							CacheReadInputTokens     int `json:"cache_read_input_tokens"`
567							CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
568						} `json:"usage"`
569					} `json:"message"`
570				}
571				_ = json.Unmarshal([]byte(ev.Data), &m)
572				usage.InputTokens += m.Message.Usage.InputTokens
573				usage.OutputTokens += m.Message.Usage.OutputTokens
574				usage.CacheReadTokens += m.Message.Usage.CacheReadInputTokens
575				usage.CacheWriteTokens += m.Message.Usage.CacheCreationInputTokens
576			case "message_delta":
577				var m struct {
578					Delta struct {
579						StopReason string `json:"stop_reason"`
580					} `json:"delta"`
581					Usage struct {
582						InputTokens              int `json:"input_tokens"`
583						OutputTokens             int `json:"output_tokens"`
584						CacheReadInputTokens     int `json:"cache_read_input_tokens"`
585						CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
586					} `json:"usage"`
587				}
588				_ = json.Unmarshal([]byte(ev.Data), &m)
589				usage.InputTokens += m.Usage.InputTokens
590				usage.OutputTokens += m.Usage.OutputTokens
591				usage.CacheReadTokens += m.Usage.CacheReadInputTokens
592				usage.CacheWriteTokens += m.Usage.CacheCreationInputTokens
593				switch m.Delta.StopReason {
594				case "end_turn", "stop_sequence":
595					stop = StopEnd
596				case "max_tokens":
597					stop = StopLength
598				case "tool_use":
599					stop = StopToolUse
600				}
601			case "message_stop":
602				sendDone()
603				return
604			case "error":
605				var e struct {
606					Error struct {
607						Type    string `json:"type"`
608						Message string `json:"message"`
609					} `json:"error"`
610				}
611				_ = json.Unmarshal([]byte(ev.Data), &e)
612				stop = StopError
613				finalErr = fmt.Errorf("anthropic %s: %s", e.Error.Type, e.Error.Message)
614				sendDone()
615				return
616			}
617			_ = activeIdx
618		}
619	}
620}

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