Skip to content

Commit 60527e1

Browse files
asimclaude
andauthored
Add AI provider integration guide and Atlas Cloud support (#2898)
* docs: add AI provider integration guide and Supported AI Providers section Add a step-by-step guide for AI infrastructure companies to implement ai.Model and contribute a provider to go-micro. Covers the full lifecycle: skeleton, tool call handling, tests, registration, and PR checklist. Add a "Supported AI Providers" section to the project README that lists current providers (Anthropic, OpenAI) in a table and links to the integration guide with a call-to-action for new providers and sponsors. Streamline the "Adding a New Provider" section in ai/README.md to point to the new guide instead of duplicating a full code listing. * feat(ai): add Atlas Cloud provider Add ai/atlascloud implementing ai.Model for Atlas Cloud's OpenAI-compatible chat completions API. Registers as "atlascloud" with default model llama-3.3-70b and base URL https://api.atlascloud.ai. Supports tool calling via ToolHandler. Includes 7 unit tests covering registration, defaults, init, generate-without-key, and stream-not-implemented. Update the Supported AI Providers table in README.md and the Supported Providers section in ai/README.md. * fix: remove nonexistent Discord link from README --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent defb278 commit 60527e1

4 files changed

Lines changed: 327 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,7 @@ Go Micro’s `ai` package gives every provider the same interface: `Init`, `Gene
397397
|----------|--------|---------------|
398398
| **Anthropic** | `go-micro.dev/v5/ai/anthropic` | `claude-sonnet-4-20250514` |
399399
| **OpenAI** | `go-micro.dev/v5/ai/openai` | `gpt-4o` |
400+
| **Atlas Cloud** | `go-micro.dev/v5/ai/atlascloud` | `llama-3.3-70b` |
400401

401402
Any provider that exposes an OpenAI-compatible API can also be used directly:
402403

ai/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,20 @@ m := ai.New("openai",
152152
Default model: `gpt-4o`
153153
Default base URL: `https://api.openai.com`
154154

155+
### Atlas Cloud
156+
157+
```go
158+
m := ai.New("atlascloud",
159+
ai.WithAPIKey("your-key"),
160+
ai.WithModel("llama-3.3-70b"), // default
161+
)
162+
```
163+
164+
Default model: `llama-3.3-70b`
165+
Default base URL: `https://api.atlascloud.ai`
166+
167+
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
168+
155169
## Auto-Detection
156170

157171
Use `AutoDetectProvider()` to detect the provider from a base URL:

ai/atlascloud/atlascloud.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Package atlascloud implements the Atlas Cloud model provider.
2+
//
3+
// Atlas Cloud is an enterprise AI infrastructure platform offering
4+
// high-performance LLM APIs. It exposes an OpenAI-compatible
5+
// chat completions endpoint.
6+
//
7+
// Usage:
8+
//
9+
// import _ "go-micro.dev/v5/ai/atlascloud"
10+
//
11+
// m := ai.New("atlascloud",
12+
// ai.WithAPIKey("your-api-key"),
13+
// )
14+
package atlascloud
15+
16+
import (
17+
"bytes"
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"net/http"
23+
"strings"
24+
25+
"go-micro.dev/v5/ai"
26+
)
27+
28+
func init() {
29+
ai.Register("atlascloud", func(opts ...ai.Option) ai.Model {
30+
return NewProvider(opts...)
31+
})
32+
}
33+
34+
// Provider implements the ai.Model interface for Atlas Cloud.
35+
type Provider struct {
36+
opts ai.Options
37+
}
38+
39+
// NewProvider creates a new Atlas Cloud provider.
40+
func NewProvider(opts ...ai.Option) *Provider {
41+
options := ai.NewOptions(opts...)
42+
43+
if options.Model == "" {
44+
options.Model = "llama-3.3-70b"
45+
}
46+
if options.BaseURL == "" {
47+
options.BaseURL = "https://api.atlascloud.ai"
48+
}
49+
50+
return &Provider{opts: options}
51+
}
52+
53+
func (p *Provider) Init(opts ...ai.Option) error {
54+
for _, o := range opts {
55+
o(&p.opts)
56+
}
57+
return nil
58+
}
59+
60+
func (p *Provider) Options() ai.Options { return p.opts }
61+
func (p *Provider) String() string { return "atlascloud" }
62+
63+
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
64+
var tools []map[string]any
65+
for _, t := range req.Tools {
66+
tools = append(tools, map[string]any{
67+
"type": "function",
68+
"function": map[string]any{
69+
"name": t.Name,
70+
"description": t.Description,
71+
"parameters": map[string]any{
72+
"type": "object",
73+
"properties": t.Properties,
74+
},
75+
},
76+
})
77+
}
78+
79+
messages := []map[string]any{
80+
{"role": "system", "content": req.SystemPrompt},
81+
{"role": "user", "content": req.Prompt},
82+
}
83+
84+
apiReq := map[string]any{
85+
"model": p.opts.Model,
86+
"messages": messages,
87+
}
88+
89+
if len(tools) > 0 {
90+
apiReq["tools"] = tools
91+
}
92+
93+
resp, rawMessage, err := p.callAPI(ctx, apiReq)
94+
if err != nil {
95+
return nil, err
96+
}
97+
98+
if len(resp.ToolCalls) == 0 {
99+
return resp, nil
100+
}
101+
102+
if p.opts.ToolHandler != nil {
103+
followUpMessages := append(messages, map[string]any{
104+
"role": "assistant",
105+
"content": rawMessage["content"],
106+
"tool_calls": rawMessage["tool_calls"],
107+
})
108+
109+
for _, tc := range resp.ToolCalls {
110+
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
111+
followUpMessages = append(followUpMessages, map[string]any{
112+
"role": "tool",
113+
"tool_call_id": tc.ID,
114+
"content": content,
115+
})
116+
}
117+
118+
followUpReq := map[string]any{
119+
"model": p.opts.Model,
120+
"messages": followUpMessages,
121+
}
122+
123+
followUpResp, _, err := p.callAPI(ctx, followUpReq)
124+
if err == nil && followUpResp.Reply != "" {
125+
resp.Answer = followUpResp.Reply
126+
}
127+
}
128+
129+
return resp, nil
130+
}
131+
132+
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
133+
return nil, fmt.Errorf("streaming not yet implemented for atlascloud provider")
134+
}
135+
136+
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
137+
reqBody, err := json.Marshal(req)
138+
if err != nil {
139+
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
140+
}
141+
142+
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
143+
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
144+
if err != nil {
145+
return nil, nil, fmt.Errorf("failed to create request: %w", err)
146+
}
147+
148+
httpReq.Header.Set("Content-Type", "application/json")
149+
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
150+
151+
httpResp, err := http.DefaultClient.Do(httpReq)
152+
if err != nil {
153+
return nil, nil, fmt.Errorf("API request failed: %w", err)
154+
}
155+
defer httpResp.Body.Close()
156+
157+
respBody, _ := io.ReadAll(httpResp.Body)
158+
if httpResp.StatusCode != 200 {
159+
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
160+
}
161+
162+
var chatResp struct {
163+
Choices []struct {
164+
Message struct {
165+
Content string `json:"content"`
166+
ToolCalls []struct {
167+
ID string `json:"id"`
168+
Function struct {
169+
Name string `json:"name"`
170+
Arguments string `json:"arguments"`
171+
} `json:"function"`
172+
} `json:"tool_calls"`
173+
} `json:"message"`
174+
} `json:"choices"`
175+
}
176+
177+
if err := json.Unmarshal(respBody, &chatResp); err != nil {
178+
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
179+
}
180+
181+
if len(chatResp.Choices) == 0 {
182+
return nil, nil, fmt.Errorf("no response from API")
183+
}
184+
185+
choice := chatResp.Choices[0]
186+
response := &ai.Response{
187+
Reply: choice.Message.Content,
188+
}
189+
190+
for _, tc := range choice.Message.ToolCalls {
191+
var input map[string]any
192+
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
193+
input = map[string]any{}
194+
}
195+
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
196+
ID: tc.ID,
197+
Name: tc.Function.Name,
198+
Input: input,
199+
})
200+
}
201+
202+
rawMessage := map[string]any{
203+
"content": choice.Message.Content,
204+
"tool_calls": choice.Message.ToolCalls,
205+
}
206+
207+
return response, rawMessage, nil
208+
}

ai/atlascloud/atlascloud_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package atlascloud
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"go-micro.dev/v5/ai"
8+
)
9+
10+
func TestProvider_String(t *testing.T) {
11+
p := NewProvider()
12+
if p.String() != "atlascloud" {
13+
t.Errorf("Expected provider name 'atlascloud', got '%s'", p.String())
14+
}
15+
}
16+
17+
func TestProvider_Init(t *testing.T) {
18+
p := NewProvider()
19+
20+
err := p.Init(
21+
ai.WithModel("test-model"),
22+
ai.WithAPIKey("test-key"),
23+
ai.WithBaseURL("https://test.com"),
24+
)
25+
26+
if err != nil {
27+
t.Fatalf("Init failed: %v", err)
28+
}
29+
30+
opts := p.Options()
31+
if opts.Model != "test-model" {
32+
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
33+
}
34+
if opts.APIKey != "test-key" {
35+
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
36+
}
37+
if opts.BaseURL != "https://test.com" {
38+
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
39+
}
40+
}
41+
42+
func TestProvider_Options(t *testing.T) {
43+
p := NewProvider(
44+
ai.WithModel("custom-model"),
45+
ai.WithAPIKey("my-key"),
46+
)
47+
48+
opts := p.Options()
49+
if opts.Model != "custom-model" {
50+
t.Errorf("Expected model 'custom-model', got '%s'", opts.Model)
51+
}
52+
if opts.APIKey != "my-key" {
53+
t.Errorf("Expected API key 'my-key', got '%s'", opts.APIKey)
54+
}
55+
}
56+
57+
func TestProvider_Defaults(t *testing.T) {
58+
p := NewProvider()
59+
60+
opts := p.Options()
61+
if opts.Model != "llama-3.3-70b" {
62+
t.Errorf("Expected default model 'llama-3.3-70b', got '%s'", opts.Model)
63+
}
64+
if opts.BaseURL != "https://api.atlascloud.ai" {
65+
t.Errorf("Expected default base URL 'https://api.atlascloud.ai', got '%s'", opts.BaseURL)
66+
}
67+
}
68+
69+
func TestProvider_Generate_NoAPIKey(t *testing.T) {
70+
p := NewProvider()
71+
72+
req := &ai.Request{
73+
Prompt: "Hello",
74+
SystemPrompt: "You are helpful",
75+
}
76+
77+
_, err := p.Generate(context.Background(), req)
78+
if err == nil {
79+
t.Error("Expected error when API key is missing, got nil")
80+
}
81+
}
82+
83+
func TestProvider_Stream_NotImplemented(t *testing.T) {
84+
p := NewProvider()
85+
86+
req := &ai.Request{
87+
Prompt: "Hello",
88+
}
89+
90+
_, err := p.Stream(context.Background(), req)
91+
if err == nil {
92+
t.Error("Expected error for unimplemented streaming, got nil")
93+
}
94+
}
95+
96+
func TestProvider_Registration(t *testing.T) {
97+
m := ai.New("atlascloud", ai.WithAPIKey("test"))
98+
if m == nil {
99+
t.Fatal("ai.New('atlascloud') returned nil — provider not registered")
100+
}
101+
if m.String() != "atlascloud" {
102+
t.Errorf("Expected 'atlascloud', got '%s'", m.String())
103+
}
104+
}

0 commit comments

Comments
 (0)