diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go new file mode 100644 index 0000000..720d101 --- /dev/null +++ b/internal/llm/anthropic.go @@ -0,0 +1,91 @@ +package llm + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type anthropicClient struct { + http *http.Client + base string + key string + model string + maxTokens int +} + +func (c *anthropicClient) Vision(ctx context.Context, prompt string, images []Image, opts Options) (string, error) { + content := []map[string]any{} + for _, img := range images { + content = append(content, map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "base64", + "media_type": img.MediaType, + "data": base64.StdEncoding.EncodeToString(img.Data), + }, + }) + } + content = append(content, map[string]any{"type": "text", "text": prompt}) + + model := c.model + if opts.Model != "" { + model = opts.Model + } + maxTok := c.maxTokens + if opts.MaxTokens > 0 { + maxTok = opts.MaxTokens + } + // NOTE: temperature/top_p are intentionally omitted — Opus 4.x rejects them (HTTP 400). + reqBody := map[string]any{ + "model": model, + "max_tokens": maxTok, + "messages": []map[string]any{{"role": "user", "content": content}}, + } + b, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/v1/messages", bytes.NewReader(b)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", c.key) + req.Header.Set("anthropic-version", "2023-06-01") + resp, err := c.http.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode >= 300 { + return "", fmt.Errorf("anthropic HTTP %d: %s", resp.StatusCode, string(raw)) + } + var out struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", err + } + for _, blk := range out.Content { + if blk.Type == "text" { + return blk.Text, nil + } + } + return "", fmt.Errorf("anthropic: no text block in response") +} + +func (c *anthropicClient) Transcribe(_ context.Context, _ []byte, _ string, _ Options) (string, error) { + return "", ErrUnsupported{Provider: "anthropic"} +} diff --git a/internal/llm/anthropic_test.go b/internal/llm/anthropic_test.go new file mode 100644 index 0000000..5667b48 --- /dev/null +++ b/internal/llm/anthropic_test.go @@ -0,0 +1,49 @@ +package llm + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestAnthropicVision(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + t.Errorf("path %s", r.URL.Path) + } + if r.Header.Get("x-api-key") != "key" || r.Header.Get("anthropic-version") != "2023-06-01" { + t.Errorf("headers %v", r.Header) + } + raw, _ := io.ReadAll(r.Body) + if strings.Contains(string(raw), "temperature") { + t.Errorf("temperature must NOT be sent to anthropic: %s", raw) + } + if !strings.Contains(string(raw), `"type":"base64"`) { + t.Errorf("image block missing: %s", raw) + } + _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"crosswalk"}]}`) + })) + defer srv.Close() + + c := &anthropicClient{http: &http.Client{Timeout: 5 * time.Second}, base: srv.URL, key: "key", model: "claude-opus-4-8", maxTokens: 100} + got, err := c.Vision(context.Background(), "which tiles", []Image{{MediaType: "image/png", Data: []byte{2}}}, Options{Temperature: 0.7}) + if err != nil || got != "crosswalk" { + t.Fatalf("got %q err %v", got, err) + } + _ = json.Marshal // keep import +} + +func TestAnthropicTranscribeUnsupported(t *testing.T) { + c := &anthropicClient{model: "claude-opus-4-8"} + _, err := c.Transcribe(context.Background(), []byte{1}, "a.mp3", Options{}) + var u ErrUnsupported + if !errors.As(err, &u) { + t.Fatalf("want ErrUnsupported, got %v", err) + } +}