Skip to content

Commit 1b37b25

Browse files
committed
feat(models): add Anthropic Messages API provider (closes #69)
- Implement internal/models/anthropic client with httptest coverage - Wire type: anthropic in registry via apiKeyFrom (env:VAR) - Adapt engine ChatMessage (system+user) to Anthropic system + messages - Document YAML fragment and structured-output behavior in EXAMPLES/README Made-with: Cursor
1 parent f89446d commit 1b37b25

7 files changed

Lines changed: 413 additions & 1 deletion

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,13 @@ spec:
122122
openai:
123123
type: openai
124124
apiKeyFrom: env:OPENAI_API_KEY
125+
# Optional: Claude via Messages API (set ANTHROPIC_API_KEY and use e.g. defaults.model: anthropic/claude-sonnet-4-20250514)
126+
# anthropic:
127+
# type: anthropic
128+
# apiKeyFrom: env:ANTHROPIC_API_KEY
125129
```
126130

127-
Field-by-field rules, extra kinds, and env overlays are in [`docs/DESIGN_DOC.md`](docs/DESIGN_DOC.md).
131+
Field-by-field rules, extra kinds, and env overlays are in [`docs/DESIGN_DOC.md`](docs/DESIGN_DOC.md). See [`docs/EXAMPLES.md`](docs/EXAMPLES.md) for an Anthropic **`project.yaml`** fragment and structured-output notes.
128132

129133
Notes:
130134

docs/EXAMPLES.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ spec:
152152
apiKeyFrom: env:OPENAI_API_KEY
153153
```
154154

155+
**Anthropic (Claude)** — register a second namespace and point agents at **`anthropic/<model id>`** (same pattern as OpenAI). Keys use **`env:ANTHROPIC_API_KEY`**; the runtime calls Anthropic’s [**Messages API**](https://docs.anthropic.com/en/api/messages) (`POST /v1/messages`).
156+
157+
```yaml
158+
providers:
159+
models:
160+
anthropic:
161+
type: anthropic
162+
apiKeyFrom: env:ANTHROPIC_API_KEY
163+
defaults:
164+
model: anthropic/claude-sonnet-4-20250514
165+
```
166+
167+
**Structured JSON output:** there is no MVP `response_format: json_object` equivalent in this adapter—agents rely on **instructions** (same as in **`agents/support_writer.yaml`**: one JSON object, no markdown fences). If you set **`spec.output.schema`**, the engine still validates the assistant text as JSON after generation.
168+
155169
### `agents/support_writer.yaml`
156170

157171
`metadata.name` is the value you use in **`agent:`** on the workflow step.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Package anthropic implements the Anthropic Messages API client (design doc §7.1, issue #69).
2+
package anthropic
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"strings"
12+
"time"
13+
)
14+
15+
const (
16+
defaultBaseURL = "https://api.anthropic.com"
17+
apiVersion = "2023-06-01"
18+
defaultMaxTok = 4096
19+
)
20+
21+
// ChatMessage is one user or assistant turn for the Messages API (roles user|assistant only).
22+
type ChatMessage struct {
23+
Role string
24+
Content string
25+
}
26+
27+
// Client calls POST /v1/messages.
28+
type Client struct {
29+
APIKey string
30+
BaseURL string
31+
HTTPClient *http.Client
32+
}
33+
34+
func (c *Client) base() string {
35+
if c != nil && strings.TrimSpace(c.BaseURL) != "" {
36+
return strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
37+
}
38+
return defaultBaseURL
39+
}
40+
41+
func (c *Client) http() *http.Client {
42+
if c != nil && c.HTTPClient != nil {
43+
return c.HTTPClient
44+
}
45+
return http.DefaultClient
46+
}
47+
48+
// Generate performs one non-streaming Messages request. system may be empty.
49+
// Returns assistant text (concatenated text blocks), token usage when present, and duration in ms.
50+
func (c *Client) Generate(ctx context.Context, model, system string, messages []ChatMessage) (text string, inputTokens, outputTokens int, durationMs int64, err error) {
51+
if c == nil || strings.TrimSpace(c.APIKey) == "" {
52+
return "", 0, 0, 0, fmt.Errorf("anthropic: client not configured")
53+
}
54+
if strings.TrimSpace(model) == "" {
55+
return "", 0, 0, 0, fmt.Errorf("anthropic: empty model")
56+
}
57+
start := time.Now()
58+
59+
type msg struct {
60+
Role string `json:"role"`
61+
Content string `json:"content"`
62+
}
63+
payload := struct {
64+
Model string `json:"model"`
65+
MaxTokens int `json:"max_tokens"`
66+
System string `json:"system,omitempty"`
67+
Messages []msg `json:"messages"`
68+
}{
69+
Model: model,
70+
MaxTokens: defaultMaxTok,
71+
System: strings.TrimSpace(system),
72+
}
73+
for _, m := range messages {
74+
role := strings.ToLower(strings.TrimSpace(m.Role))
75+
if role != "user" && role != "assistant" {
76+
return "", 0, 0, 0, fmt.Errorf("anthropic: message role %q is not user or assistant", m.Role)
77+
}
78+
payload.Messages = append(payload.Messages, msg{Role: role, Content: m.Content})
79+
}
80+
if len(payload.Messages) == 0 {
81+
return "", 0, 0, 0, fmt.Errorf("anthropic: no messages")
82+
}
83+
84+
body, err := json.Marshal(payload)
85+
if err != nil {
86+
return "", 0, 0, 0, err
87+
}
88+
url := c.base() + "/v1/messages"
89+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
90+
if err != nil {
91+
return "", 0, 0, 0, err
92+
}
93+
httpReq.Header.Set("Content-Type", "application/json")
94+
httpReq.Header.Set("x-api-key", c.APIKey)
95+
httpReq.Header.Set("anthropic-version", apiVersion)
96+
97+
resp, err := c.http().Do(httpReq)
98+
if err != nil {
99+
return "", 0, 0, 0, err
100+
}
101+
defer resp.Body.Close()
102+
b, err := io.ReadAll(resp.Body)
103+
if err != nil {
104+
return "", 0, 0, 0, err
105+
}
106+
durationMs = time.Since(start).Milliseconds()
107+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
108+
return "", 0, 0, durationMs, fmt.Errorf("anthropic: HTTP %d: %s", resp.StatusCode, truncateErrBody(b))
109+
}
110+
111+
var out struct {
112+
Content []struct {
113+
Type string `json:"type"`
114+
Text string `json:"text"`
115+
} `json:"content"`
116+
Usage *struct {
117+
InputTokens int `json:"input_tokens"`
118+
OutputTokens int `json:"output_tokens"`
119+
} `json:"usage"`
120+
}
121+
if err := json.Unmarshal(b, &out); err != nil {
122+
return "", 0, 0, durationMs, fmt.Errorf("anthropic: decode response: %w", err)
123+
}
124+
var parts []string
125+
for _, block := range out.Content {
126+
if block.Type == "text" && block.Text != "" {
127+
parts = append(parts, block.Text)
128+
}
129+
}
130+
text = strings.Join(parts, "")
131+
if text == "" {
132+
return "", 0, 0, durationMs, fmt.Errorf("anthropic: no text content in response")
133+
}
134+
if out.Usage != nil {
135+
inputTokens = out.Usage.InputTokens
136+
outputTokens = out.Usage.OutputTokens
137+
}
138+
return text, inputTokens, outputTokens, durationMs, nil
139+
}
140+
141+
func truncateErrBody(b []byte) string {
142+
const n = 500
143+
s := string(b)
144+
if len(s) <= n {
145+
return s
146+
}
147+
return s[:n] + "..."
148+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package anthropic
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
)
12+
13+
func TestClient_Generate_messagesAPI(t *testing.T) {
14+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
if r.URL.Path != "/v1/messages" {
16+
t.Errorf("path %s", r.URL.Path)
17+
http.NotFound(w, r)
18+
return
19+
}
20+
if r.Header.Get("x-api-key") != "sk-ant-test" {
21+
t.Errorf("x-api-key %q", r.Header.Get("x-api-key"))
22+
}
23+
if r.Header.Get("anthropic-version") != apiVersion {
24+
t.Errorf("anthropic-version %q", r.Header.Get("anthropic-version"))
25+
}
26+
b, err := io.ReadAll(r.Body)
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
var req struct {
31+
Model string `json:"model"`
32+
System string `json:"system"`
33+
Messages []struct {
34+
Role string `json:"role"`
35+
Content string `json:"content"`
36+
} `json:"messages"`
37+
MaxTokens int `json:"max_tokens"`
38+
}
39+
if err := json.Unmarshal(b, &req); err != nil {
40+
t.Fatal(err)
41+
}
42+
if req.Model != "claude-sonnet-4-20250514" {
43+
t.Errorf("model %q", req.Model)
44+
}
45+
if req.System != "Be brief." {
46+
t.Errorf("system %q", req.System)
47+
}
48+
if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != `{"q":1}` {
49+
t.Fatalf("messages %+v", req.Messages)
50+
}
51+
if req.MaxTokens != defaultMaxTok {
52+
t.Errorf("max_tokens %d", req.MaxTokens)
53+
}
54+
w.Header().Set("Content-Type", "application/json")
55+
_, _ = w.Write([]byte(`{"content":[{"type":"text","text":"{\"ok\":true}"}],"usage":{"input_tokens":10,"output_tokens":20}}`))
56+
}))
57+
defer srv.Close()
58+
59+
c := &Client{APIKey: "sk-ant-test", BaseURL: srv.URL, HTTPClient: srv.Client()}
60+
text, inT, outT, _, err := c.Generate(context.Background(), "claude-sonnet-4-20250514", "Be brief.", []ChatMessage{
61+
{Role: "user", Content: `{"q":1}`},
62+
})
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
if text != `{"ok":true}` {
67+
t.Fatalf("text %q", text)
68+
}
69+
if inT != 10 || outT != 20 {
70+
t.Fatalf("usage in=%d out=%d", inT, outT)
71+
}
72+
}
73+
74+
func TestClient_Generate_concatTextBlocks(t *testing.T) {
75+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
w.Header().Set("Content-Type", "application/json")
77+
_, _ = w.Write([]byte(`{"content":[{"type":"text","text":"a"},{"type":"text","text":"b"}]}`))
78+
}))
79+
defer srv.Close()
80+
81+
c := &Client{APIKey: "k", BaseURL: srv.URL, HTTPClient: srv.Client()}
82+
text, _, _, _, err := c.Generate(context.Background(), "m", "", []ChatMessage{{Role: "user", Content: "x"}})
83+
if err != nil {
84+
t.Fatal(err)
85+
}
86+
if text != "ab" {
87+
t.Fatalf("got %q", text)
88+
}
89+
}
90+
91+
func TestClient_Generate_HTTPError(t *testing.T) {
92+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
w.WriteHeader(http.StatusUnauthorized)
94+
_, _ = w.Write([]byte(`{"type":"error","error":{"type":"authentication_error","message":"bad key"}}`))
95+
}))
96+
defer srv.Close()
97+
98+
c := &Client{APIKey: "bad", BaseURL: srv.URL, HTTPClient: srv.Client()}
99+
_, _, _, _, err := c.Generate(context.Background(), "m", "", []ChatMessage{{Role: "user", Content: "x"}})
100+
if err == nil || !strings.Contains(err.Error(), "HTTP 401") {
101+
t.Fatalf("got %v", err)
102+
}
103+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package models
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/models/anthropic"
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
11+
)
12+
13+
// anthropicClient adapts [anthropic.Client] to [ModelClient] (issue #69).
14+
type anthropicClient struct {
15+
inner *anthropic.Client
16+
}
17+
18+
// NewAnthropicClientFromConfig builds a client using apiKeyFrom (e.g. env:ANTHROPIC_API_KEY).
19+
func NewAnthropicClientFromConfig(cfg spec.ModelProviderConfig) (*anthropicClient, error) {
20+
key, err := ResolveAPIKeyFrom(cfg.APIKeyFrom)
21+
if err != nil {
22+
return nil, err
23+
}
24+
return &anthropicClient{
25+
inner: &anthropic.Client{APIKey: key, HTTPClient: http.DefaultClient},
26+
}, nil
27+
}
28+
29+
// splitAnthropicMessages maps engine-style chat (system + user/assistant) to Anthropic's
30+
// top-level system string and user|assistant message list.
31+
func splitAnthropicMessages(msgs []ChatMessage) (system string, out []anthropic.ChatMessage, err error) {
32+
var sys []string
33+
for _, m := range msgs {
34+
role := strings.ToLower(strings.TrimSpace(m.Role))
35+
switch role {
36+
case "system":
37+
sys = append(sys, m.Content)
38+
case "user", "assistant":
39+
out = append(out, anthropic.ChatMessage{Role: role, Content: m.Content})
40+
default:
41+
return "", nil, fmt.Errorf("models: anthropic does not support message role %q (use system, user, or assistant)", m.Role)
42+
}
43+
}
44+
return strings.Join(sys, "\n\n"), out, nil
45+
}
46+
47+
func (a *anthropicClient) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) {
48+
if a == nil || a.inner == nil {
49+
return GenerateResponse{}, fmt.Errorf("models: anthropic client not configured")
50+
}
51+
system, msgs, err := splitAnthropicMessages(req.Messages)
52+
if err != nil {
53+
return GenerateResponse{}, err
54+
}
55+
text, _, _, durationMs, err := a.inner.Generate(ctx, req.Model, system, msgs)
56+
if err != nil {
57+
return GenerateResponse{}, err
58+
}
59+
return GenerateResponse{
60+
Content: text,
61+
Meta: GenerateMeta{DurationMs: durationMs, CostUSD: 0},
62+
}, nil
63+
}

0 commit comments

Comments
 (0)