feat: add Anthropic Messages API support (#18)
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
This commit is contained in:
+148
-26
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user