Merge pull request 'feat: add Anthropic Messages API support (#18)' (#21) from feat/18-anthropic-api into main
CI / test (push) Successful in 14s
CI / review (gpt-5, sonnet, SONNET_REVIEW_TOKEN) (push) Has been skipped
CI / review (gpt-5-mini, gpt, GPT_REVIEW_TOKEN) (push) Has been skipped

This commit was merged in pull request #21.
This commit is contained in:
2026-05-02 01:57:49 +00:00
4 changed files with 247 additions and 26 deletions
+5
View File
@@ -34,6 +34,10 @@ inputs:
llm-model: llm-model:
description: 'LLM model name' description: 'LLM model name'
required: true required: true
llm-provider:
description: 'LLM API provider: openai or anthropic (default openai)'
required: false
default: 'openai'
conventions-file: conventions-file:
description: 'Path to conventions file in the repo (e.g. CLAUDE.md)' description: 'Path to conventions file in the repo (e.g. CLAUDE.md)'
required: false required: false
@@ -140,6 +144,7 @@ runs:
PATTERNS_FILES: ${{ inputs.patterns-files }} PATTERNS_FILES: ${{ inputs.patterns-files }}
LLM_TEMPERATURE: ${{ inputs.temperature }} LLM_TEMPERATURE: ${{ inputs.temperature }}
LLM_TIMEOUT: ${{ inputs.timeout }} LLM_TIMEOUT: ${{ inputs.timeout }}
LLM_PROVIDER: ${{ inputs.llm-provider }}
run: | run: |
ARGS="" ARGS=""
if [ "${{ inputs.dry-run }}" = "true" ]; then if [ "${{ inputs.dry-run }}" = "true" ]; then
+7
View File
@@ -34,6 +34,7 @@ func main() {
dryRun := flag.Bool("dry-run", false, "Print review to stdout instead of posting") 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)") 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)") 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() flag.Parse()
@@ -74,6 +75,12 @@ func main() {
if *llmTemp > 0 { if *llmTemp > 0 {
llmClient.WithTemperature(*llmTemp) 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 { if *llmTimeout > 0 {
llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second) llmClient.WithTimeout(time.Duration(*llmTimeout) * time.Second)
} }
+148 -26
View File
@@ -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 package llm
import ( import (
@@ -12,24 +14,37 @@ import (
"time" "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. // 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 { type Client struct {
baseURL string baseURL string
apiKey string apiKey string
model string model string
temperature float64 temperature float64
provider Provider
http *http.Client 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 { func NewClient(baseURL, apiKey, model string) *Client {
return &Client{ return &Client{
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey, apiKey: apiKey,
model: model, model: model,
http: &http.Client{Timeout: 5 * time.Minute}, provider: ProviderOpenAI,
http: &http.Client{Timeout: 5 * time.Minute},
} }
} }
@@ -45,20 +60,39 @@ func (c *Client) WithTemperature(t float64) *Client {
return c 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. // Message represents a chat message.
type Message struct { type Message struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` 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 { type ChatRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []Message `json:"messages"` Messages []Message `json:"messages"`
Temperature float64 `json:"temperature,omitempty"` Temperature float64 `json:"temperature,omitempty"`
} }
// ChatResponse is the response from the API. // ChatResponse is the OpenAI response.
type ChatResponse struct { type ChatResponse struct {
Choices []struct { Choices []struct {
Message struct { Message struct {
@@ -67,8 +101,7 @@ type ChatResponse struct {
} `json:"choices"` } `json:"choices"`
} }
// Complete sends a chat completion request and returns the assistant's response content. func (c *Client) completeOpenAI(ctx context.Context, messages []Message) (string, error) {
func (c *Client) Complete(ctx context.Context, messages []Message) (string, error) {
reqBody := ChatRequest{ reqBody := ChatRequest{
Model: c.model, Model: c.model,
Temperature: c.temperature, Temperature: c.temperature,
@@ -81,37 +114,126 @@ func (c *Client) Complete(ctx context.Context, messages []Message) (string, erro
} }
url := c.baseURL + "/chat/completions" 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 { if err != nil {
return "", fmt.Errorf("create request: %w", err) return "", fmt.Errorf("create request: %w", err)
} }
req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json") 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) resp, err := c.http.Do(req)
if err != nil { if err != nil {
return "", fmt.Errorf("LLM request: %w", err) return "", fmt.Errorf("LLM request: %w", err)
} }
defer resp.Body.Close() 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) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", fmt.Errorf("read response: %w", err) return "", fmt.Errorf("read response: %w", err)
} }
var chatResp ChatResponse if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if err := json.Unmarshal(body, &chatResp); err != nil { return "", fmt.Errorf("LLM API error (status %d): %s", resp.StatusCode, string(body))
return "", fmt.Errorf("parse response: %w", err)
} }
if len(chatResp.Choices) == 0 { return parse(body)
return "", fmt.Errorf("no choices in LLM response")
}
return chatResp.Choices[0].Message.Content, nil
} }
+87
View File
@@ -208,3 +208,90 @@ func TestWithTimeout(t *testing.T) {
t.Error("expected timeout error with 50ms timeout and 200ms server delay") 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)
}
}