test: expand AI provider test coverage with HTTP mocks

This commit is contained in:
rcourtman 2025-12-17 15:53:56 +00:00
parent 07f74f5d92
commit cd3a2f14ee
6 changed files with 1013 additions and 85 deletions

View file

@ -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)
}

View file

@ -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)
}

View 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)
}
}

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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.