blob: 0b65d480f0c8753f0e634964222e8b74887b2935 [file] [log] [blame]
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001package ant
2
3import (
4 "bytes"
5 "cmp"
6 "context"
7 "encoding/json"
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -07008 "errors"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07009 "fmt"
10 "io"
11 "log/slog"
12 "math/rand/v2"
13 "net/http"
Josh Bleecher Snyderf2b5ee02025-07-21 16:42:53 -070014 "os"
15 "path/filepath"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070016 "strings"
17 "testing"
18 "time"
19
20 "sketch.dev/llm"
21)
22
23const (
Josh Bleecher Snyder0efb29d2025-05-22 21:05:04 -070024 DefaultModel = Claude4Sonnet
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070025 // See https://docs.anthropic.com/en/docs/about-claude/models/all-models for
26 // current maximums. There's currently a flag to enable 128k output (output-128k-2025-02-19)
27 DefaultMaxTokens = 8192
28 DefaultURL = "https://api.anthropic.com/v1/messages"
29)
30
31const (
32 Claude35Sonnet = "claude-3-5-sonnet-20241022"
33 Claude35Haiku = "claude-3-5-haiku-20241022"
34 Claude37Sonnet = "claude-3-7-sonnet-20250219"
Josh Bleecher Snyder0e8073a2025-05-22 21:04:51 -070035 Claude4Sonnet = "claude-sonnet-4-20250514"
36 Claude4Opus = "claude-opus-4-20250514"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070037)
38
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070039// TokenContextWindow returns the maximum token context window size for this service
40func (s *Service) TokenContextWindow() int {
41 model := s.Model
42 if model == "" {
43 model = DefaultModel
44 }
45
46 switch model {
47 case Claude35Sonnet, Claude37Sonnet:
48 return 200000
49 case Claude35Haiku:
50 return 200000
51 case Claude4Sonnet, Claude4Opus:
52 return 200000
53 default:
54 // Default for unknown models
55 return 200000
56 }
57}
58
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070059// Service provides Claude completions.
60// Fields should not be altered concurrently with calling any method on Service.
61type Service struct {
Josh Bleecher Snydere75d0ea2025-07-21 23:50:44 +000062 HTTPC *http.Client // defaults to http.DefaultClient if nil
63 URL string // defaults to DefaultURL if empty
64 APIKey string // must be non-empty
65 Model string // defaults to DefaultModel if empty
66 MaxTokens int // defaults to DefaultMaxTokens if zero
67 DumpAntCalls bool // whether to dump request/response text to files for debugging; defaults to false
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070068}
69
70var _ llm.Service = (*Service)(nil)
71
72type content struct {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070073 // https://docs.anthropic.com/en/api/messages
74 ID string `json:"id,omitempty"`
75 Type string `json:"type,omitempty"`
Philip Zeyliger72252cb2025-05-10 17:00:08 -070076
77 // Subtly, an empty string appears in tool results often, so we have
78 // to distinguish between empty string and no string.
79 // Underlying error looks like one of:
80 // "messages.46.content.0.tool_result.content.0.text.text: Field required""
81 // "messages.1.content.1.tool_use.text: Extra inputs are not permitted"
82 //
83 // I haven't found a super great source for the API, but
84 // https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/resources/messages/messages.ts
85 // is somewhat acceptable but hard to read.
86 Text *string `json:"text,omitempty"`
87 MediaType string `json:"media_type,omitempty"` // for image
88 Source json.RawMessage `json:"source,omitempty"` // for image
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070089
90 // for thinking
91 Thinking string `json:"thinking,omitempty"`
Philip Zeyliger72252cb2025-05-10 17:00:08 -070092 Data string `json:"data,omitempty"` // for redacted_thinking or image
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070093 Signature string `json:"signature,omitempty"` // for thinking
94
95 // for tool_use
96 ToolName string `json:"name,omitempty"`
97 ToolInput json.RawMessage `json:"input,omitempty"`
98
99 // for tool_result
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700100 ToolUseID string `json:"tool_use_id,omitempty"`
101 ToolError bool `json:"is_error,omitempty"`
102 // note the recursive nature here; message looks like:
103 // {
104 // "role": "user",
105 // "content": [
106 // {
107 // "type": "tool_result",
108 // "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
109 // "content": [
110 // {"type": "text", "text": "15 degrees"},
111 // {
112 // "type": "image",
113 // "source": {
114 // "type": "base64",
115 // "media_type": "image/jpeg",
116 // "data": "/9j/4AAQSkZJRg...",
117 // }
118 // }
119 // ]
120 // }
121 // ]
122 //}
123 ToolResult []content `json:"content,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700124
125 // timing information for tool_result; not sent to Claude
126 StartTime *time.Time `json:"-"`
127 EndTime *time.Time `json:"-"`
128
129 CacheControl json.RawMessage `json:"cache_control,omitempty"`
130}
131
132// message represents a message in the conversation.
133type message struct {
134 Role string `json:"role"`
135 Content []content `json:"content"`
136 ToolUse *toolUse `json:"tool_use,omitempty"` // use to control whether/which tool to use
137}
138
139// toolUse represents a tool use in the message content.
140type toolUse struct {
141 ID string `json:"id"`
142 Name string `json:"name"`
143}
144
145// tool represents a tool available to Claude.
146type tool struct {
147 Name string `json:"name"`
148 // Type is used by the text editor tool; see
149 // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
150 Type string `json:"type,omitempty"`
151 Description string `json:"description,omitempty"`
152 InputSchema json.RawMessage `json:"input_schema,omitempty"`
153}
154
155// usage represents the billing and rate-limit usage.
156type usage struct {
157 InputTokens uint64 `json:"input_tokens"`
158 CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
159 CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
160 OutputTokens uint64 `json:"output_tokens"`
161 CostUSD float64 `json:"cost_usd"`
162}
163
164func (u *usage) Add(other usage) {
165 u.InputTokens += other.InputTokens
166 u.CacheCreationInputTokens += other.CacheCreationInputTokens
167 u.CacheReadInputTokens += other.CacheReadInputTokens
168 u.OutputTokens += other.OutputTokens
169 u.CostUSD += other.CostUSD
170}
171
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700172// response represents the response from the message API.
173type response struct {
174 ID string `json:"id"`
175 Type string `json:"type"`
176 Role string `json:"role"`
177 Model string `json:"model"`
178 Content []content `json:"content"`
179 StopReason string `json:"stop_reason"`
180 StopSequence *string `json:"stop_sequence,omitempty"`
181 Usage usage `json:"usage"`
182}
183
184type toolChoice struct {
185 Type string `json:"type"`
186 Name string `json:"name,omitempty"`
187}
188
189// https://docs.anthropic.com/en/api/messages#body-system
190type systemContent struct {
191 Text string `json:"text,omitempty"`
192 Type string `json:"type,omitempty"`
193 CacheControl json.RawMessage `json:"cache_control,omitempty"`
194}
195
196// request represents the request payload for creating a message.
197type request struct {
198 Model string `json:"model"`
199 Messages []message `json:"messages"`
200 ToolChoice *toolChoice `json:"tool_choice,omitempty"`
201 MaxTokens int `json:"max_tokens"`
202 Tools []*tool `json:"tools,omitempty"`
203 Stream bool `json:"stream,omitempty"`
204 System []systemContent `json:"system,omitempty"`
205 Temperature float64 `json:"temperature,omitempty"`
206 TopK int `json:"top_k,omitempty"`
207 TopP float64 `json:"top_p,omitempty"`
208 StopSequences []string `json:"stop_sequences,omitempty"`
209
210 TokenEfficientToolUse bool `json:"-"` // DO NOT USE, broken on Anthropic's side as of 2025-02-28
211}
212
Josh Bleecher Snyderf2b5ee02025-07-21 16:42:53 -0700213// dumpToFile writes the content to a timestamped file in ~/.cache/sketch/, with typ in the filename.
214func dumpToFile(typ string, content []byte) error {
Josh Bleecher Snyderf2b5ee02025-07-21 16:42:53 -0700215 homeDir, err := os.UserHomeDir()
216 if err != nil {
217 return err
218 }
219 cacheDir := filepath.Join(homeDir, ".cache", "sketch")
220 err = os.MkdirAll(cacheDir, 0o700)
221 if err != nil {
222 return err
223 }
224 now := time.Now()
225 filename := fmt.Sprintf("%d_%s.txt", now.UnixMilli(), typ)
226 filePath := filepath.Join(cacheDir, filename)
227 return os.WriteFile(filePath, content, 0o600)
228}
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700229
230func mapped[Slice ~[]E, E, T any](s Slice, f func(E) T) []T {
231 out := make([]T, len(s))
232 for i, v := range s {
233 out[i] = f(v)
234 }
235 return out
236}
237
238func inverted[K, V cmp.Ordered](m map[K]V) map[V]K {
239 inv := make(map[V]K)
240 for k, v := range m {
241 if _, ok := inv[v]; ok {
242 panic(fmt.Errorf("inverted map has multiple keys for value %v", v))
243 }
244 inv[v] = k
245 }
246 return inv
247}
248
249var (
250 fromLLMRole = map[llm.MessageRole]string{
251 llm.MessageRoleAssistant: "assistant",
252 llm.MessageRoleUser: "user",
253 }
254 toLLMRole = inverted(fromLLMRole)
255
256 fromLLMContentType = map[llm.ContentType]string{
257 llm.ContentTypeText: "text",
258 llm.ContentTypeThinking: "thinking",
259 llm.ContentTypeRedactedThinking: "redacted_thinking",
260 llm.ContentTypeToolUse: "tool_use",
261 llm.ContentTypeToolResult: "tool_result",
262 }
263 toLLMContentType = inverted(fromLLMContentType)
264
265 fromLLMToolChoiceType = map[llm.ToolChoiceType]string{
266 llm.ToolChoiceTypeAuto: "auto",
267 llm.ToolChoiceTypeAny: "any",
268 llm.ToolChoiceTypeNone: "none",
269 llm.ToolChoiceTypeTool: "tool",
270 }
271
272 toLLMStopReason = map[string]llm.StopReason{
273 "stop_sequence": llm.StopReasonStopSequence,
274 "max_tokens": llm.StopReasonMaxTokens,
275 "end_turn": llm.StopReasonEndTurn,
276 "tool_use": llm.StopReasonToolUse,
Josh Bleecher Snyder0e8073a2025-05-22 21:04:51 -0700277 "refusal": llm.StopReasonRefusal,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700278 }
279)
280
281func fromLLMCache(c bool) json.RawMessage {
282 if !c {
283 return nil
284 }
285 return json.RawMessage(`{"type":"ephemeral"}`)
286}
287
288func fromLLMContent(c llm.Content) content {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700289 var toolResult []content
290 if len(c.ToolResult) > 0 {
291 toolResult = make([]content, len(c.ToolResult))
292 for i, tr := range c.ToolResult {
293 // For image content inside a tool_result, we need to map it to "image" type
294 if tr.MediaType != "" && tr.MediaType == "image/jpeg" || tr.MediaType == "image/png" {
295 // Format as an image for Claude
296 toolResult[i] = content{
297 Type: "image",
298 Source: json.RawMessage(fmt.Sprintf(`{"type":"base64","media_type":"%s","data":"%s"}`,
299 tr.MediaType, tr.Data)),
300 }
301 } else {
302 toolResult[i] = fromLLMContent(tr)
303 }
304 }
305 }
306
307 d := content{
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700308 ID: c.ID,
309 Type: fromLLMContentType[c.Type],
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700310 MediaType: c.MediaType,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 Thinking: c.Thinking,
312 Data: c.Data,
313 Signature: c.Signature,
314 ToolName: c.ToolName,
315 ToolInput: c.ToolInput,
316 ToolUseID: c.ToolUseID,
317 ToolError: c.ToolError,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700318 ToolResult: toolResult,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700319 CacheControl: fromLLMCache(c.Cache),
320 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700321 // Anthropic API complains if Text is specified when it shouldn't be
322 // or not specified when it's the empty string.
323 if c.Type != llm.ContentTypeToolResult && c.Type != llm.ContentTypeToolUse {
324 d.Text = &c.Text
325 }
326 return d
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700327}
328
329func fromLLMToolUse(tu *llm.ToolUse) *toolUse {
330 if tu == nil {
331 return nil
332 }
333 return &toolUse{
334 ID: tu.ID,
335 Name: tu.Name,
336 }
337}
338
339func fromLLMMessage(msg llm.Message) message {
340 return message{
341 Role: fromLLMRole[msg.Role],
342 Content: mapped(msg.Content, fromLLMContent),
343 ToolUse: fromLLMToolUse(msg.ToolUse),
344 }
345}
346
347func fromLLMToolChoice(tc *llm.ToolChoice) *toolChoice {
348 if tc == nil {
349 return nil
350 }
351 return &toolChoice{
352 Type: fromLLMToolChoiceType[tc.Type],
353 Name: tc.Name,
354 }
355}
356
357func fromLLMTool(t *llm.Tool) *tool {
358 return &tool{
359 Name: t.Name,
360 Type: t.Type,
361 Description: t.Description,
362 InputSchema: t.InputSchema,
363 }
364}
365
366func fromLLMSystem(s llm.SystemContent) systemContent {
367 return systemContent{
368 Text: s.Text,
369 Type: s.Type,
370 CacheControl: fromLLMCache(s.Cache),
371 }
372}
373
374func (s *Service) fromLLMRequest(r *llm.Request) *request {
375 return &request{
376 Model: cmp.Or(s.Model, DefaultModel),
377 Messages: mapped(r.Messages, fromLLMMessage),
378 MaxTokens: cmp.Or(s.MaxTokens, DefaultMaxTokens),
379 ToolChoice: fromLLMToolChoice(r.ToolChoice),
380 Tools: mapped(r.Tools, fromLLMTool),
381 System: mapped(r.System, fromLLMSystem),
382 }
383}
384
385func toLLMUsage(u usage) llm.Usage {
386 return llm.Usage{
387 InputTokens: u.InputTokens,
388 CacheCreationInputTokens: u.CacheCreationInputTokens,
389 CacheReadInputTokens: u.CacheReadInputTokens,
390 OutputTokens: u.OutputTokens,
391 CostUSD: u.CostUSD,
392 }
393}
394
395func toLLMContent(c content) llm.Content {
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700396 // Convert toolResult from []content to []llm.Content
397 var toolResultContents []llm.Content
398 if len(c.ToolResult) > 0 {
399 toolResultContents = make([]llm.Content, len(c.ToolResult))
400 for i, tr := range c.ToolResult {
401 toolResultContents[i] = toLLMContent(tr)
402 }
403 }
404
405 ret := llm.Content{
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700406 ID: c.ID,
407 Type: toLLMContentType[c.Type],
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700408 MediaType: c.MediaType,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700409 Thinking: c.Thinking,
410 Data: c.Data,
411 Signature: c.Signature,
412 ToolName: c.ToolName,
413 ToolInput: c.ToolInput,
414 ToolUseID: c.ToolUseID,
415 ToolError: c.ToolError,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700416 ToolResult: toolResultContents,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700417 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700418 if c.Text != nil {
419 ret.Text = *c.Text
420 }
421 return ret
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700422}
423
424func toLLMResponse(r *response) *llm.Response {
425 return &llm.Response{
426 ID: r.ID,
427 Type: r.Type,
428 Role: toLLMRole[r.Role],
429 Model: r.Model,
430 Content: mapped(r.Content, toLLMContent),
431 StopReason: toLLMStopReason[r.StopReason],
432 StopSequence: r.StopSequence,
433 Usage: toLLMUsage(r.Usage),
434 }
435}
436
437// Do sends a request to Anthropic.
438func (s *Service) Do(ctx context.Context, ir *llm.Request) (*llm.Response, error) {
439 request := s.fromLLMRequest(ir)
440
441 var payload []byte
442 var err error
Josh Bleecher Snydere75d0ea2025-07-21 23:50:44 +0000443 if s.DumpAntCalls || testing.Testing() {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700444 payload, err = json.MarshalIndent(request, "", " ")
445 } else {
446 payload, err = json.Marshal(request)
447 payload = append(payload, '\n')
448 }
449 if err != nil {
450 return nil, err
451 }
452
453 if false {
454 fmt.Printf("claude request payload:\n%s\n", payload)
455 }
456
457 backoff := []time.Duration{15 * time.Second, 30 * time.Second, time.Minute}
458 largerMaxTokens := false
459 var partialUsage usage
460
461 url := cmp.Or(s.URL, DefaultURL)
462 httpc := cmp.Or(s.HTTPC, http.DefaultClient)
463
464 // retry loop
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700465 var errs error // accumulated errors across all attempts
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700466 for attempts := 0; ; attempts++ {
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700467 if attempts > 10 {
468 return nil, fmt.Errorf("anthropic request failed after %d attempts: %w", attempts, errs)
469 }
470 if attempts > 0 {
471 sleep := backoff[min(attempts, len(backoff)-1)] + time.Duration(rand.Int64N(int64(time.Second)))
472 slog.WarnContext(ctx, "anthropic request sleep before retry", "sleep", sleep, "attempts", attempts)
473 time.Sleep(sleep)
474 }
Josh Bleecher Snydere75d0ea2025-07-21 23:50:44 +0000475 if s.DumpAntCalls {
476 if err := dumpToFile("request", payload); err != nil {
477 slog.WarnContext(ctx, "failed to dump request to file", "error", err)
478 }
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700479 }
480 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(payload))
481 if err != nil {
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700482 return nil, errors.Join(errs, err)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700483 }
484
485 req.Header.Set("Content-Type", "application/json")
486 req.Header.Set("X-API-Key", s.APIKey)
487 req.Header.Set("Anthropic-Version", "2023-06-01")
488
489 var features []string
490 if request.TokenEfficientToolUse {
491 features = append(features, "token-efficient-tool-use-2025-02-19")
492 }
493 if largerMaxTokens {
494 features = append(features, "output-128k-2025-02-19")
495 request.MaxTokens = 128 * 1024
496 }
497 if len(features) > 0 {
498 req.Header.Set("anthropic-beta", strings.Join(features, ","))
499 }
500
501 resp, err := httpc.Do(req)
502 if err != nil {
Josh Bleecher Snyder3b5646f2025-05-23 16:47:53 +0000503 // Don't retry httprr cache misses
504 if strings.Contains(err.Error(), "cached HTTP response not found") {
505 return nil, err
506 }
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700507 errs = errors.Join(errs, err)
508 continue
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700509 }
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700510 buf, err := io.ReadAll(resp.Body)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700511 resp.Body.Close()
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700512 if err != nil {
513 errs = errors.Join(errs, err)
514 continue
515 }
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700516
517 switch {
518 case resp.StatusCode == http.StatusOK:
Josh Bleecher Snydere75d0ea2025-07-21 23:50:44 +0000519 if s.DumpAntCalls {
520 if err := dumpToFile("response", buf); err != nil {
521 slog.WarnContext(ctx, "failed to dump response to file", "error", err)
522 }
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700523 }
524 var response response
525 err = json.NewDecoder(bytes.NewReader(buf)).Decode(&response)
526 if err != nil {
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700527 return nil, errors.Join(errs, err)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700528 }
529 if response.StopReason == "max_tokens" && !largerMaxTokens {
Josh Bleecher Snyder29fea842025-05-06 01:51:09 +0000530 slog.InfoContext(ctx, "anthropic_retrying_with_larger_tokens", "message", "Retrying Anthropic API call with larger max tokens size")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700531 // Retry with more output tokens.
532 largerMaxTokens = true
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -0700533 response.Usage.CostUSD = llm.CostUSDFromResponse(resp.Header)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700534 partialUsage = response.Usage
535 continue
536 }
537
538 // Calculate and set the cost_usd field
539 if largerMaxTokens {
540 response.Usage.Add(partialUsage)
541 }
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -0700542 response.Usage.CostUSD = llm.CostUSDFromResponse(resp.Header)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700543
544 return toLLMResponse(&response), nil
545 case resp.StatusCode >= 500 && resp.StatusCode < 600:
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700546 // server error, retry
547 slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode)
548 errs = errors.Join(errs, fmt.Errorf("status %v: %s", resp.Status, buf))
549 continue
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700550 case resp.StatusCode == 429:
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700551 // rate limited, retry
552 slog.WarnContext(ctx, "anthropic_request_rate_limited", "response", string(buf))
553 errs = errors.Join(errs, fmt.Errorf("status %v: %s", resp.Status, buf))
554 continue
555 case resp.StatusCode >= 400 && resp.StatusCode < 500:
556 // some other 400, probably unrecoverable
557 slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode)
558 return nil, errors.Join(errs, fmt.Errorf("status %v: %s", resp.Status, buf))
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700559 default:
Josh Bleecher Snydera4500c92025-05-15 15:38:32 -0700560 // ...retry, I guess?
561 slog.WarnContext(ctx, "anthropic_request_failed", "response", string(buf), "status_code", resp.StatusCode)
562 errs = errors.Join(errs, fmt.Errorf("status %v: %s", resp.Status, buf))
563 continue
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700564 }
565 }
566}