From a727cae963f6f4d0893e10a92673d557cb4a3ad8 Mon Sep 17 00:00:00 2001 From: Ben G Date: Mon, 26 Jan 2026 17:24:32 +0100 Subject: [PATCH 1/5] feat: add support for image and audio inputs in ai calls --- sdk/go/ai/multimodal.go | 51 ++++++++++++ sdk/go/ai/request.go | 86 ++++++++++++++++++++ sdk/go/ai/request_test.go | 163 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 sdk/go/ai/multimodal.go diff --git a/sdk/go/ai/multimodal.go b/sdk/go/ai/multimodal.go new file mode 100644 index 00000000..1ad4c572 --- /dev/null +++ b/sdk/go/ai/multimodal.go @@ -0,0 +1,51 @@ +package ai + +import "strings" + +type Image struct { + URL string `json:"url,omitempty"` + Data string `json:"data,omitempty"` + MIMEType string `json:"mime_type,omitempty"` +} + +type Audio struct { + URL string `json:"url,omitempty"` + Data string `json:"data,omitempty"` + Format string `json:"format,omitempty"` +} + +// detectMIMEType detects the MIME type from a file path. +func detectMIMEType(path string) string { + // Simple mapping; consider using a library like github.com/gabriel-vasile/mimetype for robustness + 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" + } +} + +// detectAudioFormat detects the audio format from a file path. +func detectAudioFormat(path string) string { + // Simple mapping; consider using a library for robustness + switch { + case strings.HasSuffix(path, ".mp3"): + return "mp3" + case strings.HasSuffix(path, ".wav"): + return "wav" + case strings.HasSuffix(path, ".ogg"): + return "ogg" + case strings.HasSuffix(path, ".m4a"): + return "m4a" + case strings.HasSuffix(path, ".flac"): + return "flac" + default: + return "unknown" + } +} diff --git a/sdk/go/ai/request.go b/sdk/go/ai/request.go index 41d07173..9267dcce 100644 --- a/sdk/go/ai/request.go +++ b/sdk/go/ai/request.go @@ -1,8 +1,10 @@ package ai import ( + "encoding/base64" "encoding/json" "fmt" + "os" "reflect" ) @@ -35,6 +37,9 @@ type Request struct { // Response format for structured outputs ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + + Images []Image `json:"images,omitempty"` + Audios []Audio `json:"audios,omitempty"` } // ResponseFormat specifies the desired output format. @@ -153,6 +158,87 @@ func WithSchema(schema interface{}) Option { } } +// Image options +func WithImageFile(path string) Option { + return func(r *Request) error { + // Read the file + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read image file: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(data) + + r.Images = append(r.Images, Image{ + Data: encoded, + MIMEType: detectMIMEType(path), + }) + return nil + } +} + +func WithImageURL(url string) Option { + return func(r *Request) error { + r.Images = append(r.Images, Image{ + URL: url, + }) + return nil + } +} + +func WithImageBytes(data []byte, mimeType string) Option { + return func(r *Request) error { + encoded := base64.StdEncoding.EncodeToString(data) + + r.Images = append(r.Images, Image{ + Data: encoded, + MIMEType: mimeType, + }) + + return nil + } +} + +// Audio options +func WithAudioFile(path string) Option { + return func(r *Request) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read audio file: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(data) + r.Audios = append(r.Audios, Audio{ + Data: encoded, + Format: detectAudioFormat(path), + }) + + return nil + } +} + +func WithAudioURL(url string) Option { + return func(r *Request) error { + r.Audios = append(r.Audios, Audio{ + URL: url, + }) + return nil + } +} + +func WithAudioBytes(data []byte, format string) Option { + return func(r *Request) error { + encoded := base64.StdEncoding.EncodeToString(data) + + r.Audios = append(r.Audios, Audio{ + Data: encoded, + Format: format, + }) + + return nil + } +} + // structToJSONSchema converts a Go struct to a JSON schema. // This is a simplified version - you may want to use a library like // github.com/invopop/jsonschema for production. diff --git a/sdk/go/ai/request_test.go b/sdk/go/ai/request_test.go index 5310aa6f..54577d08 100644 --- a/sdk/go/ai/request_test.go +++ b/sdk/go/ai/request_test.go @@ -2,6 +2,7 @@ package ai import ( "encoding/json" + "os" "reflect" "testing" @@ -143,6 +144,168 @@ func TestWithSchema_InvalidType(t *testing.T) { assert.Error(t, err) } +func TestWithImageFile(t *testing.T) { + // Create a temporary image file for testing + tempFile, err := os.CreateTemp("", "test_image_*.jpg") + assert.NoError(t, err) + defer os.Remove(tempFile.Name()) + + // Write dummy data to the file + _, err = tempFile.Write([]byte{0xFF, 0xD8, 0xFF}) + assert.NoError(t, err) + tempFile.Close() + + req := &Request{} + err = WithImageFile(tempFile.Name())(req) + + assert.NoError(t, err) + assert.Len(t, req.Images, 1) + assert.NotEmpty(t, req.Images[0].Data) + assert.Equal(t, "image/jpeg", req.Images[0].MIMEType) +} + +func TestWithImageURL(t *testing.T) { + req := &Request{} + testURL := "https://example.com/image.jpg" + + err := WithImageURL(testURL)(req) + + assert.NoError(t, err) + assert.Len(t, req.Images, 1) + assert.Equal(t, testURL, req.Images[0].URL) + assert.Empty(t, req.Images[0].Data) + assert.Empty(t, req.Images[0].MIMEType) +} + +func TestWithImageBytes(t *testing.T) { + req := &Request{} + testBytes := []byte{0xFF, 0xD8, 0xFF} + testMIMEType := "image/jpeg" + + err := WithImageBytes(testBytes, testMIMEType)(req) + + assert.NoError(t, err) + assert.Len(t, req.Images, 1) + assert.NotEmpty(t, req.Images[0].Data) + assert.Equal(t, testMIMEType, req.Images[0].MIMEType) +} + +func TestWithImageFile_Error(t *testing.T) { + req := &Request{} + + err := WithImageFile("non_existent_file.jpg")(req) + + assert.Error(t, err) + assert.Len(t, req.Images, 0) +} + +func TestWithImageBytes_EmptyInput(t *testing.T) { + req := &Request{} + + err := WithImageBytes(nil, "")(req) + + assert.NoError(t, err) + assert.Len(t, req.Images, 0) +} + +func TestMultipleImages(t *testing.T) { + req := &Request{} + + err := WithImageURL("https://example.com/image1.jpg")(req) + assert.NoError(t, err) + + tempFile, err := os.CreateTemp("", "test_image_*.jpg") + assert.NoError(t, err) + defer os.Remove(tempFile.Name()) + _, err = tempFile.Write([]byte{0xFF, 0xD8, 0xFF}) + assert.NoError(t, err) + tempFile.Close() + + err = WithImageFile(tempFile.Name())(req) + assert.NoError(t, err) + + testBytes := []byte{0x89, 0x50, 0x4E, 0x47} + err = WithImageBytes(testBytes, "image/png")(req) + assert.NoError(t, err) + + assert.Len(t, req.Images, 3) + assert.Equal(t, "https://example.com/image1.jpg", req.Images[0].URL) + assert.NotEmpty(t, req.Images[1].Data) + assert.Equal(t, "image/jpeg", req.Images[1].MIMEType) + assert.NotEmpty(t, req.Images[2].Data) + assert.Equal(t, "image/png", req.Images[2].MIMEType) +} + +func TestWithAudioFile(t *testing.T) { + // Create a temporary audio file for testing + tempFile, err := os.CreateTemp("", "test_audio_*.mp3") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile.Name()) + + // Write dummy data to the file + _, err = tempFile.Write([]byte{0x01, 0x02, 0x03}) + if err != nil { + t.Fatalf("Failed to write to temp file: %v", err) + } + tempFile.Close() + + req := &Request{} + err = WithAudioFile(tempFile.Name())(req) + if err != nil { + t.Fatalf("WithAudioFile failed: %v", err) + } + + assert.Len(t, req.Audios, 1) + assert.NotEmpty(t, req.Audios[0].Data) + assert.Equal(t, "mp3", req.Audios[0].Format) +} + +func TestWithAudioURL(t *testing.T) { + req := &Request{} + testURL := "https://example.com/audio.mp3" + + err := WithAudioURL(testURL)(req) + + assert.NoError(t, err) + assert.Len(t, req.Audios, 1) + assert.Equal(t, testURL, req.Audios[0].URL) + assert.Empty(t, req.Audios[0].Data) + assert.Empty(t, req.Audios[0].Format) +} + +func TestWithAudioBytes(t *testing.T) { + req := &Request{} + testBytes := []byte{0x01, 0x02, 0x03} + testFormat := "mp3" + + err := WithAudioBytes(testBytes, testFormat)(req) + + assert.NoError(t, err) + assert.Len(t, req.Audios, 1) + assert.NotEmpty(t, req.Audios[0].Data) + assert.Equal(t, testFormat, req.Audios[0].Format) +} + +func TestWithAudioFile_Error(t *testing.T) { + req := &Request{} + + err := WithAudioFile("non_existent_file.mp3")(req) + + assert.Error(t, err) + assert.Len(t, req.Audios, 0) +} + +func TestWithAudioBytes_EmptyInput(t *testing.T) { + req := &Request{} + + err := WithAudioBytes(nil, "")(req) + + assert.NoError(t, err) + assert.Len(t, req.Audios, 0) +} + func TestStructToJSONSchema(t *testing.T) { type User struct { ID int `json:"id"` From 207cda86afcc44a53d1c2510ab4004a1cfb3e010 Mon Sep 17 00:00:00 2001 From: Ben G Date: Mon, 26 Jan 2026 18:06:10 +0100 Subject: [PATCH 2/5] fix tests --- sdk/go/ai/README.md | 29 +++++++++++++++++++++++++++++ sdk/go/ai/multimodal.go | 4 ---- sdk/go/ai/request.go | 8 ++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/sdk/go/ai/README.md b/sdk/go/ai/README.md index dc80d364..996e385f 100644 --- a/sdk/go/ai/README.md +++ b/sdk/go/ai/README.md @@ -146,6 +146,35 @@ 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) +- `ai.WithAudioFile(path string)` - Attach audio from a local file +- `ai.WithAudioURL(url string)` - Attach audio from a remote URL +- `ai.WithAudioBytes(data []byte, format string)` - Add audio from raw bytes (SDK encodes automatically) + +### Multimodal Inputs (Images & Audio) + +You can attach images or audio files to AI requests. + +```go +// Image from file +response, _ := agent.AI(ctx, "Describe this image", + ai.WithImageFile("./photo.jpg"), +) + +// Audio from URL +response, _ = agent.AI(ctx, "Transcribe this audio", + ai.WithAudioURL("https://example.com/audio.mp3"), +) + +// Image from bytes +data, _ := os.ReadFile("image.png") +response, _ = agent.AI(ctx, "What's in this image?", + ai.WithImageBytes(data, "image/png"), +) +``` ### Response Methods diff --git a/sdk/go/ai/multimodal.go b/sdk/go/ai/multimodal.go index 1ad4c572..dd230b4e 100644 --- a/sdk/go/ai/multimodal.go +++ b/sdk/go/ai/multimodal.go @@ -14,9 +14,7 @@ type Audio struct { Format string `json:"format,omitempty"` } -// detectMIMEType detects the MIME type from a file path. func detectMIMEType(path string) string { - // Simple mapping; consider using a library like github.com/gabriel-vasile/mimetype for robustness switch { case strings.HasSuffix(path, ".png"): return "image/png" @@ -31,9 +29,7 @@ func detectMIMEType(path string) string { } } -// detectAudioFormat detects the audio format from a file path. func detectAudioFormat(path string) string { - // Simple mapping; consider using a library for robustness switch { case strings.HasSuffix(path, ".mp3"): return "mp3" diff --git a/sdk/go/ai/request.go b/sdk/go/ai/request.go index 9267dcce..6b3c7b5f 100644 --- a/sdk/go/ai/request.go +++ b/sdk/go/ai/request.go @@ -188,6 +188,10 @@ func WithImageURL(url string) Option { func WithImageBytes(data []byte, mimeType string) Option { return func(r *Request) error { + if len(data) == 0 { + return nil + } + encoded := base64.StdEncoding.EncodeToString(data) r.Images = append(r.Images, Image{ @@ -228,6 +232,10 @@ func WithAudioURL(url string) Option { func WithAudioBytes(data []byte, format string) Option { return func(r *Request) error { + if len(data) == 0 { + return nil + } + encoded := base64.StdEncoding.EncodeToString(data) r.Audios = append(r.Audios, Audio{ From 14dc06334d189fc5e4c2bff0ff4539cc62259b09 Mon Sep 17 00:00:00 2001 From: Ben G Date: Wed, 11 Feb 2026 23:02:58 +0100 Subject: [PATCH 3/5] fix with image calls --- sdk/go/ai/README.md | 13 ++- sdk/go/ai/client.go | 16 +++- sdk/go/ai/client_test.go | 79 ++++++++++++++++-- sdk/go/ai/multimodal.go | 30 +------ sdk/go/ai/request.go | 145 +++++++++++++++++++------------- sdk/go/ai/request_test.go | 164 ++++++++++++++++--------------------- sdk/go/ai/response.go | 13 ++- sdk/go/ai/response_test.go | 36 +++++--- 8 files changed, 286 insertions(+), 210 deletions(-) diff --git a/sdk/go/ai/README.md b/sdk/go/ai/README.md index 996e385f..c8b4e216 100644 --- a/sdk/go/ai/README.md +++ b/sdk/go/ai/README.md @@ -150,13 +150,10 @@ Functional options for customizing AI requests: - `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) -- `ai.WithAudioFile(path string)` - Attach audio from a local file -- `ai.WithAudioURL(url string)` - Attach audio from a remote URL -- `ai.WithAudioBytes(data []byte, format string)` - Add audio from raw bytes (SDK encodes automatically) -### Multimodal Inputs (Images & Audio) +### Multimodal Inputs (Images) -You can attach images or audio files to AI requests. +You can attach images files to AI requests. ```go // Image from file @@ -164,9 +161,9 @@ response, _ := agent.AI(ctx, "Describe this image", ai.WithImageFile("./photo.jpg"), ) -// Audio from URL -response, _ = agent.AI(ctx, "Transcribe this audio", - ai.WithAudioURL("https://example.com/audio.mp3"), +// Image from URL +response, _ = agent.AI(ctx, "Describe this image", + ai.WithImageURL("https://example.com/image.jpg"), ) // Image from bytes diff --git a/sdk/go/ai/client.go b/sdk/go/ai/client.go index 076dfad3..46f9a670 100644 --- a/sdk/go/ai/client.go +++ b/sdk/go/ai/client.go @@ -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, @@ -71,8 +76,8 @@ func (c *Client) CompleteWithMessages(ctx context.Context, messages []Message, o if err := opt(req); err != nil { return nil, fmt.Errorf("apply option: %w", err) } - } + } return c.doRequest(ctx, req) } @@ -155,7 +160,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, diff --git a/sdk/go/ai/client_test.go b/sdk/go/ai/client_test.go index d202c654..cbbbc1ac 100644 --- a/sdk/go/ai/client_test.go +++ b/sdk/go/ai/client_test.go @@ -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{ @@ -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", }, @@ -97,6 +104,7 @@ func TestComplete(t *testing.T) { TotalTokens: 15, }, } + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) })) @@ -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) @@ -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) })) @@ -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) })) @@ -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) })) @@ -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) diff --git a/sdk/go/ai/multimodal.go b/sdk/go/ai/multimodal.go index dd230b4e..aafece9d 100644 --- a/sdk/go/ai/multimodal.go +++ b/sdk/go/ai/multimodal.go @@ -2,16 +2,9 @@ package ai import "strings" -type Image struct { - URL string `json:"url,omitempty"` - Data string `json:"data,omitempty"` - MIMEType string `json:"mime_type,omitempty"` -} - -type Audio struct { - URL string `json:"url,omitempty"` - Data string `json:"data,omitempty"` - Format string `json:"format,omitempty"` +type ImageData struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` } func detectMIMEType(path string) string { @@ -28,20 +21,3 @@ func detectMIMEType(path string) string { return "application/octet-stream" } } - -func detectAudioFormat(path string) string { - switch { - case strings.HasSuffix(path, ".mp3"): - return "mp3" - case strings.HasSuffix(path, ".wav"): - return "wav" - case strings.HasSuffix(path, ".ogg"): - return "ogg" - case strings.HasSuffix(path, ".m4a"): - return "m4a" - case strings.HasSuffix(path, ".flac"): - return "flac" - default: - return "unknown" - } -} diff --git a/sdk/go/ai/request.go b/sdk/go/ai/request.go index 6b3c7b5f..1fcea611 100644 --- a/sdk/go/ai/request.go +++ b/sdk/go/ai/request.go @@ -9,10 +9,6 @@ import ( ) // Message represents a chat message. -type Message struct { - Role string `json:"role"` - Content string `json:"content"` -} // Request represents an AI completion request. type Request struct { @@ -37,9 +33,44 @@ type Request struct { // Response format for structured outputs ResponseFormat *ResponseFormat `json:"response_format,omitempty"` +} - Images []Image `json:"images,omitempty"` - Audios []Audio `json:"audios,omitempty"` +type Message struct { + Role string `json:"role"` + Content []ContentPart `json:"content"` +} + +type ContentPart struct { + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` + ImageURL *ImageData `json:"image_url,omitempty"` +} + +func (m *Message) UnmarshalJSON(data []byte) error { + type Alias Message + aux := &struct { + Content json.RawMessage `json:"content"` + *Alias + }{ + Alias: (*Alias)(m), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + var s string + if err := json.Unmarshal(aux.Content, &s); err == nil { + m.Content = []ContentPart{{Type: "text", Text: s}} + return nil + } + + var arr []ContentPart + if err := json.Unmarshal(aux.Content, &arr); err != nil { + return err + } + m.Content = arr + return nil } // ResponseFormat specifies the desired output format. @@ -61,7 +92,14 @@ type Option func(*Request) error // WithSystem adds a system message to the request. func WithSystem(content string) Option { return func(r *Request) error { - r.Messages = append([]Message{{Role: "system", Content: content}}, r.Messages...) + r.Messages = append([]Message{ + { + Role: "system", + Content: []ContentPart{ + {Type: "text", Text: content}, + }, + }, + }, r.Messages...) return nil } } @@ -161,86 +199,75 @@ func WithSchema(schema interface{}) Option { // Image options func WithImageFile(path string) Option { return func(r *Request) error { - // Read the file data, err := os.ReadFile(path) if err != nil { return fmt.Errorf("read image file: %w", err) } + mimeType := detectMIMEType(path) encoded := base64.StdEncoding.EncodeToString(data) - r.Images = append(r.Images, Image{ - Data: encoded, - MIMEType: detectMIMEType(path), - }) - return nil - } -} - -func WithImageURL(url string) Option { - return func(r *Request) error { - r.Images = append(r.Images, Image{ - URL: url, - }) - return nil - } -} - -func WithImageBytes(data []byte, mimeType string) Option { - return func(r *Request) error { - if len(data) == 0 { - return nil + if len(r.Messages) == 0 { + r.Messages = append(r.Messages, Message{ + Role: "user", + Content: []ContentPart{}, + }) } - encoded := base64.StdEncoding.EncodeToString(data) - - r.Images = append(r.Images, Image{ - Data: encoded, - MIMEType: mimeType, + last := &r.Messages[len(r.Messages)-1] + last.Content = append(last.Content, ContentPart{ + Type: "image_url", + ImageURL: &ImageData{ + URL: "data:" + mimeType + ";base64," + encoded, + Detail: "auto", + }, }) return nil } } -// Audio options -func WithAudioFile(path string) Option { +func WithImageURL(url string) Option { return func(r *Request) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("read audio file: %w", err) + if len(r.Messages) == 0 { + r.Messages = append(r.Messages, Message{ + Role: "user", + Content: []ContentPart{}, + }) } - - encoded := base64.StdEncoding.EncodeToString(data) - r.Audios = append(r.Audios, Audio{ - Data: encoded, - Format: detectAudioFormat(path), - }) - - return nil - } -} - -func WithAudioURL(url string) Option { - return func(r *Request) error { - r.Audios = append(r.Audios, Audio{ - URL: url, + last := &r.Messages[len(r.Messages)-1] + last.Content = append(last.Content, ContentPart{ + Type: "image_url", + ImageURL: &ImageData{ + URL: url, + Detail: "auto", + }, }) return nil } } -func WithAudioBytes(data []byte, format string) Option { +func WithImageBytes(data []byte, mimeType string) Option { return func(r *Request) error { if len(data) == 0 { return nil } - encoded := base64.StdEncoding.EncodeToString(data) - r.Audios = append(r.Audios, Audio{ - Data: encoded, - Format: format, + if len(r.Messages) == 0 { + r.Messages = append(r.Messages, Message{ + Role: "user", + Content: []ContentPart{}, + }) + } + + last := &r.Messages[len(r.Messages)-1] + last.Content = append(last.Content, ContentPart{ + Type: "image_url", + ImageURL: &ImageData{ + URL: "data:" + mimeType + ";base64," + encoded, + Detail: "auto", + }, }) return nil diff --git a/sdk/go/ai/request_test.go b/sdk/go/ai/request_test.go index 54577d08..d24962a3 100644 --- a/sdk/go/ai/request_test.go +++ b/sdk/go/ai/request_test.go @@ -12,16 +12,30 @@ import ( func TestWithSystem(t *testing.T) { req := &Request{ Messages: []Message{ - {Role: "user", Content: "Hello"}, + { + Role: "user", + Content: []ContentPart{ + {Type: "text", Text: "Hello"}, + }, + }, }, } err := WithSystem("You are a helpful assistant")(req) assert.NoError(t, err) assert.Len(t, req.Messages, 2) - assert.Equal(t, "system", req.Messages[0].Role) - assert.Equal(t, "You are a helpful assistant", req.Messages[0].Content) - assert.Equal(t, "user", req.Messages[1].Role) + + systemMsg := req.Messages[0] + assert.Equal(t, "system", systemMsg.Role) + assert.Len(t, systemMsg.Content, 1) + assert.Equal(t, "text", systemMsg.Content[0].Type) + assert.Equal(t, "You are a helpful assistant", systemMsg.Content[0].Text) + + userMsg := req.Messages[1] + assert.Equal(t, "user", userMsg.Role) + assert.Len(t, userMsg.Content, 1) + assert.Equal(t, "text", userMsg.Content[0].Type) + assert.Equal(t, "Hello", userMsg.Content[0].Text) } func TestWithModel(t *testing.T) { @@ -145,12 +159,10 @@ func TestWithSchema_InvalidType(t *testing.T) { } func TestWithImageFile(t *testing.T) { - // Create a temporary image file for testing tempFile, err := os.CreateTemp("", "test_image_*.jpg") assert.NoError(t, err) defer os.Remove(tempFile.Name()) - // Write dummy data to the file _, err = tempFile.Write([]byte{0xFF, 0xD8, 0xFF}) assert.NoError(t, err) tempFile.Close() @@ -159,9 +171,14 @@ func TestWithImageFile(t *testing.T) { err = WithImageFile(tempFile.Name())(req) assert.NoError(t, err) - assert.Len(t, req.Images, 1) - assert.NotEmpty(t, req.Images[0].Data) - assert.Equal(t, "image/jpeg", req.Images[0].MIMEType) + + assert.Len(t, req.Messages, 1) + assert.Len(t, req.Messages[0].Content, 1) + + part := req.Messages[0].Content[0] + assert.Equal(t, "image_url", part.Type) + assert.NotNil(t, part.ImageURL) + assert.Contains(t, part.ImageURL.URL, "data:image/jpeg;base64,") } func TestWithImageURL(t *testing.T) { @@ -171,10 +188,14 @@ func TestWithImageURL(t *testing.T) { err := WithImageURL(testURL)(req) assert.NoError(t, err) - assert.Len(t, req.Images, 1) - assert.Equal(t, testURL, req.Images[0].URL) - assert.Empty(t, req.Images[0].Data) - assert.Empty(t, req.Images[0].MIMEType) + + assert.Len(t, req.Messages, 1) + assert.Len(t, req.Messages[0].Content, 1) + + part := req.Messages[0].Content[0] + assert.Equal(t, "image_url", part.Type) + assert.NotNil(t, part.ImageURL) + assert.Equal(t, testURL, part.ImageURL.URL) } func TestWithImageBytes(t *testing.T) { @@ -185,9 +206,14 @@ func TestWithImageBytes(t *testing.T) { err := WithImageBytes(testBytes, testMIMEType)(req) assert.NoError(t, err) - assert.Len(t, req.Images, 1) - assert.NotEmpty(t, req.Images[0].Data) - assert.Equal(t, testMIMEType, req.Images[0].MIMEType) + + assert.Len(t, req.Messages, 1) + assert.Len(t, req.Messages[0].Content, 1) + + part := req.Messages[0].Content[0] + assert.Equal(t, "image_url", part.Type) + assert.NotNil(t, part.ImageURL) + assert.Contains(t, part.ImageURL.URL, "data:image/jpeg;base64,") } func TestWithImageFile_Error(t *testing.T) { @@ -196,7 +222,7 @@ func TestWithImageFile_Error(t *testing.T) { err := WithImageFile("non_existent_file.jpg")(req) assert.Error(t, err) - assert.Len(t, req.Images, 0) + assert.Len(t, req.Messages, 0) } func TestWithImageBytes_EmptyInput(t *testing.T) { @@ -205,18 +231,26 @@ func TestWithImageBytes_EmptyInput(t *testing.T) { err := WithImageBytes(nil, "")(req) assert.NoError(t, err) - assert.Len(t, req.Images, 0) + assert.Len(t, req.Messages, 0) } func TestMultipleImages(t *testing.T) { req := &Request{} + req.Messages = append(req.Messages, Message{ + Role: "user", + Content: []ContentPart{}, + }) + + // Image via URL err := WithImageURL("https://example.com/image1.jpg")(req) assert.NoError(t, err) + // Image via file tempFile, err := os.CreateTemp("", "test_image_*.jpg") assert.NoError(t, err) defer os.Remove(tempFile.Name()) + _, err = tempFile.Write([]byte{0xFF, 0xD8, 0xFF}) assert.NoError(t, err) tempFile.Close() @@ -228,82 +262,23 @@ func TestMultipleImages(t *testing.T) { err = WithImageBytes(testBytes, "image/png")(req) assert.NoError(t, err) - assert.Len(t, req.Images, 3) - assert.Equal(t, "https://example.com/image1.jpg", req.Images[0].URL) - assert.NotEmpty(t, req.Images[1].Data) - assert.Equal(t, "image/jpeg", req.Images[1].MIMEType) - assert.NotEmpty(t, req.Images[2].Data) - assert.Equal(t, "image/png", req.Images[2].MIMEType) -} + assert.Len(t, req.Messages, 1) + assert.Len(t, req.Messages[0].Content, 3) -func TestWithAudioFile(t *testing.T) { - // Create a temporary audio file for testing - tempFile, err := os.CreateTemp("", "test_audio_*.mp3") - if err != nil { - t.Fatalf("Failed to create temp file: %v", err) - } - defer os.Remove(tempFile.Name()) - - // Write dummy data to the file - _, err = tempFile.Write([]byte{0x01, 0x02, 0x03}) - if err != nil { - t.Fatalf("Failed to write to temp file: %v", err) - } - tempFile.Close() - - req := &Request{} - err = WithAudioFile(tempFile.Name())(req) - if err != nil { - t.Fatalf("WithAudioFile failed: %v", err) - } + part1 := req.Messages[0].Content[0] + assert.Equal(t, "image_url", part1.Type) + assert.NotNil(t, part1.ImageURL) + assert.Equal(t, "https://example.com/image1.jpg", part1.ImageURL.URL) - assert.Len(t, req.Audios, 1) - assert.NotEmpty(t, req.Audios[0].Data) - assert.Equal(t, "mp3", req.Audios[0].Format) -} - -func TestWithAudioURL(t *testing.T) { - req := &Request{} - testURL := "https://example.com/audio.mp3" + part2 := req.Messages[0].Content[1] + assert.Equal(t, "image_url", part2.Type) + assert.NotNil(t, part2.ImageURL) + assert.Contains(t, part2.ImageURL.URL, "data:image/jpeg;base64,") - err := WithAudioURL(testURL)(req) - - assert.NoError(t, err) - assert.Len(t, req.Audios, 1) - assert.Equal(t, testURL, req.Audios[0].URL) - assert.Empty(t, req.Audios[0].Data) - assert.Empty(t, req.Audios[0].Format) -} - -func TestWithAudioBytes(t *testing.T) { - req := &Request{} - testBytes := []byte{0x01, 0x02, 0x03} - testFormat := "mp3" - - err := WithAudioBytes(testBytes, testFormat)(req) - - assert.NoError(t, err) - assert.Len(t, req.Audios, 1) - assert.NotEmpty(t, req.Audios[0].Data) - assert.Equal(t, testFormat, req.Audios[0].Format) -} - -func TestWithAudioFile_Error(t *testing.T) { - req := &Request{} - - err := WithAudioFile("non_existent_file.mp3")(req) - - assert.Error(t, err) - assert.Len(t, req.Audios, 0) -} - -func TestWithAudioBytes_EmptyInput(t *testing.T) { - req := &Request{} - - err := WithAudioBytes(nil, "")(req) - - assert.NoError(t, err) - assert.Len(t, req.Audios, 0) + part3 := req.Messages[0].Content[2] + assert.Equal(t, "image_url", part3.Type) + assert.NotNil(t, part3.ImageURL) + assert.Contains(t, part3.ImageURL.URL, "data:image/png;base64,") } func TestStructToJSONSchema(t *testing.T) { @@ -412,7 +387,12 @@ func TestGoTypeToJSONType_WithPointer(t *testing.T) { func TestMultipleOptions(t *testing.T) { req := &Request{ Messages: []Message{ - {Role: "user", Content: "Hello"}, + { + Role: "user", + Content: []ContentPart{ + {Type: "text", Text: "Hello"}, + }, + }, }, } diff --git a/sdk/go/ai/response.go b/sdk/go/ai/response.go index 7e4e634d..045588a0 100644 --- a/sdk/go/ai/response.go +++ b/sdk/go/ai/response.go @@ -3,6 +3,7 @@ package ai import ( "encoding/json" "fmt" + "strings" ) // Response represents the API response from OpenAI/OpenRouter. @@ -65,10 +66,18 @@ type ErrorDetail struct { // Text returns the text content from the first choice. func (r *Response) Text() string { - if len(r.Choices) == 0 { + if len(r.Choices) == 0 || len(r.Choices[0].Message.Content) == 0 { return "" } - return r.Choices[0].Message.Content + + var sb strings.Builder + for _, part := range r.Choices[0].Message.Content { + if part.Type == "text" { + sb.WriteString(part.Text) + } + } + + return sb.String() } // JSON parses the response content as JSON into the provided destination. diff --git a/sdk/go/ai/response_test.go b/sdk/go/ai/response_test.go index f6998d99..1b1cda86 100644 --- a/sdk/go/ai/response_test.go +++ b/sdk/go/ai/response_test.go @@ -19,8 +19,10 @@ func TestResponse_Text(t *testing.T) { Choices: []Choice{ { Message: Message{ - Role: "assistant", - Content: "Hello, world!", + Role: "assistant", + Content: []ContentPart{ + {Type: "text", Text: "Hello, world!"}, + }, }, }, }, @@ -47,12 +49,16 @@ func TestResponse_Text(t *testing.T) { Choices: []Choice{ { Message: Message{ - Content: "First", + Content: []ContentPart{ + {Type: "text", Text: "First"}, + }, }, }, { Message: Message{ - Content: "Second", + Content: []ContentPart{ + {Type: "text", Text: "Second"}, + }, }, }, }, @@ -83,7 +89,9 @@ func TestResponse_JSON(t *testing.T) { Choices: []Choice{ { Message: Message{ - Content: `{"name":"John","age":30}`, + Content: []ContentPart{ + {Type: "text", Text: `{"name":"John","age":30}`}, + }, }, }, }, @@ -108,7 +116,9 @@ func TestResponse_JSON(t *testing.T) { Choices: []Choice{ { Message: Message{ - Content: "", + Content: []ContentPart{ + {Type: "text", Text: ""}, + }, }, }, }, @@ -122,7 +132,9 @@ func TestResponse_JSON(t *testing.T) { Choices: []Choice{ { Message: Message{ - Content: "not json", + Content: []ContentPart{ + {Type: "text", Text: "not json"}, + }, }, }, }, @@ -160,7 +172,9 @@ func TestResponse_Into(t *testing.T) { Choices: []Choice{ { Message: Message{ - Content: `{"value":42}`, + Content: []ContentPart{ + {Type: "text", Text: `{"value":42}`}, + }, }, }, }, @@ -258,8 +272,10 @@ func TestResponse_MarshalUnmarshal(t *testing.T) { { Index: 0, Message: Message{ - Role: "assistant", - Content: "Hello!", + Role: "assistant", + Content: []ContentPart{ + {Type: "text", Text: "Hello"}, + }, }, FinishReason: "stop", }, From be04ab9041bf140510db7e8870f61a137431fbe2 Mon Sep 17 00:00:00 2001 From: Ben G Date: Wed, 11 Feb 2026 23:39:42 +0100 Subject: [PATCH 4/5] mend --- scripts/install.ps1 | 399 ------------------------------------- sdk/go/agent/agent_test.go | 4 +- sdk/go/ai/client.go | 14 +- sdk/go/ai/config.go | 55 +++++ sdk/go/ai/multimodal.go | 5 - sdk/go/ai/request.go | 30 ++- sdk/go/ai/request_test.go | 30 ++- 7 files changed, 94 insertions(+), 443 deletions(-) delete mode 100644 scripts/install.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 deleted file mode 100644 index ae7c2536..00000000 --- a/scripts/install.ps1 +++ /dev/null @@ -1,399 +0,0 @@ -# AgentField CLI Installer for Windows -# Usage: iwr -useb https://agentfield.ai/install.ps1 | iex -# Version pinning: $env:VERSION="v1.0.0"; iwr -useb https://agentfield.ai/install.ps1 | iex - -$ErrorActionPreference = "Stop" - -# Configuration -$Repo = "Agent-Field/agentfield" -$InstallDir = if ($env:AGENTFIELD_INSTALL_DIR) { $env:AGENTFIELD_INSTALL_DIR } else { "$env:USERPROFILE\.agentfield\bin" } -$Version = if ($env:VERSION) { $env:VERSION } else { "latest" } -$Verbose = if ($env:VERBOSE -eq "1") { $true } else { $false } -$SkipPathConfig = if ($env:SKIP_PATH_CONFIG -eq "1") { $true } else { $false } - -# Colors -function Write-ColorOutput { - param( - [Parameter(Mandatory=$true)] - [string]$Message, - [string]$Color = "White", - [string]$Prefix = "" - ) - - if ($Prefix) { - Write-Host -NoNewline "[$Prefix] " -ForegroundColor $Color - Write-Host $Message - } else { - Write-Host $Message -ForegroundColor $Color - } -} - -function Write-Info { - param([string]$Message) - Write-ColorOutput -Message $Message -Color "Cyan" -Prefix "INFO" -} - -function Write-Success { - param([string]$Message) - Write-ColorOutput -Message $Message -Color "Green" -Prefix "SUCCESS" -} - -function Write-Error { - param([string]$Message) - Write-ColorOutput -Message $Message -Color "Red" -Prefix "ERROR" -} - -function Write-Warning { - param([string]$Message) - Write-ColorOutput -Message $Message -Color "Yellow" -Prefix "WARNING" -} - -function Write-Verbose { - param([string]$Message) - if ($Verbose) { - Write-ColorOutput -Message $Message -Color "DarkCyan" -Prefix "VERBOSE" - } -} - -function Write-Banner { - Write-Host "" - Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan - Write-Host "║ AgentField CLI Installer (Windows) ║" -ForegroundColor Cyan - Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan - Write-Host "" -} - -# Detect architecture -function Get-Architecture { - $arch = $env:PROCESSOR_ARCHITECTURE - - switch ($arch) { - "AMD64" { return "amd64" } - "ARM64" { return "arm64" } - default { - Write-Error "Unsupported architecture: $arch" - Write-Info "Supported architectures:" - Write-Info " - AMD64 (x86_64)" - Write-Info " - ARM64" - Write-Info "" - Write-Info "Please open an issue: https://github.com/$Repo/issues" - exit 1 - } - } -} - -# Get latest version from GitHub API -function Get-LatestVersion { - Write-Verbose "Fetching latest version from GitHub API..." - - $latestUrl = "https://api.github.com/repos/$Repo/releases/latest" - - try { - $response = Invoke-RestMethod -Uri $latestUrl -Method Get - $version = $response.tag_name - - if ([string]::IsNullOrEmpty($version)) { - throw "Failed to parse version from API response" - } - - return $version - } - catch { - Write-Error "Failed to determine latest version from GitHub API" - Write-Info "You can manually specify a version: `$env:VERSION=`"v1.0.0`"; .\install.ps1" - Write-Error $_.Exception.Message - exit 1 - } -} - -# Download file -function Download-File { - param( - [string]$Url, - [string]$OutputPath - ) - - Write-Verbose "Downloading: $Url" - Write-Verbose "To: $OutputPath" - - try { - $ProgressPreference = if ($Verbose) { "Continue" } else { "SilentlyContinue" } - Invoke-WebRequest -Uri $Url -OutFile $OutputPath -UseBasicParsing - } - catch { - Write-Error "Download failed: $Url" - Write-Error $_.Exception.Message - exit 1 - } - - if (-not (Test-Path $OutputPath)) { - Write-Error "Download failed: file not created at $OutputPath" - exit 1 - } -} - -# Verify checksum -function Test-Checksum { - param( - [string]$BinaryPath, - [string]$ChecksumsFile, - [string]$BinaryName - ) - - Write-Info "Verifying checksum..." - Write-Verbose "Binary: $BinaryPath" - Write-Verbose "Checksums file: $ChecksumsFile" - Write-Verbose "Binary name: $BinaryName" - - # Read checksums file - $checksumContent = Get-Content $ChecksumsFile -Raw - $checksumLines = $checksumContent -split "`n" - - # Find the line with our binary - $checksumLine = $checksumLines | Where-Object { $_ -match [regex]::Escape($BinaryName) } - - if ([string]::IsNullOrEmpty($checksumLine)) { - Write-Error "Could not find checksum for $BinaryName in checksums file" - Write-Verbose "Checksums file content:" - if ($Verbose) { - Write-Host $checksumContent - } - exit 1 - } - - # Extract expected checksum (format: "hash filename") - $expectedChecksum = ($checksumLine -split '\s+')[0] - - Write-Verbose "Expected checksum: $expectedChecksum" - - # Calculate actual checksum - $actualChecksum = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower() - - Write-Verbose "Actual checksum: $actualChecksum" - - if ($actualChecksum -ne $expectedChecksum) { - Write-Error "Checksum verification failed!" - Write-Error "Expected: $expectedChecksum" - Write-Error "Got: $actualChecksum" - Write-Error "" - Write-Error "This may indicate a corrupted download or security issue." - Write-Info "Please try again or report this issue:" - Write-Info " https://github.com/$Repo/issues" - exit 1 - } - - Write-Success "Checksum verified" -} - -# Install binary -function Install-Binary { - param( - [string]$BinaryPath, - [string]$InstallDir - ) - - Write-Info "Installing to $InstallDir" - - # Create install directory - if (-not (Test-Path $InstallDir)) { - New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null - } - - # Copy binary - $targetPath = Join-Path $InstallDir "agentfield.exe" - Copy-Item -Path $BinaryPath -Destination $targetPath -Force - - # Create af.exe alias for convenience (prefer hardlink, fallback to copy) - $afPath = Join-Path $InstallDir "af.exe" - $aliasCreated = $false - $aliasMethod = "" - - try { - if (Test-Path $afPath) { - Remove-Item -Path $afPath -Force - } - New-Item -ItemType HardLink -Path $afPath -Target $targetPath -Force | Out-Null - $aliasCreated = $true - $aliasMethod = "hardlink" - Write-Verbose "Created hardlink: af.exe -> agentfield.exe" - } - catch { - Write-Verbose "Hardlink creation failed, falling back to copy: $($_.Exception.Message)" - try { - Copy-Item -Path $targetPath -Destination $afPath -Force - $aliasCreated = $true - $aliasMethod = "copy" - Write-Verbose "Created copy: af.exe" - } - catch { - Write-Warning "Failed to create af.exe alias: $($_.Exception.Message)" - } - } - - Write-Success "Binary installed to $targetPath" - if ($aliasCreated) { - Write-Success "Alias created ($aliasMethod): $afPath" - } - else { - Write-Info "Alias not created; run agentfield using $targetPath or create your own shortcut." - } -} - -# Configure PATH -function Set-PathConfiguration { - param( - [string]$InstallDir - ) - - if ($SkipPathConfig) { - Write-Info "Skipping PATH configuration (SKIP_PATH_CONFIG=1)" - return - } - - Write-Info "Configuring PATH..." - - # Get current user PATH - $userPath = [Environment]::GetEnvironmentVariable("Path", "User") - - # Check if already in PATH - if ($userPath -like "*$InstallDir*") { - Write-Info "PATH already configured" - return - } - - # Add to PATH - $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } - [Environment]::SetEnvironmentVariable("Path", $newPath, "User") - - # Update PATH for current session - $env:Path = "$env:Path;$InstallDir" - - Write-Success "PATH configured" - Write-Info "" - Write-Info "PATH has been updated for future sessions." - Write-Info "For this session, restart your terminal or run:" - Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path', 'User') + ';' + [Environment]::GetEnvironmentVariable('Path', 'Machine')" -ForegroundColor Cyan -} - -# Verify installation -function Test-Installation { - param( - [string]$InstallDir - ) - - Write-Info "Verifying installation..." - - $binaryPath = Join-Path $InstallDir "agentfield.exe" - - if (Test-Path $binaryPath) { - Write-Success "Installation verified" - - # Try to get version - try { - $versionOutput = & $binaryPath --version 2>&1 - Write-Verbose "Version output: $versionOutput" - } - catch { - # Ignore version check errors - } - } - else { - Write-Error "Installation verification failed" - Write-Error "Binary not found: $binaryPath" - exit 1 - } -} - -# Print success message -function Write-SuccessMessage { - Write-Host "" - Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Green - Write-Host "║ AgentField CLI installed successfully! ║" -ForegroundColor Green - Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Green - Write-Host "" - Write-Host "Next steps:" -ForegroundColor White - Write-Host "" - Write-Host " 1. Restart your terminal or refresh PATH:" -ForegroundColor White - Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path', 'User') + ';' + [Environment]::GetEnvironmentVariable('Path', 'Machine')" -ForegroundColor Cyan - Write-Host "" - Write-Host " 2. Verify installation:" -ForegroundColor White - Write-Host " agentfield --version" -ForegroundColor Cyan - Write-Host "" - Write-Host " 3. Initialize your first agent:" -ForegroundColor White - Write-Host " agentfield init my-agent" -ForegroundColor Cyan - Write-Host "" - Write-Host "Resources:" -ForegroundColor White - Write-Host " Documentation: https://agentfield.ai/docs" -ForegroundColor Blue - Write-Host " GitHub: https://github.com/$Repo" -ForegroundColor Blue - Write-Host " Support: https://github.com/$Repo/issues" -ForegroundColor Blue - Write-Host "" -} - -# Main installation flow -function Main { - Write-Banner - - # Detect platform - $arch = Get-Architecture - $os = "windows" - - Write-Info "Detected platform: $os-$arch" - - # Determine version - if ($Version -eq "latest") { - $script:Version = Get-LatestVersion - } - - Write-Info "Installing version: $Version" - - # Construct binary name and URL - $binaryName = "agentfield-$os-$arch.exe" - $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$binaryName" - $checksumsUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" - - Write-Verbose "Binary name: $binaryName" - Write-Verbose "Download URL: $downloadUrl" - Write-Verbose "Checksums URL: $checksumsUrl" - - # Create temporary directory - $tempDir = Join-Path $env:TEMP "agentfield-install-$(Get-Random)" - New-Item -ItemType Directory -Path $tempDir -Force | Out-Null - - try { - # Download binary - Write-Info "Downloading binary..." - $binaryPath = Join-Path $tempDir $binaryName - Download-File -Url $downloadUrl -OutputPath $binaryPath - Write-Success "Binary downloaded" - - # Download checksums - Write-Info "Downloading checksums..." - $checksumsPath = Join-Path $tempDir "checksums.txt" - Download-File -Url $checksumsUrl -OutputPath $checksumsPath - Write-Success "Checksums downloaded" - - # Verify checksum - Test-Checksum -BinaryPath $binaryPath -ChecksumsFile $checksumsPath -BinaryName $binaryName - - # Install binary - Install-Binary -BinaryPath $binaryPath -InstallDir $InstallDir - - # Configure PATH - Set-PathConfiguration -InstallDir $InstallDir - - # Verify installation - Test-Installation -InstallDir $InstallDir - - # Print success message - Write-SuccessMessage - } - finally { - # Cleanup temporary directory - if (Test-Path $tempDir) { - Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - } -} - -# Run main function -Main diff --git a/sdk/go/agent/agent_test.go b/sdk/go/agent/agent_test.go index 354f5ded..aa14c0c2 100644 --- a/sdk/go/agent/agent_test.go +++ b/sdk/go/agent/agent_test.go @@ -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"}, + }, }, }, }, diff --git a/sdk/go/ai/client.go b/sdk/go/ai/client.go index 46f9a670..76764dc4 100644 --- a/sdk/go/ai/client.go +++ b/sdk/go/ai/client.go @@ -76,14 +76,24 @@ func (c *Client) CompleteWithMessages(ctx context.Context, messages []Message, o if err := opt(req); err != nil { return nil, fmt.Errorf("apply option: %w", err) } - } + return c.doRequest(ctx, req) } 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) } diff --git a/sdk/go/ai/config.go b/sdk/go/ai/config.go index 73328fb3..b96649a2 100644 --- a/sdk/go/ai/config.go +++ b/sdk/go/ai/config.go @@ -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, + } +} diff --git a/sdk/go/ai/multimodal.go b/sdk/go/ai/multimodal.go index aafece9d..1699db7d 100644 --- a/sdk/go/ai/multimodal.go +++ b/sdk/go/ai/multimodal.go @@ -2,11 +2,6 @@ package ai import "strings" -type ImageData struct { - URL string `json:"url"` - Detail string `json:"detail,omitempty"` -} - func detectMIMEType(path string) string { switch { case strings.HasSuffix(path, ".png"): diff --git a/sdk/go/ai/request.go b/sdk/go/ai/request.go index 1fcea611..c56a645c 100644 --- a/sdk/go/ai/request.go +++ b/sdk/go/ai/request.go @@ -41,9 +41,9 @@ type Message struct { } type ContentPart struct { - Type string `json:"type"` // "text" or "image_url" - Text string `json:"text,omitempty"` - ImageURL *ImageData `json:"image_url,omitempty"` + Type string `json:"type"` // "text" or "image_url" + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` } func (m *Message) UnmarshalJSON(data []byte) error { @@ -216,11 +216,8 @@ func WithImageFile(path string) Option { last := &r.Messages[len(r.Messages)-1] last.Content = append(last.Content, ContentPart{ - Type: "image_url", - ImageURL: &ImageData{ - URL: "data:" + mimeType + ";base64," + encoded, - Detail: "auto", - }, + Type: "input_image", + ImageURL: "data:" + mimeType + ";base64," + encoded, }) return nil @@ -235,14 +232,13 @@ func WithImageURL(url string) Option { Content: []ContentPart{}, }) } + last := &r.Messages[len(r.Messages)-1] last.Content = append(last.Content, ContentPart{ - Type: "image_url", - ImageURL: &ImageData{ - URL: url, - Detail: "auto", - }, + Type: "input_image", + ImageURL: url, }) + return nil } } @@ -252,6 +248,7 @@ func WithImageBytes(data []byte, mimeType string) Option { if len(data) == 0 { return nil } + encoded := base64.StdEncoding.EncodeToString(data) if len(r.Messages) == 0 { @@ -263,11 +260,8 @@ func WithImageBytes(data []byte, mimeType string) Option { last := &r.Messages[len(r.Messages)-1] last.Content = append(last.Content, ContentPart{ - Type: "image_url", - ImageURL: &ImageData{ - URL: "data:" + mimeType + ";base64," + encoded, - Detail: "auto", - }, + Type: "input_image", + ImageURL: "data:" + mimeType + ";base64," + encoded, }) return nil diff --git a/sdk/go/ai/request_test.go b/sdk/go/ai/request_test.go index d24962a3..5c691bf7 100644 --- a/sdk/go/ai/request_test.go +++ b/sdk/go/ai/request_test.go @@ -176,9 +176,8 @@ func TestWithImageFile(t *testing.T) { assert.Len(t, req.Messages[0].Content, 1) part := req.Messages[0].Content[0] - assert.Equal(t, "image_url", part.Type) - assert.NotNil(t, part.ImageURL) - assert.Contains(t, part.ImageURL.URL, "data:image/jpeg;base64,") + assert.Equal(t, "input_image", part.Type) + assert.Contains(t, part.ImageURL, "data:image/jpeg;base64,") } func TestWithImageURL(t *testing.T) { @@ -193,9 +192,8 @@ func TestWithImageURL(t *testing.T) { assert.Len(t, req.Messages[0].Content, 1) part := req.Messages[0].Content[0] - assert.Equal(t, "image_url", part.Type) - assert.NotNil(t, part.ImageURL) - assert.Equal(t, testURL, part.ImageURL.URL) + assert.Equal(t, "input_image", part.Type) + assert.Equal(t, testURL, part.ImageURL) } func TestWithImageBytes(t *testing.T) { @@ -211,9 +209,8 @@ func TestWithImageBytes(t *testing.T) { assert.Len(t, req.Messages[0].Content, 1) part := req.Messages[0].Content[0] - assert.Equal(t, "image_url", part.Type) - assert.NotNil(t, part.ImageURL) - assert.Contains(t, part.ImageURL.URL, "data:image/jpeg;base64,") + assert.Equal(t, "input_image", part.Type) + assert.Contains(t, part.ImageURL, "data:image/jpeg;base64,") } func TestWithImageFile_Error(t *testing.T) { @@ -266,19 +263,16 @@ func TestMultipleImages(t *testing.T) { assert.Len(t, req.Messages[0].Content, 3) part1 := req.Messages[0].Content[0] - assert.Equal(t, "image_url", part1.Type) - assert.NotNil(t, part1.ImageURL) - assert.Equal(t, "https://example.com/image1.jpg", part1.ImageURL.URL) + assert.Equal(t, "input_image", part1.Type) + assert.Equal(t, "https://example.com/image1.jpg", part1.ImageURL) part2 := req.Messages[0].Content[1] - assert.Equal(t, "image_url", part2.Type) - assert.NotNil(t, part2.ImageURL) - assert.Contains(t, part2.ImageURL.URL, "data:image/jpeg;base64,") + assert.Equal(t, "input_image", part2.Type) + assert.Contains(t, part2.ImageURL, "data:image/jpeg;base64,") part3 := req.Messages[0].Content[2] - assert.Equal(t, "image_url", part3.Type) - assert.NotNil(t, part3.ImageURL) - assert.Contains(t, part3.ImageURL.URL, "data:image/png;base64,") + assert.Equal(t, "input_image", part3.Type) + assert.Contains(t, part3.ImageURL, "data:image/png;base64,") } func TestStructToJSONSchema(t *testing.T) { From 0593400678af41eb43390a00c80ce553801d8531 Mon Sep 17 00:00:00 2001 From: Ben G Date: Wed, 11 Feb 2026 23:46:47 +0100 Subject: [PATCH 5/5] mend --- scripts/install.ps1 | 399 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 scripts/install.ps1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..ae7c2536 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,399 @@ +# AgentField CLI Installer for Windows +# Usage: iwr -useb https://agentfield.ai/install.ps1 | iex +# Version pinning: $env:VERSION="v1.0.0"; iwr -useb https://agentfield.ai/install.ps1 | iex + +$ErrorActionPreference = "Stop" + +# Configuration +$Repo = "Agent-Field/agentfield" +$InstallDir = if ($env:AGENTFIELD_INSTALL_DIR) { $env:AGENTFIELD_INSTALL_DIR } else { "$env:USERPROFILE\.agentfield\bin" } +$Version = if ($env:VERSION) { $env:VERSION } else { "latest" } +$Verbose = if ($env:VERBOSE -eq "1") { $true } else { $false } +$SkipPathConfig = if ($env:SKIP_PATH_CONFIG -eq "1") { $true } else { $false } + +# Colors +function Write-ColorOutput { + param( + [Parameter(Mandatory=$true)] + [string]$Message, + [string]$Color = "White", + [string]$Prefix = "" + ) + + if ($Prefix) { + Write-Host -NoNewline "[$Prefix] " -ForegroundColor $Color + Write-Host $Message + } else { + Write-Host $Message -ForegroundColor $Color + } +} + +function Write-Info { + param([string]$Message) + Write-ColorOutput -Message $Message -Color "Cyan" -Prefix "INFO" +} + +function Write-Success { + param([string]$Message) + Write-ColorOutput -Message $Message -Color "Green" -Prefix "SUCCESS" +} + +function Write-Error { + param([string]$Message) + Write-ColorOutput -Message $Message -Color "Red" -Prefix "ERROR" +} + +function Write-Warning { + param([string]$Message) + Write-ColorOutput -Message $Message -Color "Yellow" -Prefix "WARNING" +} + +function Write-Verbose { + param([string]$Message) + if ($Verbose) { + Write-ColorOutput -Message $Message -Color "DarkCyan" -Prefix "VERBOSE" + } +} + +function Write-Banner { + Write-Host "" + Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan + Write-Host "║ AgentField CLI Installer (Windows) ║" -ForegroundColor Cyan + Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan + Write-Host "" +} + +# Detect architecture +function Get-Architecture { + $arch = $env:PROCESSOR_ARCHITECTURE + + switch ($arch) { + "AMD64" { return "amd64" } + "ARM64" { return "arm64" } + default { + Write-Error "Unsupported architecture: $arch" + Write-Info "Supported architectures:" + Write-Info " - AMD64 (x86_64)" + Write-Info " - ARM64" + Write-Info "" + Write-Info "Please open an issue: https://github.com/$Repo/issues" + exit 1 + } + } +} + +# Get latest version from GitHub API +function Get-LatestVersion { + Write-Verbose "Fetching latest version from GitHub API..." + + $latestUrl = "https://api.github.com/repos/$Repo/releases/latest" + + try { + $response = Invoke-RestMethod -Uri $latestUrl -Method Get + $version = $response.tag_name + + if ([string]::IsNullOrEmpty($version)) { + throw "Failed to parse version from API response" + } + + return $version + } + catch { + Write-Error "Failed to determine latest version from GitHub API" + Write-Info "You can manually specify a version: `$env:VERSION=`"v1.0.0`"; .\install.ps1" + Write-Error $_.Exception.Message + exit 1 + } +} + +# Download file +function Download-File { + param( + [string]$Url, + [string]$OutputPath + ) + + Write-Verbose "Downloading: $Url" + Write-Verbose "To: $OutputPath" + + try { + $ProgressPreference = if ($Verbose) { "Continue" } else { "SilentlyContinue" } + Invoke-WebRequest -Uri $Url -OutFile $OutputPath -UseBasicParsing + } + catch { + Write-Error "Download failed: $Url" + Write-Error $_.Exception.Message + exit 1 + } + + if (-not (Test-Path $OutputPath)) { + Write-Error "Download failed: file not created at $OutputPath" + exit 1 + } +} + +# Verify checksum +function Test-Checksum { + param( + [string]$BinaryPath, + [string]$ChecksumsFile, + [string]$BinaryName + ) + + Write-Info "Verifying checksum..." + Write-Verbose "Binary: $BinaryPath" + Write-Verbose "Checksums file: $ChecksumsFile" + Write-Verbose "Binary name: $BinaryName" + + # Read checksums file + $checksumContent = Get-Content $ChecksumsFile -Raw + $checksumLines = $checksumContent -split "`n" + + # Find the line with our binary + $checksumLine = $checksumLines | Where-Object { $_ -match [regex]::Escape($BinaryName) } + + if ([string]::IsNullOrEmpty($checksumLine)) { + Write-Error "Could not find checksum for $BinaryName in checksums file" + Write-Verbose "Checksums file content:" + if ($Verbose) { + Write-Host $checksumContent + } + exit 1 + } + + # Extract expected checksum (format: "hash filename") + $expectedChecksum = ($checksumLine -split '\s+')[0] + + Write-Verbose "Expected checksum: $expectedChecksum" + + # Calculate actual checksum + $actualChecksum = (Get-FileHash -Path $BinaryPath -Algorithm SHA256).Hash.ToLower() + + Write-Verbose "Actual checksum: $actualChecksum" + + if ($actualChecksum -ne $expectedChecksum) { + Write-Error "Checksum verification failed!" + Write-Error "Expected: $expectedChecksum" + Write-Error "Got: $actualChecksum" + Write-Error "" + Write-Error "This may indicate a corrupted download or security issue." + Write-Info "Please try again or report this issue:" + Write-Info " https://github.com/$Repo/issues" + exit 1 + } + + Write-Success "Checksum verified" +} + +# Install binary +function Install-Binary { + param( + [string]$BinaryPath, + [string]$InstallDir + ) + + Write-Info "Installing to $InstallDir" + + # Create install directory + if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + } + + # Copy binary + $targetPath = Join-Path $InstallDir "agentfield.exe" + Copy-Item -Path $BinaryPath -Destination $targetPath -Force + + # Create af.exe alias for convenience (prefer hardlink, fallback to copy) + $afPath = Join-Path $InstallDir "af.exe" + $aliasCreated = $false + $aliasMethod = "" + + try { + if (Test-Path $afPath) { + Remove-Item -Path $afPath -Force + } + New-Item -ItemType HardLink -Path $afPath -Target $targetPath -Force | Out-Null + $aliasCreated = $true + $aliasMethod = "hardlink" + Write-Verbose "Created hardlink: af.exe -> agentfield.exe" + } + catch { + Write-Verbose "Hardlink creation failed, falling back to copy: $($_.Exception.Message)" + try { + Copy-Item -Path $targetPath -Destination $afPath -Force + $aliasCreated = $true + $aliasMethod = "copy" + Write-Verbose "Created copy: af.exe" + } + catch { + Write-Warning "Failed to create af.exe alias: $($_.Exception.Message)" + } + } + + Write-Success "Binary installed to $targetPath" + if ($aliasCreated) { + Write-Success "Alias created ($aliasMethod): $afPath" + } + else { + Write-Info "Alias not created; run agentfield using $targetPath or create your own shortcut." + } +} + +# Configure PATH +function Set-PathConfiguration { + param( + [string]$InstallDir + ) + + if ($SkipPathConfig) { + Write-Info "Skipping PATH configuration (SKIP_PATH_CONFIG=1)" + return + } + + Write-Info "Configuring PATH..." + + # Get current user PATH + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + + # Check if already in PATH + if ($userPath -like "*$InstallDir*") { + Write-Info "PATH already configured" + return + } + + # Add to PATH + $newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir } + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + + # Update PATH for current session + $env:Path = "$env:Path;$InstallDir" + + Write-Success "PATH configured" + Write-Info "" + Write-Info "PATH has been updated for future sessions." + Write-Info "For this session, restart your terminal or run:" + Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path', 'User') + ';' + [Environment]::GetEnvironmentVariable('Path', 'Machine')" -ForegroundColor Cyan +} + +# Verify installation +function Test-Installation { + param( + [string]$InstallDir + ) + + Write-Info "Verifying installation..." + + $binaryPath = Join-Path $InstallDir "agentfield.exe" + + if (Test-Path $binaryPath) { + Write-Success "Installation verified" + + # Try to get version + try { + $versionOutput = & $binaryPath --version 2>&1 + Write-Verbose "Version output: $versionOutput" + } + catch { + # Ignore version check errors + } + } + else { + Write-Error "Installation verification failed" + Write-Error "Binary not found: $binaryPath" + exit 1 + } +} + +# Print success message +function Write-SuccessMessage { + Write-Host "" + Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Green + Write-Host "║ AgentField CLI installed successfully! ║" -ForegroundColor Green + Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Green + Write-Host "" + Write-Host "Next steps:" -ForegroundColor White + Write-Host "" + Write-Host " 1. Restart your terminal or refresh PATH:" -ForegroundColor White + Write-Host " `$env:Path = [Environment]::GetEnvironmentVariable('Path', 'User') + ';' + [Environment]::GetEnvironmentVariable('Path', 'Machine')" -ForegroundColor Cyan + Write-Host "" + Write-Host " 2. Verify installation:" -ForegroundColor White + Write-Host " agentfield --version" -ForegroundColor Cyan + Write-Host "" + Write-Host " 3. Initialize your first agent:" -ForegroundColor White + Write-Host " agentfield init my-agent" -ForegroundColor Cyan + Write-Host "" + Write-Host "Resources:" -ForegroundColor White + Write-Host " Documentation: https://agentfield.ai/docs" -ForegroundColor Blue + Write-Host " GitHub: https://github.com/$Repo" -ForegroundColor Blue + Write-Host " Support: https://github.com/$Repo/issues" -ForegroundColor Blue + Write-Host "" +} + +# Main installation flow +function Main { + Write-Banner + + # Detect platform + $arch = Get-Architecture + $os = "windows" + + Write-Info "Detected platform: $os-$arch" + + # Determine version + if ($Version -eq "latest") { + $script:Version = Get-LatestVersion + } + + Write-Info "Installing version: $Version" + + # Construct binary name and URL + $binaryName = "agentfield-$os-$arch.exe" + $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$binaryName" + $checksumsUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" + + Write-Verbose "Binary name: $binaryName" + Write-Verbose "Download URL: $downloadUrl" + Write-Verbose "Checksums URL: $checksumsUrl" + + # Create temporary directory + $tempDir = Join-Path $env:TEMP "agentfield-install-$(Get-Random)" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + # Download binary + Write-Info "Downloading binary..." + $binaryPath = Join-Path $tempDir $binaryName + Download-File -Url $downloadUrl -OutputPath $binaryPath + Write-Success "Binary downloaded" + + # Download checksums + Write-Info "Downloading checksums..." + $checksumsPath = Join-Path $tempDir "checksums.txt" + Download-File -Url $checksumsUrl -OutputPath $checksumsPath + Write-Success "Checksums downloaded" + + # Verify checksum + Test-Checksum -BinaryPath $binaryPath -ChecksumsFile $checksumsPath -BinaryName $binaryName + + # Install binary + Install-Binary -BinaryPath $binaryPath -InstallDir $InstallDir + + # Configure PATH + Set-PathConfiguration -InstallDir $InstallDir + + # Verify installation + Test-Installation -InstallDir $InstallDir + + # Print success message + Write-SuccessMessage + } + finally { + # Cleanup temporary directory + if (Test-Path $tempDir) { + Remove-Item -Path $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + } +} + +# Run main function +Main