diff --git a/internal/ai/providers/anthropic.go b/internal/ai/providers/anthropic.go index 5209eea..2ae85f0 100644 --- a/internal/ai/providers/anthropic.go +++ b/internal/ai/providers/anthropic.go @@ -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) } diff --git a/internal/ai/providers/anthropic_oauth.go b/internal/ai/providers/anthropic_oauth.go index ed6cec0..e23b482 100644 --- a/internal/ai/providers/anthropic_oauth.go +++ b/internal/ai/providers/anthropic_oauth.go @@ -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) } diff --git a/internal/ai/providers/anthropic_oauth_test.go b/internal/ai/providers/anthropic_oauth_test.go new file mode 100644 index 0000000..cd426ce --- /dev/null +++ b/internal/ai/providers/anthropic_oauth_test.go @@ -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) + } +} + diff --git a/internal/ai/providers/anthropic_test.go b/internal/ai/providers/anthropic_test.go index 2987b18..94e6336 100644 --- a/internal/ai/providers/anthropic_test.go +++ b/internal/ai/providers/anthropic_test.go @@ -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) + } +} diff --git a/internal/ai/providers/openai.go b/internal/ai/providers/openai.go index 192e323..5cbb8b2 100644 --- a/internal/ai/providers/openai.go +++ b/internal/ai/providers/openai.go @@ -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) diff --git a/internal/ai/providers/openai_test.go b/internal/ai/providers/openai_test.go index 21a91e9..6c120b2 100644 --- a/internal/ai/providers/openai_test.go +++ b/internal/ai/providers/openai_test.go @@ -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. -