Claude Sonnet 5.5 는 응답에 thinking 블록을 포함한다(adaptive thinking, 비활성화 불가). 어댑터가 어시스턴트 턴을 자체 구조체로 재직렬화하면서 thinking 본문과 signature 를 떨어뜨려 두 번째 턴에서 400(thinking.thinking: Field required)이 났다(2026-10-15 C-G1 실측). 응답 content 를 json.RawMessage 로 보관해 그대로 재전송한다. 테스트에 thinking 블록 재전송 검증을 추가.
322 lines
11 KiB
Go
322 lines
11 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)
|
|
fed *Federation // nil = API key from keyFn; set = Workload Identity Federation (takes precedence)
|
|
|
|
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 != ""
|
|
}
|
|
|
|
// SetFederation switches the client to Workload Identity Federation (ignored when the ids are incomplete).
|
|
func (c *Client) SetFederation(f *Federation) {
|
|
if f.Configured() {
|
|
c.fed = f
|
|
}
|
|
}
|
|
|
|
// AuthMode is "federation", "api_key" or "none" (reported by /selfcheck).
|
|
func (c *Client) AuthMode() string {
|
|
if c.fed != nil {
|
|
return "federation"
|
|
}
|
|
if c.KeyPresent() {
|
|
return "api_key"
|
|
}
|
|
return "none"
|
|
}
|
|
|
|
// Federation returns the active federation config (nil when the API key path is used).
|
|
func (c *Client) Federation() *Federation { return c.fed }
|
|
|
|
// authorize sets the credential header: a federated bearer token, or the API key.
|
|
func (c *Client) authorize(ctx context.Context, req *http.Request) error {
|
|
if c.fed != nil {
|
|
tok, err := c.fed.AccessToken(ctx, c.BaseURL)
|
|
if err != nil {
|
|
return fmt.Errorf("anthropic federation: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+tok)
|
|
return nil
|
|
}
|
|
key, err := c.keyFn()
|
|
if err != nil {
|
|
return fmt.Errorf("anthropic key: %w", err)
|
|
}
|
|
if key == "" {
|
|
return fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
|
|
}
|
|
req.Header.Set("x-api-key", key)
|
|
return nil
|
|
}
|
|
|
|
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 json.RawMessage `json:"content"` // kept verbatim: thinking blocks (with their signature) must be replayed unchanged
|
|
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) {
|
|
if c.fed == nil {
|
|
if key, err := c.keyFn(); err != nil {
|
|
return nil, fmt.Errorf("anthropic key: %w", err)
|
|
} else if key == "" {
|
|
return nil, fmt.Errorf("anthropic api key is empty (secret selfheal-anthropic-api-key not registered yet) and federation is not configured")
|
|
}
|
|
}
|
|
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)
|
|
send := func() (*http.Response, error) {
|
|
hreq, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+"/v1/messages", bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := c.authorize(ctx, hreq); err != nil {
|
|
return nil, err
|
|
}
|
|
hreq.Header.Set("anthropic-version", apiVersion)
|
|
hreq.Header.Set("Content-Type", "application/json")
|
|
return c.HTTP.Do(hreq)
|
|
}
|
|
resp, err := send()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == 401 && c.fed != nil { // token revoked/expired early: exchange once more
|
|
resp.Body.Close()
|
|
c.fed.Invalidate()
|
|
if resp, err = send(); 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 blocks []block
|
|
_ = json.Unmarshal(r.Content, &blocks)
|
|
var text strings.Builder
|
|
for _, bl := range blocks {
|
|
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: the assistant content is replayed as the raw JSON the API returned,
|
|
// so tool_use ids and thinking blocks (adaptive thinking is always on for Claude 5.5; a thinking block replayed
|
|
// without its text/signature is rejected with "thinking.thinking: Field required") stay intact
|
|
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] + "..."
|
|
}
|