Files
selfheal-lab-apps/ai/internal/anthropic/client.go
T
seon 53ff243183 feat: per-run model selection, usage in diagnosis results and a Claude Messages API provider
캠페인마다 진단 모델을 고를 수 있게 한다. 하네스는 runs.model 열을 추가해 POST /runs의
model을 저장하고, 활성 런의 model을 /diagnose 요청에 실어 보낸다. 에이전트는 요청의
model을 우선 사용하고(없으면 OPENAI_MODEL 기본값), 모델 id가 claude-로 시작하면
internal/anthropic(Claude Messages API 어댑터)로 라우팅한다. 어댑터는 Responses API
항목(message/function_call/function_call_output)을 system·tools(input_schema)·tool_use/
tool_result로 변환하고 previous_response_id 체인을 클라이언트 쪽에서 재현하며, Claude 5.5가
강제 tool_choice를 거부하므로 강제 호출은 auto + 지시문으로 바꾼다. 키는
ANTHROPIC_KEY_FILE(/run/secrets/anthropic-api-key)에서 읽고 비어 있으면 명확한 오류를 낸다.
Result에 usage(입력·출력·합계 토큰)를 추가해 하네스 diagnosis 이벤트에 비용 근거를 남긴다.
2026-10-14 23:21:46 +09:00

263 lines
8.7 KiB
Go

// Package anthropic adapts the Claude Messages API to the small Responses-API surface the agent loop uses
// (openai.Request / openai.Response with message, function_call and function_call_output items), so one loop
// serves both providers. The agent picks this client for model ids starting with "claude-".
//
// Wire format (Messages API reference, anthropic-version 2023-06-01): system prompt as the top-level `system`
// field, tools as {name, description, input_schema, strict}, tool calls as `tool_use` content blocks answered by
// `tool_result` blocks in the next user message. The Responses-API `previous_response_id` chain is emulated
// client-side: every response id maps to the full message list up to and including that assistant turn.
//
// Claude Opus 5.5 / Sonnet 5.5 reject forced tool use (tool_choice type "tool"/"any" -> 400), so a forced
// tool_choice from the loop becomes {type:"auto"} plus a one-line instruction in the user turn.
package anthropic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/selfheal-lab/selfheal-lab-apps/ai/internal/openai"
)
const (
apiVersion = "2023-06-01"
maxConvCached = 64 // response ids kept for previous_response_id chaining (one diagnosis uses <= MaxTurns)
defaultTokens = 4000
)
type Client struct {
BaseURL string
HTTP *http.Client
keyFn func() (string, error)
mu sync.Mutex
conv map[string]conversation // response id -> messages (and system prompt) up to and including that assistant turn
order []string // insertion order for eviction
seq int
}
type conversation struct {
system string
msgs []message
}
func New(baseURL string, keyFn func() (string, error)) *Client {
if baseURL == "" {
baseURL = "https://api.anthropic.com"
}
return &Client{BaseURL: strings.TrimRight(baseURL, "/"), HTTP: &http.Client{Timeout: 150 * time.Second}, keyFn: keyFn, conv: map[string]conversation{}}
}
// KeyPresent reports whether the key file has a non-empty value (the secret may not be registered yet).
func (c *Client) KeyPresent() bool {
k, err := c.keyFn()
return err == nil && k != ""
}
type message struct {
Role string `json:"role"`
Content any `json:"content"` // string or []block
}
type block struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"` // tool_use
Name string `json:"name,omitempty"` // tool_use
Input json.RawMessage `json:"input,omitempty"` // tool_use
ToolUseID string `json:"tool_use_id,omitempty"` // tool_result
Content string `json:"content,omitempty"` // tool_result
}
type tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema"`
Strict bool `json:"strict,omitempty"`
}
type request struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system,omitempty"`
Messages []message `json:"messages"`
Tools []tool `json:"tools,omitempty"`
ToolChoice map[string]any `json:"tool_choice,omitempty"`
}
type response struct {
ID string `json:"id"`
Type string `json:"type"`
Model string `json:"model"`
StopReason string `json:"stop_reason"`
Content []block `json:"content"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
CacheReadInputTokens int `json:"cache_read_input_tokens"`
} `json:"usage"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
// forcedNudge is appended to the user turn when the loop asked for a forced submit_diagnosis call.
const forcedNudge = "You must call the submit_diagnosis tool now with your final answer; do not call any other tool."
// CreateResponse translates one loop turn to a Messages API call and the answer back to Responses-API items.
func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*openai.Response, error) {
key, err := c.keyFn()
if err != nil {
return nil, fmt.Errorf("anthropic key: %w", err)
}
if key == "" {
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet)")
}
var msgs []message
var system []string
if req.PreviousResponseID != "" {
c.mu.Lock()
prev, ok := c.conv[req.PreviousResponseID]
c.mu.Unlock()
if !ok {
return nil, fmt.Errorf("anthropic: unknown previous_response_id %q", req.PreviousResponseID)
}
msgs = append(msgs, prev.msgs...)
if prev.system != "" {
system = append(system, prev.system) // the loop sends the system message only on the first turn
}
}
var userBlocks []block
flush := func() {
if len(userBlocks) == 0 {
return
}
msgs = append(msgs, message{Role: "user", Content: userBlocks})
userBlocks = nil
}
for _, it := range req.Input {
switch it.Type {
case "message":
text, _ := it.Content.(string)
switch it.Role {
case "system":
system = append(system, text)
case "assistant":
flush()
msgs = append(msgs, message{Role: "assistant", Content: []block{{Type: "text", Text: text}}})
default:
userBlocks = append(userBlocks, block{Type: "text", Text: text})
}
case "function_call_output":
userBlocks = append(userBlocks, block{Type: "tool_result", ToolUseID: it.CallID, Content: it.Output})
}
}
forced := false
if m, ok := req.ToolChoice.(map[string]any); ok && m["type"] == "function" {
forced = true
}
if forced {
userBlocks = append(userBlocks, block{Type: "text", Text: forcedNudge})
}
flush()
if len(msgs) == 0 {
return nil, fmt.Errorf("anthropic: no messages to send")
}
body := request{Model: req.Model, MaxTokens: req.MaxOutputTokens, System: strings.Join(system, "\n\n"), Messages: msgs}
if body.MaxTokens <= 0 {
body.MaxTokens = defaultTokens
}
for _, t := range req.Tools {
body.Tools = append(body.Tools, tool{Name: t.Name, Description: t.Description, InputSchema: t.Parameters, Strict: t.Strict})
}
if len(body.Tools) > 0 {
tc := map[string]any{"type": "auto"}
if req.ParallelToolCalls != nil && !*req.ParallelToolCalls {
tc["disable_parallel_tool_use"] = true
}
body.ToolChoice = tc
}
b, _ := json.Marshal(body)
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
if err != nil {
return nil, err
}
hreq.Header.Set("x-api-key", key)
hreq.Header.Set("anthropic-version", apiVersion)
hreq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(hreq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
var r response
_ = json.Unmarshal(rb, &r)
if resp.StatusCode/100 != 2 {
msg := truncate(string(rb), 600)
if r.Error != nil {
msg = r.Error.Type + ": " + r.Error.Message
}
return nil, fmt.Errorf("anthropic /v1/messages: status %d: %s", resp.StatusCode, msg)
}
if r.Error != nil {
return nil, fmt.Errorf("anthropic response error: %s", r.Error.Message)
}
out := &openai.Response{ID: r.ID, Status: "completed", Model: r.Model}
if out.ID == "" {
c.mu.Lock()
c.seq++
out.ID = fmt.Sprintf("msg_local_%d", c.seq)
c.mu.Unlock()
}
var text strings.Builder
for _, bl := range r.Content {
switch bl.Type {
case "text":
text.WriteString(bl.Text)
case "tool_use":
args := string(bl.Input)
if args == "" {
args = "{}"
}
out.Output = append(out.Output, openai.Item{Type: "function_call", CallID: bl.ID, Name: bl.Name, Arguments: args, ID: bl.ID, Status: "completed"})
}
}
if text.Len() > 0 {
out.Output = append([]openai.Item{{Type: "message", Role: "assistant", Content: []any{map[string]any{"type": "output_text", "text": text.String()}}}}, out.Output...)
}
// input tokens include cache writes/reads so the sum matches what is billed
out.Usage.InputTokens = r.Usage.InputTokens + r.Usage.CacheCreationInputTokens + r.Usage.CacheReadInputTokens
out.Usage.OutputTokens = r.Usage.OutputTokens
out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens
// remember the conversation for the next turn (assistant content kept verbatim so tool_use ids line up)
full := append(append([]message{}, msgs...), message{Role: "assistant", Content: r.Content})
c.mu.Lock()
c.conv[out.ID] = conversation{system: body.System, msgs: full}
c.order = append(c.order, out.ID)
for len(c.order) > maxConvCached {
delete(c.conv, c.order[0])
c.order = c.order[1:]
}
c.mu.Unlock()
return out, nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}