gitlink-cli/shortcuts/workflow/aiclient.go

125 lines
3.0 KiB
Go

package workflow
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/gitlink-org/gitlink-cli/internal/config"
)
const anthropicBaseURL = "https://api.anthropic.com/v1/messages"
const defaultModel = "claude-sonnet-4-6"
// AIClient wraps the Anthropic Messages API for skill step execution.
type AIClient struct {
apiKey string
model string
http *http.Client
}
// AIRequest bundles the data needed for an AI skill step call.
type AIRequest struct {
SystemPrompt string
UserData string
}
// AIResponse is the parsed structured output from an AI skill step.
type AIResponse struct {
Analysis interface{} `json:"analysis"`
Actions []AIAction `json:"actions"`
}
// NewAIClient resolves the API key (env → config) and returns a client, or nil if unavailable.
func NewAIClient() *AIClient {
key := os.Getenv("ANTHROPIC_API_KEY")
if key == "" {
cfg, err := config.Load()
if err == nil {
key = cfg.AnthropicAPIKey
}
}
if key == "" {
return nil
}
return &AIClient{
apiKey: key,
model: defaultModel,
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Analyze sends the skill prompt + upstream data to the Anthropic API and parses the response.
func (c *AIClient) Analyze(req *AIRequest) (*AIResponse, error) {
if c == nil {
return nil, fmt.Errorf("AI client not configured: set ANTHROPIC_API_KEY or configure anthropic_api_key")
}
body := map[string]interface{}{
"model": c.model,
"max_tokens": 4096,
"system": req.SystemPrompt,
"messages": []map[string]string{
{"role": "user", "content": req.UserData},
},
}
payload, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequest("POST", anthropicBaseURL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-api-key", c.apiKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("API call: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("Anthropic API returned %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
if len(result.Content) == 0 {
return nil, fmt.Errorf("empty response from Anthropic API")
}
text := result.Content[0].Text
var aiResp AIResponse
if err := json.Unmarshal([]byte(text), &aiResp); err != nil {
return nil, fmt.Errorf("parse AI JSON output: %w\nraw: %s", err, text)
}
return &aiResp, nil
}
// HasKey reports whether the AI client is configured.
func (c *AIClient) HasKey() bool {
return c != nil && c.apiKey != ""
}