Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion sdk/go/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,9 @@ func TestAI(t *testing.T) {
Choices: []ai.Choice{
{
Message: ai.Message{
Content: "AI response",
Content: []ai.ContentPart{
{Type: "text", Text: "AI response"},
},
},
},
},
Expand Down
26 changes: 26 additions & 0 deletions sdk/go/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,32 @@ Functional options for customizing AI requests:
- `ai.WithStream()` - Enable streaming
- `ai.WithJSONMode()` - Enable JSON object mode
- `ai.WithSchema(schema interface{})` - Enable structured outputs with schema
##### Multimodal
- `ai.WithImageFile(path string)` - Attach an image from a local file
- `ai.WithImageURL(url string)` - Attach an image from a remote URL
- `ai.WithImageBytes(data []byte, mimeType string)` - Add an image from raw bytes (SDK encodes automatically)

### Multimodal Inputs (Images)

You can attach images files to AI requests.

```go
// Image from file
response, _ := agent.AI(ctx, "Describe this image",
ai.WithImageFile("./photo.jpg"),
)

// Image from URL
response, _ = agent.AI(ctx, "Describe this image",
ai.WithImageURL("https://example.com/image.jpg"),
)

// Image from bytes
data, _ := os.ReadFile("image.png")
response, _ = agent.AI(ctx, "What's in this image?",
ai.WithImageBytes(data, "image/png"),
)
```

### Response Methods

Expand Down
26 changes: 23 additions & 3 deletions sdk/go/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@ func (c *Client) Complete(ctx context.Context, prompt string, opts ...Option) (*
// Build base request
req := &Request{
Messages: []Message{
{Role: "user", Content: prompt},
{
Role: "user",
Content: []ContentPart{
{Type: "text", Text: prompt},
},
},
},
Model: c.config.Model,
Temperature: &c.config.Temperature,
Expand Down Expand Up @@ -78,7 +83,17 @@ func (c *Client) CompleteWithMessages(ctx context.Context, messages []Message, o

func (c *Client) doRequest(ctx context.Context, req *Request) (*Response, error) {
// Marshal request
body, err := json.Marshal(req)

var body []byte
var err error

if c.config.IsOpenRouter() {
payload := transformForOpenRouter(req)
body, err = json.Marshal(payload)
fmt.Printf("[DEBUG] OpenRouter JSON:\n%s\n", string(body))
} else {
body, err = json.Marshal(req)
}
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
Expand Down Expand Up @@ -155,7 +170,12 @@ func (c *Client) StreamComplete(ctx context.Context, prompt string, opts ...Opti
opts = append(opts, WithStream())
req := &Request{
Messages: []Message{
{Role: "user", Content: prompt},
{
Role: "user",
Content: []ContentPart{
{Type: "text", Text: prompt},
},
},
},
Model: c.config.Model,
Temperature: &c.config.Temperature,
Expand Down
79 changes: 70 additions & 9 deletions sdk/go/ai/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ func TestComplete(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, req.Messages, 1)
assert.Equal(t, "user", req.Messages[0].Role)
assert.Equal(t, "Hello", req.Messages[0].Content)
assert.Len(t, req.Messages[0].Content, 1)
assert.Equal(t, "text", req.Messages[0].Content[0].Type)
assert.Equal(t, "Hello", req.Messages[0].Content[0].Text)

// Send response
resp := Response{
Expand All @@ -85,8 +87,13 @@ func TestComplete(t *testing.T) {
{
Index: 0,
Message: Message{
Role: "assistant",
Content: "Hello! How can I help you?",
Role: "assistant",
Content: []ContentPart{
{
Type: "text",
Text: "Hello! How can I help you?",
},
},
},
FinishReason: "stop",
},
Expand All @@ -97,6 +104,7 @@ func TestComplete(t *testing.T) {
TotalTokens: 15,
},
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
Expand Down Expand Up @@ -124,7 +132,17 @@ func TestComplete_WithAPIKeyOverride(t *testing.T) {

resp := Response{
Choices: []Choice{
{Message: Message{Content: "ok"}},
{
Message: Message{
Role: "assistant",
Content: []ContentPart{
{
Type: "text",
Text: "ok",
},
},
},
},
},
}
w.WriteHeader(http.StatusOK)
Expand Down Expand Up @@ -162,9 +180,20 @@ func TestComplete_WithOptions(t *testing.T) {

resp := Response{
Choices: []Choice{
{Message: Message{Content: "Response"}},
{
Message: Message{
Role: "assistant",
Content: []ContentPart{
{
Type: "text",
Text: "Response",
},
},
},
},
},
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
Expand Down Expand Up @@ -197,9 +226,20 @@ func TestComplete_WithOpenRouterHeaders(t *testing.T) {
receivedHeaders = r.Header
resp := Response{
Choices: []Choice{
{Message: Message{Content: "Response"}},
{
Message: Message{
Role: "assistant",
Content: []ContentPart{
{
Type: "text",
Text: "Reponse",
},
},
},
},
},
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
Expand Down Expand Up @@ -325,9 +365,20 @@ func TestCompleteWithMessages(t *testing.T) {

resp := Response{
Choices: []Choice{
{Message: Message{Content: "Response"}},
{
Message: Message{
Role: "assistant", // optional but recommended
Content: []ContentPart{
{
Type: "text",
Text: "Response",
},
},
},
},
},
}

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
Expand All @@ -343,8 +394,18 @@ func TestCompleteWithMessages(t *testing.T) {
require.NoError(t, err)

messages := []Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hello"},
{
Role: "system",
Content: []ContentPart{
{Type: "text", Text: "You are helpful"},
},
},
{
Role: "user",
Content: []ContentPart{
{Type: "text", Text: "Hello"},
},
},
}

resp, err := client.CompleteWithMessages(context.Background(), messages)
Expand Down
55 changes: 55 additions & 0 deletions sdk/go/ai/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,58 @@ func (c *Config) IsOpenRouter() bool {
return c.BaseURL == "https://openrouter.ai/api/v1" ||
c.BaseURL == "https://openrouter.ai/api/v1/"
}

type OpenRouterRequest struct {
Messages []OpenRouterMessage `json:"messages"`
Model string `json:"model,omitempty"`
}

type OpenRouterMessage struct {
Role string `json:"role"`
Content []OpenRouterContentPart `json:"content"`
}

type OpenRouterContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageData `json:"image_url,omitempty"`
}

type ImageData struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}

func transformForOpenRouter(req *Request) *OpenRouterRequest {
var messages []OpenRouterMessage

for _, m := range req.Messages {
msg := OpenRouterMessage{
Role: m.Role,
}

for _, c := range m.Content {
part := OpenRouterContentPart{
Type: c.Type,
Text: c.Text,
}

// Map Go struct's input_image to OpenRouter's image_url
if c.Type == "input_image" && c.ImageURL != "" {
part.Type = "image_url"
part.ImageURL = &ImageData{
URL: c.ImageURL,
}
}

msg.Content = append(msg.Content, part)
}

messages = append(messages, msg)
}

return &OpenRouterRequest{
Messages: messages,
Model: req.Model,
}
}
18 changes: 18 additions & 0 deletions sdk/go/ai/multimodal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ai

import "strings"

func detectMIMEType(path string) string {
switch {
case strings.HasSuffix(path, ".png"):
return "image/png"
case strings.HasSuffix(path, ".jpg"), strings.HasSuffix(path, ".jpeg"):
return "image/jpeg"
case strings.HasSuffix(path, ".gif"):
return "image/gif"
case strings.HasSuffix(path, ".webp"):
return "image/webp"
default:
return "application/octet-stream"
}
}
Loading
Loading