Skip to content

Commit d133148

Browse files
committed
Go port: providers (Anthropic SSE + OAuth, OpenAI-compatible, oMLX) with tests
1 parent aef2ccf commit d133148

5 files changed

Lines changed: 1265 additions & 0 deletions

File tree

‎provider/anthropic.go‎

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
package provider
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"os"
13+
"strings"
14+
"unicode"
15+
16+
"github.com/AgentiLoop/AgentiLoopGo/core"
17+
)
18+
19+
const (
20+
anthropicDefaultBaseURL = "https://api.anthropic.com"
21+
anthropicAPIVersion = "2023-06-01"
22+
oauthPrefix = "sk-ant-oat01-"
23+
oauthBeta = "oauth-2025-04-20,prompt-caching-2024-07-31"
24+
// OAuth tokens (from `claude setup-token`) are gated at the API to requests
25+
// whose first system block is exactly this string.
26+
claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude."
27+
)
28+
29+
type Anthropic struct {
30+
client *http.Client
31+
credential string
32+
baseURL string
33+
}
34+
35+
// NewAnthropic accepts either a standard API key (sk-ant-api…) or a Claude Code
36+
// OAuth token (sk-ant-oat01-…); the auth scheme is chosen automatically.
37+
func NewAnthropic(credential string) *Anthropic {
38+
base := os.Getenv("ANTHROPIC_BASE_URL")
39+
if base == "" {
40+
base = anthropicDefaultBaseURL
41+
}
42+
return &Anthropic{client: &http.Client{}, credential: sanitize(credential), baseURL: base}
43+
}
44+
45+
// AnthropicFromEnv reads ANTHROPIC_API_KEY (API key or OAuth token), falling back to ANTHROPIC_OAUTH_TOKEN.
46+
func AnthropicFromEnv() (*Anthropic, error) {
47+
key, ok := os.LookupEnv("ANTHROPIC_API_KEY")
48+
if !ok {
49+
key, ok = os.LookupEnv("ANTHROPIC_OAUTH_TOKEN")
50+
}
51+
if !ok {
52+
return nil, errors.New("ANTHROPIC_API_KEY is not set (API key or sk-ant-oat01- OAuth token)")
53+
}
54+
return NewAnthropic(key), nil
55+
}
56+
57+
func (a *Anthropic) IsOAuth() bool { return strings.HasPrefix(a.credential, oauthPrefix) }
58+
59+
// sanitize strips whitespace/control chars a terminal paste may have wrapped into the token.
60+
func sanitize(raw string) string {
61+
return strings.Map(func(r rune) rune {
62+
if unicode.IsSpace(r) || unicode.IsControl(r) {
63+
return -1
64+
}
65+
return r
66+
}, raw)
67+
}
68+
69+
func (a *Anthropic) Name() string { return "anthropic" }
70+
func (a *Anthropic) DefaultModel() string { return "claude-sonnet-5" }
71+
72+
func (a *Anthropic) auth(r *http.Request, beta string) {
73+
r.Header.Set("anthropic-version", anthropicAPIVersion)
74+
if a.IsOAuth() {
75+
r.Header.Set("authorization", "Bearer "+a.credential)
76+
r.Header.Set("anthropic-beta", beta)
77+
} else {
78+
r.Header.Set("x-api-key", a.credential)
79+
}
80+
}
81+
82+
type wireUsage struct {
83+
InputTokens uint64 `json:"input_tokens"`
84+
OutputTokens uint64 `json:"output_tokens"`
85+
}
86+
87+
type wireErrorBody struct {
88+
Type string `json:"type"`
89+
Message string `json:"message"`
90+
}
91+
92+
func (a *Anthropic) sendMessages(ctx context.Context, req core.ProviderRequest, stream bool) (*http.Response, error) {
93+
type sys struct {
94+
Type string `json:"type"`
95+
Text string `json:"text"`
96+
}
97+
system := []sys{}
98+
if a.IsOAuth() {
99+
system = append(system, sys{"text", claudeCodeIdentity})
100+
}
101+
system = append(system, sys{"text", req.System})
102+
body := map[string]any{
103+
"model": req.Model,
104+
"max_tokens": req.MaxTokens,
105+
"system": system,
106+
"messages": nonNilMessages(req.Messages),
107+
}
108+
if len(req.Tools) > 0 {
109+
body["tools"] = req.Tools
110+
}
111+
if stream {
112+
body["stream"] = true
113+
}
114+
data, err := json.Marshal(body)
115+
if err != nil {
116+
return nil, err
117+
}
118+
hr, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/v1/messages", bytes.NewReader(data))
119+
if err != nil {
120+
return nil, err
121+
}
122+
hr.Header.Set("content-type", "application/json")
123+
a.auth(hr, oauthBeta)
124+
resp, err := a.client.Do(hr)
125+
if err != nil {
126+
return nil, fmt.Errorf("request to Anthropic failed: %w", err)
127+
}
128+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
129+
defer resp.Body.Close()
130+
text, _ := io.ReadAll(resp.Body)
131+
var we struct{ Error wireErrorBody }
132+
if json.Unmarshal(text, &we) == nil && we.Error.Type != "" {
133+
return nil, fmt.Errorf("Anthropic %s (%s): %s", statusText(resp), we.Error.Type, we.Error.Message)
134+
}
135+
return nil, fmt.Errorf("Anthropic %s: %s", statusText(resp), text)
136+
}
137+
return resp, nil
138+
}
139+
140+
// ListModels fetches GET /v1/models, newest first (the API sorts by created_at descending).
141+
func (a *Anthropic) ListModels(ctx context.Context) ([]core.ModelInfo, error) {
142+
hr, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/v1/models?limit=100", nil)
143+
if err != nil {
144+
return nil, err
145+
}
146+
a.auth(hr, "oauth-2025-04-20")
147+
resp, err := a.client.Do(hr)
148+
if err != nil {
149+
return nil, fmt.Errorf("request to /v1/models failed: %w", err)
150+
}
151+
defer resp.Body.Close()
152+
text, err := io.ReadAll(resp.Body)
153+
if err != nil {
154+
return nil, err
155+
}
156+
if resp.StatusCode < 200 || resp.StatusCode > 299 {
157+
return nil, fmt.Errorf("Anthropic %s: %s", statusText(resp), text)
158+
}
159+
var list struct {
160+
Data []core.ModelInfo `json:"data"`
161+
}
162+
if err := json.Unmarshal(text, &list); err != nil {
163+
return nil, fmt.Errorf("decoding /v1/models: %w", err)
164+
}
165+
return list.Data, nil
166+
}
167+
168+
func (a *Anthropic) Complete(ctx context.Context, req core.ProviderRequest) (core.ProviderResponse, error) {
169+
resp, err := a.sendMessages(ctx, req, false)
170+
if err != nil {
171+
return core.ProviderResponse{}, err
172+
}
173+
defer resp.Body.Close()
174+
var wire struct {
175+
Content []core.ContentBlock `json:"content"`
176+
StopReason *string `json:"stop_reason"`
177+
Usage wireUsage `json:"usage"`
178+
}
179+
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
180+
return core.ProviderResponse{}, fmt.Errorf("decoding Anthropic response: %w", err)
181+
}
182+
return core.ProviderResponse{
183+
Message: core.Message{Role: core.RoleAssistant, Content: wire.Content},
184+
StopReason: parseStopReason(wire.StopReason),
185+
InputTokens: wire.Usage.InputTokens,
186+
OutputTokens: wire.Usage.OutputTokens,
187+
}, nil
188+
}
189+
190+
// partial is a content block being assembled from stream deltas.
191+
type partial struct {
192+
kind string // "text", "tool_use", "skip"
193+
text string
194+
id, name string
195+
json strings.Builder
196+
}
197+
198+
func (a *Anthropic) CompleteStream(ctx context.Context, req core.ProviderRequest, onText func(string)) (core.ProviderResponse, error) {
199+
resp, err := a.sendMessages(ctx, req, true)
200+
if err != nil {
201+
return core.ProviderResponse{}, err
202+
}
203+
defer resp.Body.Close()
204+
205+
var blocks []*partial
206+
var stopReason *string
207+
var inTok, outTok uint64
208+
r := bufio.NewReader(resp.Body)
209+
for {
210+
line, rerr := r.ReadString('\n')
211+
// SSE frames are newline-delimited; the JSON payload sits on `data:` lines.
212+
if data, ok := strings.CutPrefix(strings.TrimRight(line, "\r\n \t"), "data:"); ok {
213+
var ev struct {
214+
Type string `json:"type"`
215+
Message struct {
216+
Usage wireUsage `json:"usage"`
217+
} `json:"message"`
218+
ContentBlock struct {
219+
Type string `json:"type"`
220+
ID string `json:"id"`
221+
Name string `json:"name"`
222+
} `json:"content_block"`
223+
Index int `json:"index"`
224+
Delta struct {
225+
Type string `json:"type"`
226+
Text string `json:"text"`
227+
PartialJSON string `json:"partial_json"`
228+
StopReason *string `json:"stop_reason"`
229+
} `json:"delta"`
230+
Usage wireUsage `json:"usage"`
231+
Error wireErrorBody `json:"error"`
232+
}
233+
if err := json.Unmarshal([]byte(strings.TrimLeft(data, " ")), &ev); err != nil {
234+
return core.ProviderResponse{}, fmt.Errorf("decoding stream event: %w", err)
235+
}
236+
switch ev.Type {
237+
case "message_start":
238+
inTok = ev.Message.Usage.InputTokens
239+
case "content_block_start":
240+
p := &partial{kind: "skip"}
241+
switch ev.ContentBlock.Type {
242+
case "text":
243+
p.kind = "text"
244+
case "tool_use":
245+
p.kind, p.id, p.name = "tool_use", ev.ContentBlock.ID, ev.ContentBlock.Name
246+
}
247+
blocks = append(blocks, p)
248+
case "content_block_delta":
249+
if ev.Index < 0 || ev.Index >= len(blocks) {
250+
break
251+
}
252+
b := blocks[ev.Index]
253+
switch {
254+
case b.kind == "text" && ev.Delta.Type == "text_delta":
255+
onText(ev.Delta.Text)
256+
b.text += ev.Delta.Text
257+
case b.kind == "tool_use" && ev.Delta.Type == "input_json_delta":
258+
b.json.WriteString(ev.Delta.PartialJSON)
259+
}
260+
case "message_delta":
261+
stopReason = ev.Delta.StopReason
262+
outTok = ev.Usage.OutputTokens
263+
case "error":
264+
return core.ProviderResponse{}, fmt.Errorf("Anthropic stream error (%s): %s", ev.Error.Type, ev.Error.Message)
265+
case "content_block_stop", "message_stop", "ping":
266+
default:
267+
return core.ProviderResponse{}, fmt.Errorf("decoding stream event: unknown variant `%s`", ev.Type)
268+
}
269+
}
270+
if rerr == io.EOF {
271+
break
272+
}
273+
if rerr != nil {
274+
return core.ProviderResponse{}, fmt.Errorf("reading Anthropic stream: %w", rerr)
275+
}
276+
}
277+
278+
var content []core.ContentBlock
279+
for _, b := range blocks {
280+
switch b.kind {
281+
case "text":
282+
content = append(content, core.TextBlock(b.text))
283+
case "tool_use":
284+
raw := strings.TrimSpace(b.json.String())
285+
input := json.RawMessage("{}")
286+
if raw != "" {
287+
if !json.Valid([]byte(raw)) {
288+
return core.ProviderResponse{}, fmt.Errorf("decoding tool input for `%s`: invalid JSON", b.name)
289+
}
290+
input = json.RawMessage(raw)
291+
}
292+
content = append(content, core.ToolUseBlock(b.id, b.name, input))
293+
}
294+
}
295+
return core.ProviderResponse{
296+
Message: core.Message{Role: core.RoleAssistant, Content: content},
297+
StopReason: parseStopReason(stopReason),
298+
InputTokens: inTok,
299+
OutputTokens: outTok,
300+
}, nil
301+
}
302+
303+
func parseStopReason(raw *string) core.StopReason {
304+
if raw == nil {
305+
return core.StopOther
306+
}
307+
switch s := core.StopReason(*raw); s {
308+
case core.StopEndTurn, core.StopToolUse, core.StopMaxTokens, core.StopStopSequence:
309+
return s
310+
}
311+
return core.StopOther
312+
}
313+
314+
// statusText renders like reqwest's StatusCode Display: "401 Unauthorized".
315+
func statusText(resp *http.Response) string {
316+
return fmt.Sprintf("%d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
317+
}
318+
319+
func nonNilMessages(m []core.Message) []core.Message {
320+
if m == nil {
321+
return []core.Message{}
322+
}
323+
return m
324+
}

0 commit comments

Comments
 (0)