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"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
|
|
@ -21,16 +22,27 @@ const (
|
|||
|
||||
// AnthropicClient implements the Provider interface for Anthropic's Claude API
|
||||
type AnthropicClient struct {
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
apiKey string
|
||||
model string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAnthropicClient creates a new Anthropic API client
|
||||
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{
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
// 5 minutes - Opus and other large models can take a very long time
|
||||
Timeout: 300 * time.Second,
|
||||
|
|
@ -54,33 +66,33 @@ type anthropicRequest struct {
|
|||
}
|
||||
|
||||
type anthropicMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"` // Can be string or []anthropicContent
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"` // Can be string or []anthropicContent
|
||||
}
|
||||
|
||||
// anthropicTool represents a regular function tool
|
||||
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"`
|
||||
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
|
||||
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
|
||||
type anthropicResponse struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Role string `json:"role"`
|
||||
Content []anthropicContent `json:"content"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence string `json:"stop_sequence,omitempty"`
|
||||
Usage anthropicUsage `json:"usage"`
|
||||
Model string `json:"model"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
StopSequence string `json:"stop_sequence,omitempty"`
|
||||
Usage anthropicUsage `json:"usage"`
|
||||
}
|
||||
|
||||
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"`
|
||||
ID string `json:"id,omitempty"` // For tool_use
|
||||
Name string `json:"name,omitempty"` // For tool_use
|
||||
|
|
@ -96,8 +108,8 @@ type anthropicUsage struct {
|
|||
}
|
||||
|
||||
type anthropicError struct {
|
||||
Type string `json:"type"`
|
||||
Error anthropicErrorDetail `json:"error"`
|
||||
Type string `json:"type"`
|
||||
Error anthropicErrorDetail `json:"error"`
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
|
@ -346,9 +358,18 @@ func (c *AnthropicClient) TestConnection(ctx context.Context) error {
|
|||
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
|
||||
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 {
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
type OAuthTokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
|
|
@ -111,7 +121,7 @@ func GetAuthorizationURL(session *OAuthSession) string {
|
|||
params.Set("code_challenge_method", "S256")
|
||||
params.Set("state", session.State)
|
||||
|
||||
return anthropicAuthorizeURL + "?" + params.Encode()
|
||||
return oauthAuthorizeURL + "?" + params.Encode()
|
||||
}
|
||||
|
||||
// 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").
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
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
|
||||
// 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) {
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIKeyURL, nil)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", oauthAPIKeyURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create API key request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
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).
|
||||
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 {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
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
|
||||
type AnthropicOAuthClient struct {
|
||||
accessToken string
|
||||
refreshToken string
|
||||
expiresAt time.Time
|
||||
model string
|
||||
client *http.Client
|
||||
accessToken string
|
||||
refreshToken string
|
||||
expiresAt time.Time
|
||||
model string
|
||||
baseURL string
|
||||
client *http.Client
|
||||
onTokenRefresh func(tokens *OAuthTokens) // Callback when tokens are refreshed
|
||||
}
|
||||
|
||||
// NewAnthropicOAuthClient creates a new Anthropic client using OAuth tokens
|
||||
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{
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
model: model,
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
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)
|
||||
}
|
||||
|
||||
// Use the beta endpoint for subscription-based access
|
||||
apiURL := "https://api.anthropic.com/v1/messages?beta=true"
|
||||
|
||||
var respBody []byte
|
||||
var lastErr error
|
||||
var skipBackoff bool
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := initialBackoff * time.Duration(1<<(attempt-1))
|
||||
log.Warn().
|
||||
Int("attempt", attempt).
|
||||
Dur("backoff", backoff).
|
||||
Str("last_error", lastErr.Error()).
|
||||
Msg("Retrying Anthropic OAuth API request after transient error")
|
||||
if skipBackoff {
|
||||
skipBackoff = false
|
||||
} else {
|
||||
backoff := initialBackoff * time.Duration(1<<(attempt-1))
|
||||
log.Warn().
|
||||
Int("attempt", attempt).
|
||||
Dur("backoff", backoff).
|
||||
Str("last_error", lastErr.Error()).
|
||||
Msg("Retrying Anthropic OAuth API request after transient error")
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
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 {
|
||||
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 != "" {
|
||||
log.Info().Msg("Got 401, forcing token refresh...")
|
||||
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")
|
||||
skipBackoff = true
|
||||
continue
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
||||
// Ensure we have a valid token
|
||||
|
|
@ -617,7 +647,7 @@ func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, 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 {
|
||||
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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Anthropic tests are limited because the client hardcodes API URLs.
|
||||
// These tests verify context handling and basic construction.
|
||||
// Anthropic tests focus on request/response correctness and endpoint behavior.
|
||||
|
||||
func TestAnthropicClient_Name(t *testing.T) {
|
||||
client := NewAnthropicClient("test-key", "claude-3-5-sonnet")
|
||||
|
|
@ -45,3 +47,212 @@ func TestAnthropicClient_Chat_Timeout(t *testing.T) {
|
|||
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"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -14,8 +15,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
openaiAPIURL = "https://api.openai.com/v1/chat/completions"
|
||||
openaiMaxRetries = 3
|
||||
openaiAPIURL = "https://api.openai.com/v1/chat/completions"
|
||||
openaiMaxRetries = 3
|
||||
openaiInitialBackoff = 2 * time.Second
|
||||
)
|
||||
|
||||
|
|
@ -28,7 +29,6 @@ type OpenAIClient struct {
|
|||
client *http.Client
|
||||
}
|
||||
|
||||
|
||||
// NewOpenAIClient creates a new OpenAI API client
|
||||
func NewOpenAIClient(apiKey, model, baseURL string) *OpenAIClient {
|
||||
if baseURL == "" {
|
||||
|
|
@ -58,22 +58,22 @@ func (c *OpenAIClient) Name() string {
|
|||
|
||||
// openaiRequest is the request body for the OpenAI API
|
||||
type openaiRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"` // Legacy parameter for older models
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // For o1/o3 models
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Tools []openaiTool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", or specific tool
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"` // Legacy parameter for older models
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // For o1/o3 models
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
Tools []openaiTool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"` // "auto", "none", or specific tool
|
||||
}
|
||||
|
||||
// deepseekRequest extends openaiRequest with DeepSeek-specific fields
|
||||
type deepseekRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Tools []openaiTool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []openaiMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
Tools []openaiTool `json:"tools,omitempty"`
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content,omitempty"` // string or null for tool calls
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // For assistant messages with tool calls
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
||||
Content interface{} `json:"content,omitempty"` // string or null for tool calls
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek thinking mode
|
||||
ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` // For assistant messages with tool calls
|
||||
ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages
|
||||
}
|
||||
|
||||
type openaiToolCall struct {
|
||||
|
|
@ -473,14 +473,28 @@ func (c *OpenAIClient) TestConnection(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// ListModels fetches available models from the OpenAI API
|
||||
func (c *OpenAIClient) ListModels(ctx context.Context) ([]ModelInfo, error) {
|
||||
// Determine the models endpoint based on the base URL
|
||||
func (c *OpenAIClient) modelsEndpoint() string {
|
||||
// Default to public API endpoints to preserve current behavior if baseURL is invalid.
|
||||
modelsURL := "https://api.openai.com/v1/models"
|
||||
if c.isDeepSeek() {
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,24 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"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
|
||||
type mockOpenAIResponse struct {
|
||||
ID string `json:"id"`
|
||||
|
|
@ -48,6 +62,9 @@ func TestOpenAIClient_Chat_Success(t *testing.T) {
|
|||
if r.Method != "POST" {
|
||||
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" {
|
||||
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()
|
||||
|
||||
// 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
|
||||
ctx := context.Background()
|
||||
|
|
@ -133,12 +150,15 @@ func TestOpenAIClient_Chat_Success(t *testing.T) {
|
|||
func TestOpenAIClient_Chat_APIError(t *testing.T) {
|
||||
// Create a mock server that returns an error
|
||||
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.Write([]byte(`{"error": {"message": "Invalid API key", "type": "invalid_request_error"}}`))
|
||||
}))
|
||||
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()
|
||||
_, err := client.Chat(ctx, ChatRequest{
|
||||
|
|
@ -173,7 +193,7 @@ func TestOpenAIClient_Chat_ContextCanceled(t *testing.T) {
|
|||
}))
|
||||
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())
|
||||
cancel() // Cancel immediately
|
||||
|
|
@ -216,7 +236,7 @@ func TestOpenAIClient_Chat_WithSystemPrompt(t *testing.T) {
|
|||
}))
|
||||
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()
|
||||
_, 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
|
||||
// the OpenAI client hardcodes the models endpoint URLs (api.openai.com/v1/models).
|
||||
// To properly test these methods, the OpenAI client would need to be refactored
|
||||
func TestOpenAIClient_ListModels_UsesConfiguredHostAndFilters(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("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.
|
||||
//
|
||||
// For now, these tests would require actual API keys to run E2E tests,
|
||||
// which should be done in a separate integration test suite.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue