Files
hanzoai_agent/sdk/go/ai/client_test.go
zeekay 2899aeaa9f feat: merge agents control plane + SDKs into unified agent repo
Consolidate all Hanzo agent infrastructure into one repo:
- control-plane/: Go control plane server (was agents-new)
- sdk/: Go, Python, TypeScript SDKs
- deployments/: Docker, Kubernetes, Helm configs
- Dockerfile.sdk: Python SDK image (ghcr.io/hanzoai/agent:latest)
- CI builds both SDK + control-plane images

No more agentfield — everything is hanzoai/agent.
2026-02-13 12:24:31 -08:00

535 lines
13 KiB
Go

package ai
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewClient(t *testing.T) {
tests := []struct {
name string
config *Config
wantErr bool
}{
{
name: "valid config",
config: &Config{
APIKey: "test-key",
BaseURL: "https://api.example.com/v1",
Model: "gpt-4o",
},
wantErr: false,
},
{
name: "nil config uses default",
config: nil,
wantErr: true, // DefaultConfig may not have API key set
},
{
name: "invalid config",
config: &Config{
APIKey: "",
BaseURL: "https://api.example.com/v1",
Model: "gpt-4o",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewClient(tt.config)
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, client)
} else {
assert.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, tt.config, client.config)
}
})
}
}
func TestComplete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Contains(t, r.URL.Path, "/chat/completions")
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
assert.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
// Verify request body
var req Request
err := json.NewDecoder(r.Body).Decode(&req)
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)
// Send response
resp := Response{
ID: "chatcmpl-123",
Object: "chat.completion",
Created: 1234567890,
Model: "gpt-4o",
Choices: []Choice{
{
Index: 0,
Message: Message{
Role: "assistant",
Content: "Hello! How can I help you?",
},
FinishReason: "stop",
},
},
Usage: &Usage{
PromptTokens: 5,
CompletionTokens: 10,
TotalTokens: 15,
},
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
config := &Config{
APIKey: "test-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
resp, err := client.Complete(context.Background(), "Hello")
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, "Hello! How can I help you?", resp.Text())
assert.Len(t, resp.Choices, 1)
}
func TestComplete_WithAPIKeyOverride(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Bearer override-key", r.Header.Get("Authorization"))
resp := Response{
Choices: []Choice{
{Message: Message{Content: "ok"}},
},
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
config := &Config{
APIKey: "default-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
resp, err := client.Complete(context.Background(), "Hello", WithAPIKey("override-key"))
assert.NoError(t, err)
assert.NotNil(t, resp)
}
func TestComplete_WithOptions(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req Request
json.NewDecoder(r.Body).Decode(&req)
// Verify options were applied
assert.Equal(t, "gpt-3.5-turbo", req.Model)
assert.NotNil(t, req.Temperature)
assert.Equal(t, 0.9, *req.Temperature)
assert.NotNil(t, req.MaxTokens)
assert.Equal(t, 500, *req.MaxTokens)
assert.Len(t, req.Messages, 2) // system + user
assert.Equal(t, "system", req.Messages[0].Role)
resp := Response{
Choices: []Choice{
{Message: Message{Content: "Response"}},
},
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
config := &Config{
APIKey: "test-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
resp, err := client.Complete(
context.Background(),
"Hello",
WithSystem("You are helpful"),
WithModel("gpt-3.5-turbo"),
WithTemperature(0.9),
WithMaxTokens(500),
)
assert.NoError(t, err)
assert.NotNil(t, resp)
}
func TestComplete_WithOpenRouterHeaders(t *testing.T) {
var receivedHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedHeaders = r.Header
resp := Response{
Choices: []Choice{
{Message: Message{Content: "Response"}},
},
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
// Create config with OpenRouter URL format
config := &Config{
APIKey: "test-key",
BaseURL: "https://openrouter.ai/api/v1", // This triggers IsOpenRouter()
Model: "openrouter/gpt-4o",
SiteURL: "https://example.com",
SiteName: "MyApp",
}
client, err := NewClient(config)
require.NoError(t, err)
// Override BaseURL to point to test server for the actual HTTP call
// but keep the OpenRouter detection working
originalBaseURL := client.config.BaseURL
client.config.BaseURL = server.URL
resp, err := client.Complete(context.Background(), "Hello")
assert.NoError(t, err)
assert.NotNil(t, resp)
// Verify headers were set (note: IsOpenRouter() checks the original config, not the overridden one)
// So we need to restore and check the logic differently
client.config.BaseURL = originalBaseURL
// The headers would be set if IsOpenRouter() returns true
// Since we're using a test server, we verify the request succeeded
// In a real scenario with OpenRouter URL, the headers would be present
assert.NotNil(t, receivedHeaders)
}
func TestComplete_ErrorHandling(t *testing.T) {
tests := []struct {
name string
serverResponse func(w http.ResponseWriter, r *http.Request)
wantErr bool
checkError func(t *testing.T, err error)
}{
{
name: "API error response",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
errResp := ErrorResponse{
Error: ErrorDetail{
Message: "Invalid API key",
Type: "invalid_request_error",
},
}
json.NewEncoder(w).Encode(errResp)
},
wantErr: true,
checkError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "Invalid API key")
},
},
{
name: "non-JSON error response",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal Server Error"))
},
wantErr: true,
checkError: func(t *testing.T, err error) {
assert.Contains(t, err.Error(), "500")
},
},
{
name: "network error",
serverResponse: func(w http.ResponseWriter, r *http.Request) {
// Close connection immediately
hj, ok := w.(http.Hijacker)
if ok {
conn, _, _ := hj.Hijack()
conn.Close()
}
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(tt.serverResponse))
defer server.Close()
config := &Config{
APIKey: "test-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
resp, err := client.Complete(context.Background(), "Hello")
if tt.wantErr {
assert.Error(t, err)
assert.Nil(t, resp)
if tt.checkError != nil {
tt.checkError(t, err)
}
} else {
assert.NoError(t, err)
assert.NotNil(t, resp)
}
})
}
}
func TestCompleteWithMessages(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req Request
json.NewDecoder(r.Body).Decode(&req)
assert.Len(t, req.Messages, 2)
assert.Equal(t, "system", req.Messages[0].Role)
assert.Equal(t, "user", req.Messages[1].Role)
resp := Response{
Choices: []Choice{
{Message: Message{Content: "Response"}},
},
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
config := &Config{
APIKey: "test-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
messages := []Message{
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hello"},
}
resp, err := client.CompleteWithMessages(context.Background(), messages)
assert.NoError(t, err)
assert.NotNil(t, resp)
}
func TestStreamComplete(t *testing.T) {
// Use a channel to keep the handler alive until client is done reading
done := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "text/event-stream", r.Header.Get("Accept"))
// Send SSE stream
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
chunks := []string{
`data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"Hello"}}]}`,
`data: {"id":"chatcmpl-123","choices":[{"delta":{"content":" "}}]}`,
`data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"world"}}]}`,
`data: [DONE]`,
}
for _, chunk := range chunks {
w.Write([]byte(chunk + "\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// Wait for client to signal it's done reading
<-done
}))
defer server.Close()
defer close(done)
config := &Config{
APIKey: "test-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
chunks, errs := client.StreamComplete(context.Background(), "Hello")
var receivedChunks []StreamChunk
var streamErr error
// Collect chunks
for chunk := range chunks {
receivedChunks = append(receivedChunks, chunk)
}
// Get error
select {
case err := <-errs:
streamErr = err
case <-time.After(1 * time.Second):
// No error
}
assert.NoError(t, streamErr)
assert.Greater(t, len(receivedChunks), 0)
// Verify first chunk
if len(receivedChunks) > 0 {
assert.Equal(t, "chatcmpl-123", receivedChunks[0].ID)
assert.Len(t, receivedChunks[0].Choices, 1)
}
}
func TestStreamComplete_ErrorHandling(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
errResp := ErrorResponse{
Error: ErrorDetail{
Message: "Invalid request",
},
}
json.NewEncoder(w).Encode(errResp)
}))
defer server.Close()
config := &Config{
APIKey: "test-key",
BaseURL: server.URL,
Model: "gpt-4o",
}
client, err := NewClient(config)
require.NoError(t, err)
chunks, errs := client.StreamComplete(context.Background(), "Hello")
// Should receive error
var streamErr error
select {
case err := <-errs:
streamErr = err
case <-time.After(1 * time.Second):
t.Fatal("Expected error but got none")
}
assert.Error(t, streamErr)
assert.Contains(t, streamErr.Error(), "400")
// Chunks channel should be closed
_, ok := <-chunks
assert.False(t, ok)
}
func TestSSEDecoder(t *testing.T) {
tests := []struct {
name string
input string
expected int // number of chunks expected
wantErr bool
}{
{
name: "single chunk",
input: "data: {\"id\":\"test\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n",
expected: 1,
wantErr: false,
},
{
name: "multiple chunks",
input: "data: {\"id\":\"test\",\"choices\":[{\"delta\":{\"content\":\"a\"}}]}\n\ndata: {\"id\":\"test\",\"choices\":[{\"delta\":{\"content\":\"b\"}}]}\n\n",
expected: 2, // Decoder correctly processes all available messages
wantErr: false,
},
{
name: "DONE marker",
input: "data: [DONE]\n\n",
expected: 0, // DONE should return EOF
wantErr: false,
},
{
name: "invalid JSON",
input: "data: invalid json\n\n",
expected: 0, // Should skip invalid chunks
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decoder := NewSSEDecoder(strings.NewReader(tt.input))
var chunks []StreamChunk
for {
chunk, err := decoder.Decode()
if err == io.EOF {
break
}
if err != nil {
if !tt.wantErr {
t.Fatalf("Unexpected error: %v", err)
}
break
}
chunks = append(chunks, chunk)
}
if tt.name == "DONE marker" {
// DONE should result in EOF, so no chunks
assert.Equal(t, 0, len(chunks))
} else {
assert.Equal(t, tt.expected, len(chunks))
}
})
}
}
func TestSimpleAI(t *testing.T) {
// This test requires a valid config, so we'll skip it in unit tests
// or mock the environment
t.Skip("Requires actual API key or extensive mocking")
}
func TestStructuredAI(t *testing.T) {
// This test requires a valid config, so we'll skip it in unit tests
t.Skip("Requires actual API key or extensive mocking")
}