test: expand AI provider test coverage with HTTP mocks
This commit is contained in:
parent
07f74f5d92
commit
cd3a2f14ee
6 changed files with 1013 additions and 85 deletions
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
@ -21,16 +22,27 @@ const (
|
||||||
|
|
||||||
// AnthropicClient implements the Provider interface for Anthropic's Claude API
|
// AnthropicClient implements the Provider interface for Anthropic's Claude API
|
||||||
type AnthropicClient struct {
|
type AnthropicClient struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
model string
|
model string
|
||||||
client *http.Client
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnthropicClient creates a new Anthropic API client
|
// NewAnthropicClient creates a new Anthropic API client
|
||||||
func NewAnthropicClient(apiKey, model string) *AnthropicClient {
|
func NewAnthropicClient(apiKey, model string) *AnthropicClient {
|
||||||
|
return NewAnthropicClientWithBaseURL(apiKey, model, anthropicAPIURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnthropicClientWithBaseURL creates a new Anthropic client using a custom messages endpoint.
|
||||||
|
// This is useful for testing and for deployments that route requests through a proxy.
|
||||||
|
func NewAnthropicClientWithBaseURL(apiKey, model, baseURL string) *AnthropicClient {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = anthropicAPIURL
|
||||||
|
}
|
||||||
return &AnthropicClient{
|
return &AnthropicClient{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
model: model,
|
model: model,
|
||||||
|
baseURL: baseURL,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
// 5 minutes - Opus and other large models can take a very long time
|
// 5 minutes - Opus and other large models can take a very long time
|
||||||
Timeout: 300 * time.Second,
|
Timeout: 300 * time.Second,
|
||||||
|
|
@ -54,33 +66,33 @@ type anthropicRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type anthropicMessage struct {
|
type anthropicMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content interface{} `json:"content"` // Can be string or []anthropicContent
|
Content interface{} `json:"content"` // Can be string or []anthropicContent
|
||||||
}
|
}
|
||||||
|
|
||||||
// anthropicTool represents a regular function tool
|
// anthropicTool represents a regular function tool
|
||||||
type anthropicTool struct {
|
type anthropicTool struct {
|
||||||
Type string `json:"type,omitempty"` // "web_search_20250305" for web search, omit for regular tools
|
Type string `json:"type,omitempty"` // "web_search_20250305" for web search, omit for regular tools
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"` // Not used for web search
|
Description string `json:"description,omitempty"` // Not used for web search
|
||||||
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // Not used for web search
|
InputSchema map[string]interface{} `json:"input_schema,omitempty"` // Not used for web search
|
||||||
MaxUses int `json:"max_uses,omitempty"` // For web search: limit searches per request
|
MaxUses int `json:"max_uses,omitempty"` // For web search: limit searches per request
|
||||||
}
|
}
|
||||||
|
|
||||||
// anthropicResponse is the response from the Anthropic API
|
// anthropicResponse is the response from the Anthropic API
|
||||||
type anthropicResponse struct {
|
type anthropicResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content []anthropicContent `json:"content"`
|
Content []anthropicContent `json:"content"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
StopReason string `json:"stop_reason"`
|
StopReason string `json:"stop_reason"`
|
||||||
StopSequence string `json:"stop_sequence,omitempty"`
|
StopSequence string `json:"stop_sequence,omitempty"`
|
||||||
Usage anthropicUsage `json:"usage"`
|
Usage anthropicUsage `json:"usage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type anthropicContent struct {
|
type anthropicContent struct {
|
||||||
Type string `json:"type"` // "text", "tool_use", "tool_result", "server_tool_use", "web_search_tool_result"
|
Type string `json:"type"` // "text", "tool_use", "tool_result", "server_tool_use", "web_search_tool_result"
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
ID string `json:"id,omitempty"` // For tool_use
|
ID string `json:"id,omitempty"` // For tool_use
|
||||||
Name string `json:"name,omitempty"` // For tool_use
|
Name string `json:"name,omitempty"` // For tool_use
|
||||||
|
|
@ -96,8 +108,8 @@ type anthropicUsage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type anthropicError struct {
|
type anthropicError struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Error anthropicErrorDetail `json:"error"`
|
Error anthropicErrorDetail `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type anthropicErrorDetail struct {
|
type anthropicErrorDetail struct {
|
||||||
|
|
@ -239,7 +251,7 @@ func (c *AnthropicClient) Chat(ctx context.Context, req ChatRequest) (*ChatRespo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIURL, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -346,9 +358,18 @@ func (c *AnthropicClient) TestConnection(ctx context.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *AnthropicClient) modelsEndpoint() string {
|
||||||
|
defaultURL := "https://api.anthropic.com/v1/models"
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return defaultURL
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host + "/v1/models"
|
||||||
|
}
|
||||||
|
|
||||||
// ListModels fetches available models from the Anthropic API
|
// ListModels fetches available models from the Anthropic API
|
||||||
func (c *AnthropicClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
func (c *AnthropicClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", c.modelsEndpoint(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,16 @@ const (
|
||||||
oauthScopes = "org:create_api_key user:profile user:inference user:sessions:claude_code"
|
oauthScopes = "org:create_api_key user:profile user:inference user:sessions:claude_code"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
oauthAuthorizeURL = anthropicAuthorizeURL
|
||||||
|
oauthTokenURL = anthropicTokenURL
|
||||||
|
oauthAPIKeyURL = anthropicAPIKeyURL
|
||||||
|
|
||||||
|
// oauthHTTPClient is used for token exchange, refresh, and API key creation.
|
||||||
|
// It is a package variable to enable deterministic, local unit tests.
|
||||||
|
oauthHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
)
|
||||||
|
|
||||||
// OAuthTokens represents the tokens obtained from OAuth flow
|
// OAuthTokens represents the tokens obtained from OAuth flow
|
||||||
type OAuthTokens struct {
|
type OAuthTokens struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
|
|
@ -111,7 +121,7 @@ func GetAuthorizationURL(session *OAuthSession) string {
|
||||||
params.Set("code_challenge_method", "S256")
|
params.Set("code_challenge_method", "S256")
|
||||||
params.Set("state", session.State)
|
params.Set("state", session.State)
|
||||||
|
|
||||||
return anthropicAuthorizeURL + "?" + params.Encode()
|
return oauthAuthorizeURL + "?" + params.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeCodeForTokens exchanges an authorization code for access tokens
|
// ExchangeCodeForTokens exchanges an authorization code for access tokens
|
||||||
|
|
@ -141,15 +151,14 @@ func ExchangeCodeForTokens(ctx context.Context, code string, session *OAuthSessi
|
||||||
Str("redirect_uri", "https://console.anthropic.com/oauth/code/callback").
|
Str("redirect_uri", "https://console.anthropic.com/oauth/code/callback").
|
||||||
Msg("Sending OAuth token exchange request (JSON)")
|
Msg("Sending OAuth token exchange request (JSON)")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, bytes.NewReader(jsonBody))
|
req, err := http.NewRequestWithContext(ctx, "POST", oauthTokenURL, bytes.NewReader(jsonBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
resp, err := oauthHTTPClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("token request failed: %w", err)
|
return nil, fmt.Errorf("token request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -190,15 +199,14 @@ func ExchangeCodeForTokens(ctx context.Context, code string, session *OAuthSessi
|
||||||
// CreateAPIKeyFromOAuth uses an OAuth access token to create a real API key
|
// CreateAPIKeyFromOAuth uses an OAuth access token to create a real API key
|
||||||
// This is how Claude Code uses the OAuth flow - the OAuth token creates an API key
|
// This is how Claude Code uses the OAuth flow - the OAuth token creates an API key
|
||||||
func CreateAPIKeyFromOAuth(ctx context.Context, accessToken string) (string, error) {
|
func CreateAPIKeyFromOAuth(ctx context.Context, accessToken string) (string, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIKeyURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "POST", oauthAPIKeyURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create API key request: %w", err)
|
return "", fmt.Errorf("failed to create API key request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
resp, err := oauthHTTPClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("API key request failed: %w", err)
|
return "", fmt.Errorf("API key request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -252,15 +260,14 @@ func RefreshAccessToken(ctx context.Context, refreshToken string) (*OAuthTokens,
|
||||||
Str("client_id", claudeCodeClientID).
|
Str("client_id", claudeCodeClientID).
|
||||||
Msg("Sending OAuth token refresh request")
|
Msg("Sending OAuth token refresh request")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, bytes.NewReader(jsonBody))
|
req, err := http.NewRequestWithContext(ctx, "POST", oauthTokenURL, bytes.NewReader(jsonBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
resp, err := oauthHTTPClient.Do(req)
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("refresh request failed: %w", err)
|
return nil, fmt.Errorf("refresh request failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -303,21 +310,32 @@ func RefreshAccessToken(ctx context.Context, refreshToken string) (*OAuthTokens,
|
||||||
|
|
||||||
// AnthropicOAuthClient is a client that uses OAuth tokens instead of API keys
|
// AnthropicOAuthClient is a client that uses OAuth tokens instead of API keys
|
||||||
type AnthropicOAuthClient struct {
|
type AnthropicOAuthClient struct {
|
||||||
accessToken string
|
accessToken string
|
||||||
refreshToken string
|
refreshToken string
|
||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
model string
|
model string
|
||||||
client *http.Client
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
onTokenRefresh func(tokens *OAuthTokens) // Callback when tokens are refreshed
|
onTokenRefresh func(tokens *OAuthTokens) // Callback when tokens are refreshed
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnthropicOAuthClient creates a new Anthropic client using OAuth tokens
|
// NewAnthropicOAuthClient creates a new Anthropic client using OAuth tokens
|
||||||
func NewAnthropicOAuthClient(accessToken, refreshToken string, expiresAt time.Time, model string) *AnthropicOAuthClient {
|
func NewAnthropicOAuthClient(accessToken, refreshToken string, expiresAt time.Time, model string) *AnthropicOAuthClient {
|
||||||
|
return NewAnthropicOAuthClientWithBaseURL(accessToken, refreshToken, expiresAt, model, "https://api.anthropic.com/v1/messages?beta=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnthropicOAuthClientWithBaseURL creates a new Anthropic OAuth client using a custom messages endpoint.
|
||||||
|
// This is useful for testing and for deployments that route requests through a proxy.
|
||||||
|
func NewAnthropicOAuthClientWithBaseURL(accessToken, refreshToken string, expiresAt time.Time, model, baseURL string) *AnthropicOAuthClient {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.anthropic.com/v1/messages?beta=true"
|
||||||
|
}
|
||||||
return &AnthropicOAuthClient{
|
return &AnthropicOAuthClient{
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
refreshToken: refreshToken,
|
refreshToken: refreshToken,
|
||||||
expiresAt: expiresAt,
|
expiresAt: expiresAt,
|
||||||
model: model,
|
model: model,
|
||||||
|
baseURL: baseURL,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: 300 * time.Second,
|
Timeout: 300 * time.Second,
|
||||||
},
|
},
|
||||||
|
|
@ -479,29 +497,31 @@ func (c *AnthropicOAuthClient) Chat(ctx context.Context, req ChatRequest) (*Chat
|
||||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the beta endpoint for subscription-based access
|
|
||||||
apiURL := "https://api.anthropic.com/v1/messages?beta=true"
|
|
||||||
|
|
||||||
var respBody []byte
|
var respBody []byte
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
var skipBackoff bool
|
||||||
|
|
||||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
backoff := initialBackoff * time.Duration(1<<(attempt-1))
|
if skipBackoff {
|
||||||
log.Warn().
|
skipBackoff = false
|
||||||
Int("attempt", attempt).
|
} else {
|
||||||
Dur("backoff", backoff).
|
backoff := initialBackoff * time.Duration(1<<(attempt-1))
|
||||||
Str("last_error", lastErr.Error()).
|
log.Warn().
|
||||||
Msg("Retrying Anthropic OAuth API request after transient error")
|
Int("attempt", attempt).
|
||||||
|
Dur("backoff", backoff).
|
||||||
|
Str("last_error", lastErr.Error()).
|
||||||
|
Msg("Retrying Anthropic OAuth API request after transient error")
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil, ctx.Err()
|
return nil, ctx.Err()
|
||||||
case <-time.After(backoff):
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(body))
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -533,8 +553,9 @@ func (c *AnthropicOAuthClient) Chat(ctx context.Context, req ChatRequest) (*Chat
|
||||||
if resp.StatusCode == 401 && c.refreshToken != "" {
|
if resp.StatusCode == 401 && c.refreshToken != "" {
|
||||||
log.Info().Msg("Got 401, forcing token refresh...")
|
log.Info().Msg("Got 401, forcing token refresh...")
|
||||||
if err := c.forceRefreshToken(ctx); err == nil {
|
if err := c.forceRefreshToken(ctx); err == nil {
|
||||||
// Token refreshed, retry immediately
|
// Token refreshed, retry immediately without backoff
|
||||||
lastErr = fmt.Errorf("token expired, retried with refreshed token")
|
lastErr = fmt.Errorf("token expired, retried with refreshed token")
|
||||||
|
skipBackoff = true
|
||||||
continue
|
continue
|
||||||
} else {
|
} else {
|
||||||
log.Error().Err(err).Msg("Failed to refresh token after 401")
|
log.Error().Err(err).Msg("Failed to refresh token after 401")
|
||||||
|
|
@ -610,6 +631,15 @@ func (c *AnthropicOAuthClient) TestConnection(ctx context.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *AnthropicOAuthClient) modelsEndpoint() string {
|
||||||
|
defaultURL := "https://api.anthropic.com/v1/models"
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return defaultURL
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host + "/v1/models"
|
||||||
|
}
|
||||||
|
|
||||||
// ListModels fetches available models from the Anthropic API using OAuth
|
// ListModels fetches available models from the Anthropic API using OAuth
|
||||||
func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
||||||
// Ensure we have a valid token
|
// Ensure we have a valid token
|
||||||
|
|
@ -617,7 +647,7 @@ func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, err
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", c.modelsEndpoint(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
415
internal/ai/providers/anthropic_oauth_test.go
Normal file
415
internal/ai/providers/anthropic_oauth_test.go
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateOAuthSession(t *testing.T) {
|
||||||
|
s, err := GenerateOAuthSession("http://localhost/callback")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateOAuthSession: %v", err)
|
||||||
|
}
|
||||||
|
if s.RedirectURI != "http://localhost/callback" {
|
||||||
|
t.Fatalf("RedirectURI = %q", s.RedirectURI)
|
||||||
|
}
|
||||||
|
if s.State == "" || s.CodeVerifier == "" {
|
||||||
|
t.Fatalf("expected non-empty state and verifier: %+v", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s.State, "=") || strings.Contains(s.CodeVerifier, "=") {
|
||||||
|
t.Fatalf("expected raw base64url encoding (no '='): %+v", s)
|
||||||
|
}
|
||||||
|
if time.Since(s.CreatedAt) > 2*time.Second {
|
||||||
|
t.Fatalf("CreatedAt too old: %v", s.CreatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAuthorizationURL_IncludesExpectedParamsAndChallenge(t *testing.T) {
|
||||||
|
session := &OAuthSession{
|
||||||
|
State: "state_123",
|
||||||
|
CodeVerifier: "verifier_456",
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(GetAuthorizationURL(session))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
if q.Get("code") != "true" {
|
||||||
|
t.Fatalf("code = %q", q.Get("code"))
|
||||||
|
}
|
||||||
|
if q.Get("client_id") != claudeCodeClientID {
|
||||||
|
t.Fatalf("client_id = %q", q.Get("client_id"))
|
||||||
|
}
|
||||||
|
if q.Get("response_type") != "code" {
|
||||||
|
t.Fatalf("response_type = %q", q.Get("response_type"))
|
||||||
|
}
|
||||||
|
if q.Get("redirect_uri") != "https://console.anthropic.com/oauth/code/callback" {
|
||||||
|
t.Fatalf("redirect_uri = %q", q.Get("redirect_uri"))
|
||||||
|
}
|
||||||
|
if q.Get("scope") != oauthScopes {
|
||||||
|
t.Fatalf("scope = %q", q.Get("scope"))
|
||||||
|
}
|
||||||
|
if q.Get("code_challenge_method") != "S256" {
|
||||||
|
t.Fatalf("code_challenge_method = %q", q.Get("code_challenge_method"))
|
||||||
|
}
|
||||||
|
if q.Get("state") != "state_123" {
|
||||||
|
t.Fatalf("state = %q", q.Get("state"))
|
||||||
|
}
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte(session.CodeVerifier))
|
||||||
|
wantChallenge := base64.RawURLEncoding.EncodeToString(h[:])
|
||||||
|
if q.Get("code_challenge") != wantChallenge {
|
||||||
|
t.Fatalf("code_challenge = %q, want %q", q.Get("code_challenge"), wantChallenge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeCodeForTokens_Success(t *testing.T) {
|
||||||
|
oldTokenURL := oauthTokenURL
|
||||||
|
oldClient := oauthHTTPClient
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthTokenURL = oldTokenURL
|
||||||
|
oauthHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/oauth/token" {
|
||||||
|
t.Fatalf("path = %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Fatalf("Content-Type = %q", r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if payload["grant_type"] != "authorization_code" {
|
||||||
|
t.Fatalf("grant_type = %v", payload["grant_type"])
|
||||||
|
}
|
||||||
|
if payload["code"] != "code_abc" {
|
||||||
|
t.Fatalf("code = %v", payload["code"])
|
||||||
|
}
|
||||||
|
if payload["client_id"] != claudeCodeClientID {
|
||||||
|
t.Fatalf("client_id = %v", payload["client_id"])
|
||||||
|
}
|
||||||
|
if payload["code_verifier"] != "verifier" {
|
||||||
|
t.Fatalf("code_verifier = %v", payload["code_verifier"])
|
||||||
|
}
|
||||||
|
if payload["state"] != "state" {
|
||||||
|
t.Fatalf("state = %v", payload["state"])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(OAuthTokens{
|
||||||
|
AccessToken: "access_1",
|
||||||
|
RefreshToken: "refresh_1",
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
Scope: oauthScopes,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oauthTokenURL = server.URL + "/v1/oauth/token"
|
||||||
|
oauthHTTPClient = server.Client()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
tokens, err := ExchangeCodeForTokens(context.Background(), "code_abc", &OAuthSession{
|
||||||
|
State: "state",
|
||||||
|
CodeVerifier: "verifier",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ExchangeCodeForTokens: %v", err)
|
||||||
|
}
|
||||||
|
if tokens.AccessToken != "access_1" || tokens.RefreshToken != "refresh_1" {
|
||||||
|
t.Fatalf("unexpected tokens: %+v", tokens)
|
||||||
|
}
|
||||||
|
if tokens.ExpiresAt.Before(now) || tokens.ExpiresAt.After(now.Add(2*time.Hour)) {
|
||||||
|
t.Fatalf("ExpiresAt = %v, now = %v", tokens.ExpiresAt, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExchangeCodeForTokens_ErrorStatusIncludesBody(t *testing.T) {
|
||||||
|
oldTokenURL := oauthTokenURL
|
||||||
|
oldClient := oauthHTTPClient
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthTokenURL = oldTokenURL
|
||||||
|
oauthHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte("bad request"))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oauthTokenURL = server.URL
|
||||||
|
oauthHTTPClient = server.Client()
|
||||||
|
|
||||||
|
_, err := ExchangeCodeForTokens(context.Background(), "code", &OAuthSession{State: "s", CodeVerifier: "v"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "status 400") || !strings.Contains(err.Error(), "bad request") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateAPIKeyFromOAuth_SuccessAndEmptyKey(t *testing.T) {
|
||||||
|
oldAPIKeyURL := oauthAPIKeyURL
|
||||||
|
oldClient := oauthHTTPClient
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthAPIKeyURL = oldAPIKeyURL
|
||||||
|
oauthHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
var call int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
call++
|
||||||
|
if r.Header.Get("Authorization") != "Bearer access" {
|
||||||
|
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
if call == 1 {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"raw_key": "sk-ant-123"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"raw_key": ""})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oauthAPIKeyURL = server.URL
|
||||||
|
oauthHTTPClient = server.Client()
|
||||||
|
|
||||||
|
key, err := CreateAPIKeyFromOAuth(context.Background(), "access")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateAPIKeyFromOAuth: %v", err)
|
||||||
|
}
|
||||||
|
if key != "sk-ant-123" {
|
||||||
|
t.Fatalf("key = %q", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = CreateAPIKeyFromOAuth(context.Background(), "access")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshAccessToken_KeepsOriginalRefreshTokenWhenOmitted(t *testing.T) {
|
||||||
|
oldTokenURL := oauthTokenURL
|
||||||
|
oldClient := oauthHTTPClient
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthTokenURL = oldTokenURL
|
||||||
|
oauthHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var payload map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["grant_type"] != "refresh_token" {
|
||||||
|
t.Fatalf("grant_type = %v", payload["grant_type"])
|
||||||
|
}
|
||||||
|
if payload["refresh_token"] != "refresh_old" {
|
||||||
|
t.Fatalf("refresh_token = %v", payload["refresh_token"])
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(OAuthTokens{
|
||||||
|
AccessToken: "access_new",
|
||||||
|
// RefreshToken intentionally omitted
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oauthTokenURL = server.URL
|
||||||
|
oauthHTTPClient = server.Client()
|
||||||
|
|
||||||
|
tokens, err := RefreshAccessToken(context.Background(), "refresh_old")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RefreshAccessToken: %v", err)
|
||||||
|
}
|
||||||
|
if tokens.AccessToken != "access_new" || tokens.RefreshToken != "refresh_old" {
|
||||||
|
t.Fatalf("unexpected tokens: %+v", tokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicOAuthClient_forceRefreshToken_UpdatesAndCallsCallback(t *testing.T) {
|
||||||
|
oldTokenURL := oauthTokenURL
|
||||||
|
oldClient := oauthHTTPClient
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthTokenURL = oldTokenURL
|
||||||
|
oauthHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewEncoder(w).Encode(OAuthTokens{
|
||||||
|
AccessToken: "access_new",
|
||||||
|
RefreshToken: "refresh_new",
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oauthTokenURL = server.URL
|
||||||
|
oauthHTTPClient = server.Client()
|
||||||
|
|
||||||
|
client := NewAnthropicOAuthClient("access_old", "refresh_old", time.Now().Add(-time.Minute), "claude-3")
|
||||||
|
|
||||||
|
var cbTokens *OAuthTokens
|
||||||
|
client.SetTokenRefreshCallback(func(tokens *OAuthTokens) { cbTokens = tokens })
|
||||||
|
|
||||||
|
if err := client.forceRefreshToken(context.Background()); err != nil {
|
||||||
|
t.Fatalf("forceRefreshToken: %v", err)
|
||||||
|
}
|
||||||
|
if client.accessToken != "access_new" || client.refreshToken != "refresh_new" {
|
||||||
|
t.Fatalf("unexpected client tokens: access=%q refresh=%q", client.accessToken, client.refreshToken)
|
||||||
|
}
|
||||||
|
if cbTokens == nil || cbTokens.AccessToken != "access_new" {
|
||||||
|
t.Fatalf("expected callback with new tokens, got: %+v", cbTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicOAuthClient_Chat_RefreshesOn401AndRetriesImmediately(t *testing.T) {
|
||||||
|
oldTokenURL := oauthTokenURL
|
||||||
|
oldClient := oauthHTTPClient
|
||||||
|
t.Cleanup(func() {
|
||||||
|
oauthTokenURL = oldTokenURL
|
||||||
|
oauthHTTPClient = oldClient
|
||||||
|
})
|
||||||
|
|
||||||
|
var messageCalls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/oauth/token":
|
||||||
|
_ = json.NewEncoder(w).Encode(OAuthTokens{
|
||||||
|
AccessToken: "access_new",
|
||||||
|
RefreshToken: "refresh_new",
|
||||||
|
TokenType: "bearer",
|
||||||
|
ExpiresIn: 3600,
|
||||||
|
})
|
||||||
|
case "/v1/messages":
|
||||||
|
if r.URL.Query().Get("beta") != "true" {
|
||||||
|
t.Fatalf("expected beta=true query, got %q", r.URL.RawQuery)
|
||||||
|
}
|
||||||
|
if r.Header.Get("anthropic-version") != anthropicAPIVersion {
|
||||||
|
t.Fatalf("anthropic-version = %q", r.Header.Get("anthropic-version"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("anthropic-beta") != "oauth-2025-04-20" {
|
||||||
|
t.Fatalf("anthropic-beta = %q", r.Header.Get("anthropic-beta"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("x-app") != "cli" {
|
||||||
|
t.Fatalf("x-app = %q", r.Header.Get("x-app"))
|
||||||
|
}
|
||||||
|
|
||||||
|
messageCalls++
|
||||||
|
if messageCalls == 1 {
|
||||||
|
if r.Header.Get("Authorization") != "Bearer access_old" {
|
||||||
|
t.Fatalf("Authorization (first) = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":{"message":"unauthorized"}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer access_new" {
|
||||||
|
t.Fatalf("Authorization (retry) = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var got anthropicRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
if got.Model != "claude-3" {
|
||||||
|
t.Fatalf("Model = %q", got.Model)
|
||||||
|
}
|
||||||
|
if len(got.Messages) != 1 || got.Messages[0].Role != "user" || got.Messages[0].Content != "Hi" {
|
||||||
|
t.Fatalf("unexpected messages: %+v", got.Messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(anthropicResponse{
|
||||||
|
ID: "msg_123",
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: "claude-3",
|
||||||
|
StopReason: "end_turn",
|
||||||
|
Content: []anthropicContent{{Type: "text", Text: "ok"}},
|
||||||
|
Usage: anthropicUsage{InputTokens: 1, OutputTokens: 2},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
oauthTokenURL = server.URL + "/v1/oauth/token"
|
||||||
|
oauthHTTPClient = server.Client()
|
||||||
|
|
||||||
|
client := NewAnthropicOAuthClientWithBaseURL(
|
||||||
|
"access_old",
|
||||||
|
"refresh_old",
|
||||||
|
time.Now().Add(10*time.Minute), // valid token, so refresh is driven by 401
|
||||||
|
"claude-3",
|
||||||
|
server.URL+"/v1/messages?beta=true",
|
||||||
|
)
|
||||||
|
client.client = server.Client()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := client.Chat(ctx, ChatRequest{Messages: []Message{{Role: "user", Content: "Hi"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
if out.Content != "ok" || out.InputTokens != 1 || out.OutputTokens != 2 {
|
||||||
|
t.Fatalf("unexpected response: %+v", out)
|
||||||
|
}
|
||||||
|
if messageCalls != 2 {
|
||||||
|
t.Fatalf("messageCalls = %d, want 2", messageCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicOAuthClient_ListModels_UsesConfiguredHost(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Fatalf("path = %s, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer access" {
|
||||||
|
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"data": []map[string]any{
|
||||||
|
{"id": "claude-3", "display_name": "Claude 3", "created_at": "2024-01-01T00:00:00Z"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewAnthropicOAuthClientWithBaseURL(
|
||||||
|
"access",
|
||||||
|
"refresh",
|
||||||
|
time.Now().Add(10*time.Minute),
|
||||||
|
"claude-3",
|
||||||
|
server.URL+"/v1/messages?beta=true",
|
||||||
|
)
|
||||||
|
client.client = server.Client()
|
||||||
|
|
||||||
|
models, err := client.ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels: %v", err)
|
||||||
|
}
|
||||||
|
if len(models) != 1 || models[0].ID != "claude-3" || models[0].Name != "Claude 3" {
|
||||||
|
t.Fatalf("unexpected models: %+v", models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,12 +2,14 @@ package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Anthropic tests are limited because the client hardcodes API URLs.
|
// Anthropic tests focus on request/response correctness and endpoint behavior.
|
||||||
// These tests verify context handling and basic construction.
|
|
||||||
|
|
||||||
func TestAnthropicClient_Name(t *testing.T) {
|
func TestAnthropicClient_Name(t *testing.T) {
|
||||||
client := NewAnthropicClient("test-key", "claude-3-5-sonnet")
|
client := NewAnthropicClient("test-key", "claude-3-5-sonnet")
|
||||||
|
|
@ -45,3 +47,212 @@ func TestAnthropicClient_Chat_Timeout(t *testing.T) {
|
||||||
t.Error("Expected timeout error")
|
t.Error("Expected timeout error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnthropicClient_Chat_Success_TextAndToolCalls(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/messages" {
|
||||||
|
t.Fatalf("path = %s, want /v1/messages", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("x-api-key") != "test-key" {
|
||||||
|
t.Fatalf("x-api-key = %q", r.Header.Get("x-api-key"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("anthropic-version") != anthropicAPIVersion {
|
||||||
|
t.Fatalf("anthropic-version = %q", r.Header.Get("anthropic-version"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var got anthropicRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.Model != "claude-3-5-sonnet" {
|
||||||
|
t.Fatalf("Model = %q", got.Model)
|
||||||
|
}
|
||||||
|
if got.System != "You are helpful" {
|
||||||
|
t.Fatalf("System = %q", got.System)
|
||||||
|
}
|
||||||
|
if got.MaxTokens != 123 {
|
||||||
|
t.Fatalf("MaxTokens = %d", got.MaxTokens)
|
||||||
|
}
|
||||||
|
if len(got.Messages) != 1 || got.Messages[0].Role != "user" {
|
||||||
|
t.Fatalf("unexpected messages: %+v", got.Messages)
|
||||||
|
}
|
||||||
|
if got.Messages[0].Content != "Hello" {
|
||||||
|
t.Fatalf("unexpected message content: %+v", got.Messages[0])
|
||||||
|
}
|
||||||
|
if len(got.Tools) != 2 {
|
||||||
|
t.Fatalf("tools = %d, want 2", len(got.Tools))
|
||||||
|
}
|
||||||
|
if got.Tools[0].Name != "get_time" || got.Tools[0].Type != "" {
|
||||||
|
t.Fatalf("unexpected function tool: %+v", got.Tools[0])
|
||||||
|
}
|
||||||
|
if got.Tools[1].Type != "web_search_20250305" || got.Tools[1].Name != "web_search" || got.Tools[1].MaxUses != 2 {
|
||||||
|
t.Fatalf("unexpected web search tool: %+v", got.Tools[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(anthropicResponse{
|
||||||
|
ID: "msg_123",
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: "claude-3-5-sonnet",
|
||||||
|
StopReason: "tool_use",
|
||||||
|
Content: []anthropicContent{
|
||||||
|
{Type: "text", Text: "Hi"},
|
||||||
|
{Type: "tool_use", ID: "tool_1", Name: "get_time", Input: map[string]any{"tz": "UTC"}},
|
||||||
|
},
|
||||||
|
Usage: anthropicUsage{InputTokens: 10, OutputTokens: 20},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL+"/v1/messages")
|
||||||
|
out, err := client.Chat(context.Background(), ChatRequest{
|
||||||
|
System: "You are helpful",
|
||||||
|
Messages: []Message{{Role: "user", Content: "Hello"}},
|
||||||
|
MaxTokens: 123,
|
||||||
|
Tools: []Tool{
|
||||||
|
{
|
||||||
|
Name: "get_time",
|
||||||
|
Description: "get time",
|
||||||
|
InputSchema: map[string]any{"type": "object"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "web_search_20250305",
|
||||||
|
Name: "web_search",
|
||||||
|
MaxUses: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Content != "Hi" {
|
||||||
|
t.Fatalf("Content = %q, want Hi", out.Content)
|
||||||
|
}
|
||||||
|
if out.StopReason != "tool_use" {
|
||||||
|
t.Fatalf("StopReason = %q, want tool_use", out.StopReason)
|
||||||
|
}
|
||||||
|
if len(out.ToolCalls) != 1 || out.ToolCalls[0].ID != "tool_1" || out.ToolCalls[0].Name != "get_time" {
|
||||||
|
t.Fatalf("unexpected ToolCalls: %+v", out.ToolCalls)
|
||||||
|
}
|
||||||
|
if out.ToolCalls[0].Input["tz"] != "UTC" {
|
||||||
|
t.Fatalf("unexpected ToolCall input: %+v", out.ToolCalls[0].Input)
|
||||||
|
}
|
||||||
|
if out.InputTokens != 10 || out.OutputTokens != 20 {
|
||||||
|
t.Fatalf("unexpected usage: %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicClient_Chat_ToolResultInRequest(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
_ = json.NewEncoder(w).Encode(anthropicResponse{
|
||||||
|
ID: "msg_123",
|
||||||
|
Type: "message",
|
||||||
|
Role: "assistant",
|
||||||
|
Model: "claude-3-5-sonnet",
|
||||||
|
StopReason: "end_turn",
|
||||||
|
Content: []anthropicContent{{Type: "text", Text: "ok"}},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL+"/v1/messages")
|
||||||
|
_, err := client.Chat(context.Background(), ChatRequest{
|
||||||
|
Messages: []Message{
|
||||||
|
{
|
||||||
|
ToolResult: &ToolResult{
|
||||||
|
ToolUseID: "tool_1",
|
||||||
|
Content: "{\"time\":\"00:00\"}",
|
||||||
|
IsError: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, ok := got["messages"].([]any)
|
||||||
|
if !ok || len(msgs) != 1 {
|
||||||
|
t.Fatalf("unexpected messages: %+v", got["messages"])
|
||||||
|
}
|
||||||
|
first, ok := msgs[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected message type: %T", msgs[0])
|
||||||
|
}
|
||||||
|
if first["role"] != "user" {
|
||||||
|
t.Fatalf("role = %v, want user", first["role"])
|
||||||
|
}
|
||||||
|
contentArr, ok := first["content"].([]any)
|
||||||
|
if !ok || len(contentArr) != 1 {
|
||||||
|
t.Fatalf("unexpected content: %+v", first["content"])
|
||||||
|
}
|
||||||
|
block, ok := contentArr[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("unexpected content block type: %T", contentArr[0])
|
||||||
|
}
|
||||||
|
if block["type"] != "tool_result" || block["tool_use_id"] != "tool_1" || block["is_error"] != true {
|
||||||
|
t.Fatalf("unexpected tool_result block: %+v", block)
|
||||||
|
}
|
||||||
|
if _, ok := block["content"].(string); !ok {
|
||||||
|
t.Fatalf("expected tool_result content to be a string, got: %+v", block["content"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicClient_ListModels_UsesConfiguredHost(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Fatalf("method = %s, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Fatalf("path = %s, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("x-api-key") != "test-key" {
|
||||||
|
t.Fatalf("x-api-key = %q", r.Header.Get("x-api-key"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("anthropic-version") != anthropicAPIVersion {
|
||||||
|
t.Fatalf("anthropic-version = %q", r.Header.Get("anthropic-version"))
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"data": []map[string]any{
|
||||||
|
{"id": "claude-3-5-sonnet", "display_name": "Claude 3.5 Sonnet", "created_at": "2024-01-01T00:00:00Z"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL+"/v1/messages")
|
||||||
|
models, err := client.ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels: %v", err)
|
||||||
|
}
|
||||||
|
if len(models) != 1 || models[0].ID != "claude-3-5-sonnet" || models[0].Name != "Claude 3.5 Sonnet" {
|
||||||
|
t.Fatalf("unexpected models: %+v", models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnthropicClient_TestConnection_CallsListModels(t *testing.T) {
|
||||||
|
var called int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Fatalf("path = %s, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
called++
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewAnthropicClientWithBaseURL("test-key", "claude-3-5-sonnet", server.URL+"/v1/messages")
|
||||||
|
if err := client.TestConnection(context.Background()); err != nil {
|
||||||
|
t.Fatalf("TestConnection: %v", err)
|
||||||
|
}
|
||||||
|
if called != 1 {
|
||||||
|
t.Fatalf("ListModels calls = %d, want 1", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -14,8 +15,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
openaiAPIURL = "https://api.openai.com/v1/chat/completions"
|
openaiAPIURL = "https://api.openai.com/v1/chat/completions"
|
||||||
openaiMaxRetries = 3
|
openaiMaxRetries = 3
|
||||||
openaiInitialBackoff = 2 * time.Second
|
openaiInitialBackoff = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -28,7 +29,6 @@ type OpenAIClient struct {
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// NewOpenAIClient creates a new OpenAI API client
|
// NewOpenAIClient creates a new OpenAI API client
|
||||||
func NewOpenAIClient(apiKey, model, baseURL string) *OpenAIClient {
|
func NewOpenAIClient(apiKey, model, baseURL string) *OpenAIClient {
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
|
|
@ -58,22 +58,22 @@ func (c *OpenAIClient) Name() string {
|
||||||
|
|
||||||
// openaiRequest is the request body for the OpenAI API
|
// openaiRequest is the request body for the OpenAI API
|
||||||
type openaiRequest struct {
|
type openaiRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []openaiMessage `json:"messages"`
|
Messages []openaiMessage `json:"messages"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"` // Legacy parameter for older models
|
MaxTokens int `json:"max_tokens,omitempty"` // Legacy parameter for older models
|
||||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // For o1/o3 models
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // For o1/o3 models
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
Temperature float64 `json:"temperature,omitempty"`
|
||||||
Tools []openaiTool `json:"tools,omitempty"`
|
Tools []openaiTool `json:"tools,omitempty"`
|
||||||
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", or specific tool
|
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", or specific tool
|
||||||
}
|
}
|
||||||
|
|
||||||
// deepseekRequest extends openaiRequest with DeepSeek-specific fields
|
// deepseekRequest extends openaiRequest with DeepSeek-specific fields
|
||||||
type deepseekRequest struct {
|
type deepseekRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []openaiMessage `json:"messages"`
|
Messages []openaiMessage `json:"messages"`
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
MaxTokens int `json:"max_tokens,omitempty"`
|
||||||
Tools []openaiTool `json:"tools,omitempty"`
|
Tools []openaiTool `json:"tools,omitempty"`
|
||||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// openaiCompletionsRequest is for non-chat models like gpt-5.2-pro that use /v1/completions
|
// openaiCompletionsRequest is for non-chat models like gpt-5.2-pro that use /v1/completions
|
||||||
|
|
@ -98,10 +98,10 @@ type openaiFunction struct {
|
||||||
|
|
||||||
type openaiMessage struct {
|
type openaiMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content interface{} `json:"content,omitempty"` // string or null for tool calls
|
Content interface{} `json:"content,omitempty"` // string or null for tool calls
|
||||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode
|
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode
|
||||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // For assistant messages with tool calls
|
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // For assistant messages with tool calls
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
||||||
}
|
}
|
||||||
|
|
||||||
type openaiToolCall struct {
|
type openaiToolCall struct {
|
||||||
|
|
@ -473,14 +473,28 @@ func (c *OpenAIClient) TestConnection(ctx context.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListModels fetches available models from the OpenAI API
|
func (c *OpenAIClient) modelsEndpoint() string {
|
||||||
func (c *OpenAIClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
// Default to public API endpoints to preserve current behavior if baseURL is invalid.
|
||||||
// Determine the models endpoint based on the base URL
|
|
||||||
modelsURL := "https://api.openai.com/v1/models"
|
modelsURL := "https://api.openai.com/v1/models"
|
||||||
if c.isDeepSeek() {
|
if c.isDeepSeek() {
|
||||||
modelsURL = "https://api.deepseek.com/models"
|
modelsURL = "https://api.deepseek.com/models"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(c.baseURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return modelsURL
|
||||||
|
}
|
||||||
|
|
||||||
|
base := u.Scheme + "://" + u.Host
|
||||||
|
if c.isDeepSeek() {
|
||||||
|
return base + "/models"
|
||||||
|
}
|
||||||
|
return base + "/v1/models"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListModels fetches available models from the OpenAI API
|
||||||
|
func (c *OpenAIClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
||||||
|
modelsURL := c.modelsEndpoint()
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", modelsURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", modelsURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,24 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type rewriteToServerTransport struct {
|
||||||
|
serverBase *url.URL
|
||||||
|
rt http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t rewriteToServerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
cloned := req.Clone(req.Context())
|
||||||
|
cloned.URL.Scheme = t.serverBase.Scheme
|
||||||
|
cloned.URL.Host = t.serverBase.Host
|
||||||
|
cloned.Host = t.serverBase.Host
|
||||||
|
return t.rt.RoundTrip(cloned)
|
||||||
|
}
|
||||||
|
|
||||||
// Mock OpenAI API response format
|
// Mock OpenAI API response format
|
||||||
type mockOpenAIResponse struct {
|
type mockOpenAIResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|
@ -48,6 +62,9 @@ func TestOpenAIClient_Chat_Success(t *testing.T) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
t.Errorf("Expected POST, got %s", r.Method)
|
t.Errorf("Expected POST, got %s", r.Method)
|
||||||
}
|
}
|
||||||
|
if r.URL.Path != "/v1/chat/completions" {
|
||||||
|
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
if r.Header.Get("Authorization") != "Bearer test-api-key" {
|
if r.Header.Get("Authorization") != "Bearer test-api-key" {
|
||||||
t.Errorf("Expected Bearer token, got %s", r.Header.Get("Authorization"))
|
t.Errorf("Expected Bearer token, got %s", r.Header.Get("Authorization"))
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +115,7 @@ func TestOpenAIClient_Chat_Success(t *testing.T) {
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
// Create client with mock server URL
|
// Create client with mock server URL
|
||||||
client := NewOpenAIClient("test-api-key", "gpt-4o", server.URL)
|
client := NewOpenAIClient("test-api-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
|
||||||
// Execute chat request
|
// Execute chat request
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -133,12 +150,15 @@ func TestOpenAIClient_Chat_Success(t *testing.T) {
|
||||||
func TestOpenAIClient_Chat_APIError(t *testing.T) {
|
func TestOpenAIClient_Chat_APIError(t *testing.T) {
|
||||||
// Create a mock server that returns an error
|
// Create a mock server that returns an error
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/chat/completions" {
|
||||||
|
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
w.Write([]byte(`{"error": {"message": "Invalid API key", "type": "invalid_request_error"}}`))
|
w.Write([]byte(`{"error": {"message": "Invalid API key", "type": "invalid_request_error"}}`))
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewOpenAIClient("invalid-key", "gpt-4o", server.URL)
|
client := NewOpenAIClient("invalid-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err := client.Chat(ctx, ChatRequest{
|
_, err := client.Chat(ctx, ChatRequest{
|
||||||
|
|
@ -173,7 +193,7 @@ func TestOpenAIClient_Chat_ContextCanceled(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewOpenAIClient("test-key", "gpt-4o", server.URL)
|
client := NewOpenAIClient("test-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
cancel() // Cancel immediately
|
cancel() // Cancel immediately
|
||||||
|
|
@ -216,7 +236,7 @@ func TestOpenAIClient_Chat_WithSystemPrompt(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
client := NewOpenAIClient("test-key", "gpt-4o", server.URL)
|
client := NewOpenAIClient("test-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, err := client.Chat(ctx, ChatRequest{
|
_, err := client.Chat(ctx, ChatRequest{
|
||||||
|
|
@ -235,11 +255,228 @@ func TestOpenAIClient_Chat_WithSystemPrompt(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: ListModels and TestConnection tests are not included here because
|
func TestOpenAIClient_ListModels_UsesConfiguredHostAndFilters(t *testing.T) {
|
||||||
// the OpenAI client hardcodes the models endpoint URLs (api.openai.com/v1/models).
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// To properly test these methods, the OpenAI client would need to be refactored
|
if r.Method != http.MethodGet {
|
||||||
|
t.Fatalf("method = %s, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Fatalf("path = %s, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-api-key" {
|
||||||
|
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(mockOpenAIModelsResponse{
|
||||||
|
Object: "list",
|
||||||
|
Data: []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
}{
|
||||||
|
{ID: "gpt-4o", Object: "model", Created: 1, OwnedBy: "openai"},
|
||||||
|
{ID: "text-embedding-3-small", Object: "model", Created: 2, OwnedBy: "openai"},
|
||||||
|
{ID: "o1-mini", Object: "model", Created: 3, OwnedBy: "openai"},
|
||||||
|
{ID: "nonmatching", Object: "model", Created: 4, OwnedBy: "openai"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewOpenAIClient("test-api-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
models, err := client.ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Fatalf("models = %d, want 2", len(models))
|
||||||
|
}
|
||||||
|
if models[0].ID != "gpt-4o" || models[1].ID != "o1-mini" {
|
||||||
|
t.Fatalf("unexpected models: %+v", models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_TestConnection_CallsListModels(t *testing.T) {
|
||||||
|
var called int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/models" {
|
||||||
|
t.Fatalf("path = %s, want /v1/models", r.URL.Path)
|
||||||
|
}
|
||||||
|
called++
|
||||||
|
_ = json.NewEncoder(w).Encode(mockOpenAIModelsResponse{
|
||||||
|
Object: "list",
|
||||||
|
Data: nil,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewOpenAIClient("test-api-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
if err := client.TestConnection(context.Background()); err != nil {
|
||||||
|
t.Fatalf("TestConnection: %v", err)
|
||||||
|
}
|
||||||
|
if called != 1 {
|
||||||
|
t.Fatalf("ListModels calls = %d, want 1", called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Chat_UsesMaxCompletionTokensForOpenAI(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
_ = json.NewEncoder(w).Encode(mockOpenAIResponse{
|
||||||
|
ID: "chatcmpl-123",
|
||||||
|
Model: "gpt-4o",
|
||||||
|
Choices: []struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}{
|
||||||
|
{Message: struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Role: "assistant", Content: "ok"}, FinishReason: "stop"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewOpenAIClient("test-api-key", "gpt-4o", server.URL+"/v1/chat/completions")
|
||||||
|
_, err := client.Chat(context.Background(), ChatRequest{
|
||||||
|
Messages: []Message{{Role: "user", Content: "Hello"}},
|
||||||
|
MaxTokens: 123,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := got["max_completion_tokens"]; !ok {
|
||||||
|
t.Fatalf("expected max_completion_tokens to be set, got: %+v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got["max_tokens"]; ok {
|
||||||
|
t.Fatalf("did not expect max_tokens for OpenAI, got: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Chat_GPT52NonChat_UsesCompletionsEndpointAndPrompt(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
var gotPath string
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
_ = json.NewEncoder(w).Encode(openaiResponse{
|
||||||
|
Model: "gpt-5.2-pro",
|
||||||
|
Choices: []openaiChoice{
|
||||||
|
{Text: "ok", FinishReason: "stop"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
serverURL, err := url.Parse(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse server url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewOpenAIClient("test-api-key", "gpt-5.2-pro", "https://api.openai.com/v1/chat/completions")
|
||||||
|
client.client.Transport = rewriteToServerTransport{serverBase: serverURL, rt: http.DefaultTransport}
|
||||||
|
|
||||||
|
_, err = client.Chat(context.Background(), ChatRequest{
|
||||||
|
System: "System prompt",
|
||||||
|
Messages: []Message{{Role: "user", Content: "Hello"}},
|
||||||
|
MaxTokens: 50,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotPath != "/v1/completions" {
|
||||||
|
t.Fatalf("path = %q, want /v1/completions", gotPath)
|
||||||
|
}
|
||||||
|
if _, ok := got["prompt"]; !ok {
|
||||||
|
t.Fatalf("expected prompt in completions request, got: %+v", got)
|
||||||
|
}
|
||||||
|
if _, ok := got["messages"]; ok {
|
||||||
|
t.Fatalf("did not expect messages in completions request, got: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_ListModels_DeepSeekUsesModelsEndpoint(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
_ = json.NewEncoder(w).Encode(struct {
|
||||||
|
Data []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Created int64 `json:"created"`
|
||||||
|
OwnedBy string `json:"owned_by"`
|
||||||
|
} `json:"data"`
|
||||||
|
}{})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
serverURL, err := url.Parse(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse server url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewOpenAIClient("test-api-key", "deepseek-chat", "https://api.deepseek.com/v1/chat/completions")
|
||||||
|
client.client.Transport = rewriteToServerTransport{serverBase: serverURL, rt: http.DefaultTransport}
|
||||||
|
|
||||||
|
_, err = client.ListModels(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListModels: %v", err)
|
||||||
|
}
|
||||||
|
if gotPath != "/models" {
|
||||||
|
t.Fatalf("path = %q, want /models", gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIClient_Chat_O1OmitsTemperature(t *testing.T) {
|
||||||
|
var got map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
_ = json.NewEncoder(w).Encode(mockOpenAIResponse{
|
||||||
|
ID: "chatcmpl-123",
|
||||||
|
Model: "o1-mini",
|
||||||
|
Choices: []struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
}{
|
||||||
|
{Message: struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}{Role: "assistant", Content: "ok"}, FinishReason: "stop"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewOpenAIClient("test-api-key", "o1-mini", server.URL+"/v1/chat/completions")
|
||||||
|
_, err := client.Chat(context.Background(), ChatRequest{
|
||||||
|
Messages: []Message{{Role: "user", Content: "Hello"}},
|
||||||
|
Model: "o1-mini",
|
||||||
|
Temperature: 0.7,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := got["temperature"]; ok {
|
||||||
|
t.Fatalf("did not expect temperature for o1 models, got: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// to derive the models URL from the baseURL, similar to how Chat works.
|
// to derive the models URL from the baseURL, similar to how Chat works.
|
||||||
//
|
//
|
||||||
// For now, these tests would require actual API keys to run E2E tests,
|
// For now, these tests would require actual API keys to run E2E tests,
|
||||||
// which should be done in a separate integration test suite.
|
// which should be done in a separate integration test suite.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue