From 14a0c2a946d18197e9773b93a091229de432d9ba Mon Sep 17 00:00:00 2001 From: Rodin Date: Fri, 1 May 2026 18:49:17 -0700 Subject: [PATCH] feat: add Anthropic Messages API support (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --llm-provider flag (openai|anthropic) to switch between API formats. Anthropic implementation: - POST /messages endpoint - x-api-key + anthropic-version headers - System prompt as top-level field (not a message) - max_tokens: 8192 for response generation - Parses content blocks [{type: "text", text: "..."}] Changes: - llm/client.go: Provider type, completeAnthropic(), doRequest() shared helper - cmd/review-bot/main.go: --llm-provider / LLM_PROVIDER flag - .gitea/actions/review/action.yml: llm-provider input + env - llm/client_test.go: 4 new tests for Anthropic path Backwards compatible — default provider is still openai. Closes #18 --- .gitea/actions/review/action.yml | 5 + cmd/review-bot/main.go | 7 ++ llm/client.go | 174 ++++++++++++++++++++++++++----- llm/client_test.go | 87 ++++++++++++++++ 4 files changed, 247 insertions(+), 26 deletions(-) diff --git a/.gitea/actions/review/action.yml b/.gitea/actions/review/action.yml index ccc3167..f52307c 100644 --- a/.gitea/actions/review/action.yml +++ b/.gitea/actions/review/action.yml @@ -34,6 +34,10 @@ inputs: llm-model: description: 'LLM model name' required: true + llm-provider: + description: 'LLM API provider: openai or anthropic (default openai)' + required: false + default: 'openai' conventions-file: description: 'Path to conventions file in the repo (e.g. CLAUDE.md)' required: false @@ -140,6 +144,7 @@ runs: PATTERNS_FILES: ${{ inputs.patterns-files }} LLM_TEMPERATURE: ${{ inputs.temperature }} LLM_TIMEOUT: ${{ inputs.timeout }} + LLM_PROVIDER: ${{ inputs.llm-provider }} run: | ARGS="" if [ "${{ inputs.dry-run }}" = "true" ]; then diff --git a/cmd/review-bot/main.go b/cmd/review-bot/main.go index f31c756..bca9979 100644 --- a/cmd/review-bot/main.go +++ b/cmd/review-bot/main.go @@ -34,6 +34,7 @@ func main() { dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting") llmTemp := flag.Float64("llm-temperature", envOrDefaultFloat("LLM_TEMPERATURE", 0), "LLM temperature (0 = server default)") llmTimeout := flag.Int("llm-timeout", envOrDefaultInt("LLM_TIMEOUT", 300), "LLM request timeout in seconds (default 300)") + llmProvider := flag.String("llm-provider", envOrDefault("LLM_PROVIDER", "openai"), "LLM API provider: openai or anthropic") flag.Parse() @@ -74,6 +75,12 @@ func main() { if *llmTemp > 0 { llmClient.WithTemperature(*llmTemp) } + switch llm.Provider(*llmProvider) { + case llm.ProviderOpenAI, llm.ProviderAnthropic: + llmClient.WithProvider(llm.Provider(*llmProvider)) + default: + log.Fatalf("Invalid --llm-provider %q, must be openai or anthropic", *llmProvider) + } if *llmTimeout > 0 { llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second) } diff --git a/llm/client.go b/llm/client.go index 41c1154..7bbd8b3 100644 --- a/llm/client.go +++ b/llm/client.go @@ -1,4 +1,6 @@ -// Package llm provides a client for OpenAI-compatible chat completion APIs. +// Package llm provides clients for LLM chat completion APIs. +// +// Supports OpenAI-compatible (default) and Anthropic Messages API providers. package llm import ( @@ -12,24 +14,37 @@ import ( "time" ) -// Client calls an OpenAI-compatible chat completion API. +// Provider identifies which API format to use. +type Provider string + +const ( + // ProviderOpenAI uses the OpenAI-compatible chat/completions endpoint. + ProviderOpenAI Provider = "openai" + // ProviderAnthropic uses the Anthropic Messages API endpoint. + ProviderAnthropic Provider = "anthropic" +) + +// Client calls an LLM chat completion API. // A Client is safe for concurrent use by multiple goroutines after construction. -// WithTimeout and WithTemperature must be called during setup, before concurrent use. +// WithTimeout, WithTemperature, and WithProvider must be called during setup, +// before concurrent use. type Client struct { baseURL string apiKey string model string temperature float64 + provider Provider http *http.Client } -// NewClient creates a new LLM client. +// NewClient creates a new LLM client. Default provider is OpenAI-compatible. func NewClient(baseURL, apiKey, model string) *Client { return &Client{ - baseURL: strings.TrimRight(baseURL, "/"), - apiKey: apiKey, - model: model, - http: &http.Client{Timeout: 5 * time.Minute}, + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + model: model, + provider: ProviderOpenAI, + http: &http.Client{Timeout: 5 * time.Minute}, } } @@ -45,20 +60,39 @@ func (c *Client) WithTemperature(t float64) *Client { return c } +// WithProvider sets the API provider format (openai or anthropic). +func (c *Client) WithProvider(p Provider) *Client { + c.provider = p + return c +} + // Message represents a chat message. type Message struct { Role string `json:"role"` Content string `json:"content"` } -// ChatRequest is the request payload. +// Complete sends a chat completion request and returns the assistant's response content. +// The first message with role "system" is treated as the system prompt. +func (c *Client) Complete(ctx context.Context, messages []Message) (string, error) { + switch c.provider { + case ProviderAnthropic: + return c.completeAnthropic(ctx, messages) + default: + return c.completeOpenAI(ctx, messages) + } +} + +// --- OpenAI-compatible implementation --- + +// ChatRequest is the OpenAI request payload. type ChatRequest struct { Model string `json:"model"` Messages []Message `json:"messages"` Temperature float64 `json:"temperature,omitempty"` } -// ChatResponse is the response from the API. +// ChatResponse is the OpenAI response. type ChatResponse struct { Choices []struct { Message struct { @@ -67,8 +101,7 @@ type ChatResponse struct { } `json:"choices"` } -// Complete sends a chat completion request and returns the assistant's response content. -func (c *Client) Complete(ctx context.Context, messages []Message) (string, error) { +func (c *Client) completeOpenAI(ctx context.Context, messages []Message) (string, error) { reqBody := ChatRequest{ Model: c.model, Temperature: c.temperature, @@ -81,37 +114,126 @@ func (c *Client) Complete(ctx context.Context, messages []Message) (string, erro } url := c.baseURL + "/chat/completions" - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(data)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) if err != nil { return "", fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") + return c.doRequest(req, func(body []byte) (string, error) { + var resp ChatResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + if len(resp.Choices) == 0 { + return "", fmt.Errorf("no choices in LLM response") + } + return resp.Choices[0].Message.Content, nil + }) +} + +// --- Anthropic Messages API implementation --- + +type anthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + System string `json:"system,omitempty"` + Messages []anthropicMsg `json:"messages"` + Temperature float64 `json:"temperature,omitempty"` +} + +type anthropicMsg struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type anthropicResponse struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +} + +func (c *Client) completeAnthropic(ctx context.Context, messages []Message) (string, error) { + // Extract system message (first message with role "system") + var system string + var userMessages []anthropicMsg + for _, m := range messages { + if m.Role == "system" { + system = m.Content + } else { + userMessages = append(userMessages, anthropicMsg{ + Role: m.Role, + Content: m.Content, + }) + } + } + + reqBody := anthropicRequest{ + Model: c.model, + MaxTokens: 8192, + System: system, + Messages: userMessages, + } + if c.temperature > 0 { + reqBody.Temperature = c.temperature + } + + data, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal request: %w", err) + } + + url := c.baseURL + "/messages" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("x-api-key", c.apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("Content-Type", "application/json") + + return c.doRequest(req, func(body []byte) (string, error) { + var resp anthropicResponse + if err := json.Unmarshal(body, &resp); err != nil { + return "", fmt.Errorf("parse response: %w", err) + } + if len(resp.Content) == 0 { + return "", fmt.Errorf("no content in Anthropic response") + } + // Concatenate all text blocks + var sb strings.Builder + for _, block := range resp.Content { + if block.Type == "text" { + sb.WriteString(block.Text) + } + } + result := sb.String() + if result == "" { + return "", fmt.Errorf("no text content in Anthropic response") + } + return result, nil + }) +} + +// --- Shared HTTP execution --- + +func (c *Client) doRequest(req *http.Request, parse func([]byte) (string, error)) (string, error) { resp, err := c.http.Do(req) if err != nil { return "", fmt.Errorf("LLM request: %w", err) } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(body)) - } - body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("read response: %w", err) } - var chatResp ChatResponse - if err := json.Unmarshal(body, &chatResp); err != nil { - return "", fmt.Errorf("parse response: %w", err) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(body)) } - if len(chatResp.Choices) == 0 { - return "", fmt.Errorf("no choices in LLM response") - } - - return chatResp.Choices[0].Message.Content, nil + return parse(body) } diff --git a/llm/client_test.go b/llm/client_test.go index 01b5c8c..a6881ab 100644 --- a/llm/client_test.go +++ b/llm/client_test.go @@ -208,3 +208,90 @@ func TestWithTimeout(t *testing.T) { t.Error("expected timeout error with 50ms timeout and 200ms server delay") } } + + +func TestComplete_Anthropic_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/messages" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("x-api-key") != "test-key" { + t.Errorf("expected x-api-key header, got %q", r.Header.Get("x-api-key")) + } + if r.Header.Get("anthropic-version") != "2023-06-01" { + t.Errorf("expected anthropic-version header, got %q", r.Header.Get("anthropic-version")) + } + + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + if req["system"] != "You are helpful" { + t.Errorf("expected system prompt, got %v", req["system"]) + } + msgs := req["messages"].([]interface{}) + if len(msgs) != 1 { + t.Errorf("expected 1 user message, got %d", len(msgs)) + } + if req["max_tokens"] != float64(8192) { + t.Errorf("expected max_tokens 8192, got %v", req["max_tokens"]) + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"content":[{"type":"text","text":"Hello from Claude!"}]}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic) + got, err := client.Complete(context.Background(), []Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hi"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "Hello from Claude!" { + t.Errorf("expected %q, got %q", "Hello from Claude!", got) + } +} + +func TestComplete_Anthropic_NoContent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"content":[]}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic) + _, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}}) + if err == nil { + t.Fatal("expected error for empty content, got nil") + } +} + +func TestComplete_Anthropic_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":{"message":"invalid request"}}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "test-key", "claude-sonnet").WithProvider(ProviderAnthropic) + _, err := client.Complete(context.Background(), []Message{{Role: "user", Content: "Hi"}}) + if err == nil { + t.Fatal("expected error for 400, got nil") + } +} + +func TestWithProvider(t *testing.T) { + client := NewClient("http://example.com", "key", "model") + if client.provider != ProviderOpenAI { + t.Errorf("expected default provider openai, got %s", client.provider) + } + result := client.WithProvider(ProviderAnthropic) + if result != client { + t.Error("WithProvider should return the same client for chaining") + } + if client.provider != ProviderAnthropic { + t.Errorf("expected provider anthropic, got %s", client.provider) + } +}