diff --git a/config/config.example.json b/config/config.example.json index 55a823009..5b3e1301c 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -43,6 +43,17 @@ "model": "openai/gpt-5.2", "api_key": "sk-key2", "api_base": "https://api2.example.com/v1" + }, + { + "model_name": "qwen-api", + "model": "qwen/qwen-plus", + "api_key": "sk-your-qwen-api-key", + "api_base": "https://dashscope.aliyuncs.com/compatible-mode/v1" + }, + { + "model_name": "qwen-oauth", + "model": "qwen-oauth/coder-model", + "auth_method": "oauth" } ], "channels": { diff --git a/pkg/auth/qwen_oauth.go b/pkg/auth/qwen_oauth.go index f8e763d06..35c979276 100644 --- a/pkg/auth/qwen_oauth.go +++ b/pkg/auth/qwen_oauth.go @@ -18,15 +18,25 @@ import ( // Qwen Portal OAuth constants (extracted from openclaw/openclaw extensions/qwen-portal-auth). // Reference: https://github.com/openclaw/openclaw/tree/main/extensions/qwen-portal-auth const ( - qwenOAuthBaseURL = "https://chat.qwen.ai" - qwenDeviceCodeEndpoint = qwenOAuthBaseURL + "/api/v1/oauth2/device/code" - qwenTokenEndpoint = qwenOAuthBaseURL + "/api/v1/oauth2/token" - // Client ID from OpenClaw qwen-portal-auth extension - qwenClientID = "f0304373b74a44d2b584a3fb70ca9e56" - qwenOAuthScope = "openid profile email model.completion" - qwenDeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + qwenOAuthBaseURL = "https://chat.qwen.ai" + qwenClientID = "f0304373b74a44d2b584a3fb70ca9e56" + qwenOAuthScope = "openid profile email model.completion" + qwenDeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" ) +// qwenEndpointFuncs holds customizable endpoint functions for testing. +var ( + qwenDeviceCodeEndpointFunc = func() string { return qwenOAuthBaseURL + "/api/v1/oauth2/device/code" } + qwenTokenEndpointFunc = func() string { return qwenOAuthBaseURL + "/api/v1/oauth2/token" } +) + +// SetQwenTestEndpoints sets custom endpoints for testing. +// This function is exported for testing purposes only. +func SetQwenTestEndpoints(deviceCodeURL, tokenURL string) { + qwenDeviceCodeEndpointFunc = func() string { return deviceCodeURL } + qwenTokenEndpointFunc = func() string { return tokenURL } +} + // qwenDeviceAuthorization is returned by the device/code endpoint. type qwenDeviceAuthorization struct { DeviceCode string `json:"device_code"` @@ -71,7 +81,7 @@ func requestQwenDeviceCode(challenge string) (*qwenDeviceAuthorization, error) { body.Set("code_challenge", challenge) body.Set("code_challenge_method", "S256") - req, err := http.NewRequest("POST", qwenDeviceCodeEndpoint, strings.NewReader(body.Encode())) + req, err := http.NewRequest("POST", qwenDeviceCodeEndpointFunc(), strings.NewReader(body.Encode())) if err != nil { return nil, err } @@ -119,7 +129,7 @@ func pollQwenToken(deviceCode, verifier string, interval, expiresIn int) (*qwenT for time.Now().Before(deadline) { time.Sleep(pollInterval) - req, err := http.NewRequest("POST", qwenTokenEndpoint, strings.NewReader(body.Encode())) + req, err := http.NewRequest("POST", qwenTokenEndpointFunc(), strings.NewReader(body.Encode())) if err != nil { return nil, err } @@ -247,7 +257,7 @@ func RefreshQwenCredentials(cred *AuthCredential) (*AuthCredential, error) { body.Set("refresh_token", cred.RefreshToken) body.Set("client_id", qwenClientID) - req, err := http.NewRequest("POST", qwenTokenEndpoint, strings.NewReader(body.Encode())) + req, err := http.NewRequest("POST", qwenTokenEndpointFunc(), strings.NewReader(body.Encode())) if err != nil { return nil, err } diff --git a/pkg/auth/qwen_oauth_test.go b/pkg/auth/qwen_oauth_test.go new file mode 100644 index 000000000..1ff84d9df --- /dev/null +++ b/pkg/auth/qwen_oauth_test.go @@ -0,0 +1,522 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestIsQwenOAuthModel(t *testing.T) { + tests := []struct { + model string + expect bool + }{ + {"qwen-oauth/coder-model", true}, + {"qwen-oauth/vision-model", true}, + {"qwen-oauth", true}, + {"qwen/coder-model", false}, + {"openai/gpt-4", false}, + {"", false}, + {"my-qwen-oauth-model", false}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + got := IsQwenOAuthModel(tt.model) + if got != tt.expect { + t.Errorf("IsQwenOAuthModel(%q) = %v, want %v", tt.model, got, tt.expect) + } + }) + } +} + +func TestRequestQwenDeviceCode(t *testing.T) { + expectedDeviceCode := "test-device-code-12345" + expectedUserCode := "ABC-123" + expectedVerificationURI := "https://chat.qwen.ai/verify" + expectedVerificationURIComplete := "https://chat.qwen.ai/verify?code=ABC-123" + expectedExpiresIn := 300 + expectedInterval := 5 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/oauth2/device/code" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Verify content type + contentType := r.Header.Get("Content-Type") + if contentType != "application/x-www-form-urlencoded" { + http.Error(w, "invalid content type", http.StatusBadRequest) + return + } + + // Verify PKCE challenge is present + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if r.FormValue("code_challenge") == "" { + http.Error(w, "missing code_challenge", http.StatusBadRequest) + return + } + if r.FormValue("code_challenge_method") != "S256" { + http.Error(w, "invalid code_challenge_method", http.StatusBadRequest) + return + } + + resp := qwenDeviceAuthorization{ + DeviceCode: expectedDeviceCode, + UserCode: expectedUserCode, + VerificationURI: expectedVerificationURI, + VerificationURIComplete: expectedVerificationURIComplete, + ExpiresIn: expectedExpiresIn, + Interval: expectedInterval, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(server.URL+"/api/v1/oauth2/device/code", qwenOAuthBaseURL+"/api/v1/oauth2/token") + + _, challenge, err := generatePKCE() + if err != nil { + t.Fatalf("generatePKCE() error: %v", err) + } + + da, err := requestQwenDeviceCode(challenge) + if err != nil { + t.Fatalf("requestQwenDeviceCode() error: %v", err) + } + + if da.DeviceCode != expectedDeviceCode { + t.Errorf("DeviceCode = %q, want %q", da.DeviceCode, expectedDeviceCode) + } + + if da.UserCode != expectedUserCode { + t.Errorf("UserCode = %q, want %q", da.UserCode, expectedUserCode) + } + + if da.VerificationURI != expectedVerificationURI { + t.Errorf("VerificationURI = %q, want %q", da.VerificationURI, expectedVerificationURI) + } + + if da.VerificationURIComplete != expectedVerificationURIComplete { + t.Errorf("VerificationURIComplete = %q, want %q", da.VerificationURIComplete, expectedVerificationURIComplete) + } + + if da.ExpiresIn != expectedExpiresIn { + t.Errorf("ExpiresIn = %d, want %d", da.ExpiresIn, expectedExpiresIn) + } + + if da.Interval != expectedInterval { + t.Errorf("Interval = %d, want %d", da.Interval, expectedInterval) + } +} + +func TestRequestQwenDeviceCodeInvalidResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Return invalid JSON + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"invalid": "response"}`)) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(server.URL+"/api/v1/oauth2/device/code", qwenOAuthBaseURL+"/api/v1/oauth2/token") + + _, err := requestQwenDeviceCode("test-challenge") + if err == nil { + t.Error("expected error for invalid response") + } + + if !strings.Contains(err.Error(), "missing device_code or user_code") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPollQwenTokenSuccess(t *testing.T) { + expectedAccessToken := "test-access-token-xyz" + expectedRefreshToken := "test-refresh-token-abc" + expectedExpiresIn := 3600 + expectedTokenType := "Bearer" + + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/oauth2/token" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + callCount++ + + // First two calls return pending, third returns success + if callCount < 3 { + resp := qwenTokenResponse{ + Error: "authorization_pending", + ErrorDescription: "User has not yet authorized", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + // Verify grant_type and code_verifier + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if r.FormValue("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { + http.Error(w, "invalid grant_type", http.StatusBadRequest) + return + } + if r.FormValue("code_verifier") == "" { + http.Error(w, "missing code_verifier", http.StatusBadRequest) + return + } + + resp := qwenTokenResponse{ + AccessToken: expectedAccessToken, + RefreshToken: expectedRefreshToken, + ExpiresIn: expectedExpiresIn, + TokenType: expectedTokenType, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(qwenOAuthBaseURL+"/api/v1/oauth2/device/code", server.URL+"/api/v1/oauth2/token") + + // Use very short interval for testing + tok, err := pollQwenToken("test-device-code", "test-verifier", 1, 30) + if err != nil { + t.Fatalf("pollQwenToken() error: %v", err) + } + + if tok.AccessToken != expectedAccessToken { + t.Errorf("AccessToken = %q, want %q", tok.AccessToken, expectedAccessToken) + } + + if tok.RefreshToken != expectedRefreshToken { + t.Errorf("RefreshToken = %q, want %q", tok.RefreshToken, expectedRefreshToken) + } + + if tok.ExpiresIn != expectedExpiresIn { + t.Errorf("ExpiresIn = %d, want %d", tok.ExpiresIn, expectedExpiresIn) + } + + if tok.TokenType != expectedTokenType { + t.Errorf("TokenType = %q, want %q", tok.TokenType, expectedTokenType) + } +} + +func TestPollQwenTokenAccessDenied(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := qwenTokenResponse{ + Error: "access_denied", + ErrorDescription: "User denied the request", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(qwenOAuthBaseURL+"/api/v1/oauth2/device/code", server.URL+"/api/v1/oauth2/token") + + _, err := pollQwenToken("test-device-code", "test-verifier", 1, 30) + if err == nil { + t.Error("expected error for access_denied") + } + + if !strings.Contains(err.Error(), "authorization denied") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPollQwenTokenExpiredToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := qwenTokenResponse{ + Error: "expired_token", + ErrorDescription: "Device code expired", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(qwenOAuthBaseURL+"/api/v1/oauth2/device/code", server.URL+"/api/v1/oauth2/token") + + _, err := pollQwenToken("test-device-code", "test-verifier", 1, 30) + if err == nil { + t.Error("expected error for expired_token") + } + + if !strings.Contains(err.Error(), "expired") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPollQwenTokenSlowDown(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + // First call returns slow_down + if callCount == 1 { + resp := qwenTokenResponse{ + Error: "slow_down", + ErrorDescription: "Please slow down", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + + // Second call returns success + resp := qwenTokenResponse{ + AccessToken: "test-token", + ExpiresIn: 3600, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(qwenOAuthBaseURL+"/api/v1/oauth2/device/code", server.URL+"/api/v1/oauth2/token") + + tok, err := pollQwenToken("test-device-code", "test-verifier", 1, 30) + if err != nil { + t.Fatalf("pollQwenToken() error: %v", err) + } + + if tok.AccessToken != "test-token" { + t.Errorf("AccessToken = %q, want %q", tok.AccessToken, "test-token") + } +} + +func TestRefreshQwenCredentialsSuccess(t *testing.T) { + expectedAccessToken := "new-access-token" + expectedRefreshToken := "new-refresh-token" + expectedExpiresIn := 7200 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/oauth2/token" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + if r.FormValue("grant_type") != "refresh_token" { + http.Error(w, "invalid grant_type", http.StatusBadRequest) + return + } + + if r.FormValue("refresh_token") != "old-refresh-token" { + http.Error(w, "invalid refresh_token", http.StatusBadRequest) + return + } + + resp := qwenTokenResponse{ + AccessToken: expectedAccessToken, + RefreshToken: expectedRefreshToken, + ExpiresIn: expectedExpiresIn, + TokenType: "Bearer", + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(qwenOAuthBaseURL+"/api/v1/oauth2/device/code", server.URL+"/api/v1/oauth2/token") + + oldCred := &AuthCredential{ + AccessToken: "old-access-token", + RefreshToken: "old-refresh-token", + ExpiresAt: time.Now().Add(-1 * time.Hour), // Expired + Provider: "qwen", + AuthMethod: "oauth", + } + + newCred, err := RefreshQwenCredentials(oldCred) + if err != nil { + t.Fatalf("RefreshQwenCredentials() error: %v", err) + } + + if newCred.AccessToken != expectedAccessToken { + t.Errorf("AccessToken = %q, want %q", newCred.AccessToken, expectedAccessToken) + } + + if newCred.RefreshToken != expectedRefreshToken { + t.Errorf("RefreshToken = %q, want %q", newCred.RefreshToken, expectedRefreshToken) + } + + if newCred.Provider != "qwen" { + t.Errorf("Provider = %q, want %q", newCred.Provider, "qwen") + } +} + +func TestRefreshQwenCredentialsNoRefreshToken(t *testing.T) { + cred := &AuthCredential{ + AccessToken: "some-token", + Provider: "qwen", + AuthMethod: "oauth", + } + + _, err := RefreshQwenCredentials(cred) + if err == nil { + t.Error("expected error for missing refresh token") + } + + if !strings.Contains(err.Error(), "no refresh token available") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestRefreshQwenCredentialsExpired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error": "invalid_grant"}`)) + })) + defer server.Close() + + // Set test endpoint + SetQwenTestEndpoints(qwenOAuthBaseURL+"/api/v1/oauth2/device/code", server.URL+"/api/v1/oauth2/token") + + cred := &AuthCredential{ + AccessToken: "old-token", + RefreshToken: "expired-refresh-token", + Provider: "qwen", + AuthMethod: "oauth", + } + + _, err := RefreshQwenCredentials(cred) + if err == nil { + t.Error("expected error for expired refresh token") + } + + if !strings.Contains(err.Error(), "expired") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCreateQwenTokenSource(t *testing.T) { + // This test verifies the token source closure works correctly + // We can't test the full flow without mocking the auth store, + // but we can verify the function returns a valid closure + + tokenSource := CreateQwenTokenSource() + if tokenSource == nil { + t.Fatal("CreateQwenTokenSource() returned nil") + } + + // Note: The token source will return an error when not authenticated + // This is expected behavior - we just verify the closure is created + _, err := tokenSource() + if err == nil { + // This is actually OK - it means credentials might exist in the test environment + // The important thing is that the closure was created successfully + t.Log("Token source created successfully (credentials may exist in test env)") + } +} + +func TestQwenDeviceAuthorizationStruct(t *testing.T) { + // Test that the struct can be properly unmarshaled + jsonData := `{ + "device_code": "dc-12345", + "user_code": "UC-ABC", + "verification_uri": "https://example.com/verify", + "verification_uri_complete": "https://example.com/verify?code=UC-ABC", + "expires_in": 300, + "interval": 5 + }` + + var da qwenDeviceAuthorization + if err := json.Unmarshal([]byte(jsonData), &da); err != nil { + t.Fatalf("unmarshal qwenDeviceAuthorization error: %v", err) + } + + if da.DeviceCode != "dc-12345" { + t.Errorf("DeviceCode = %q, want %q", da.DeviceCode, "dc-12345") + } + if da.UserCode != "UC-ABC" { + t.Errorf("UserCode = %q, want %q", da.UserCode, "UC-ABC") + } + if da.ExpiresIn != 300 { + t.Errorf("ExpiresIn = %d, want %d", da.ExpiresIn, 300) + } + if da.Interval != 5 { + t.Errorf("Interval = %d, want %d", da.Interval, 5) + } +} + +func TestQwenTokenResponseStruct(t *testing.T) { + // Test success response + jsonData := `{ + "access_token": "at-12345", + "refresh_token": "rt-67890", + "expires_in": 3600, + "token_type": "Bearer", + "resource_url": "https://api.example.com" + }` + + var tr qwenTokenResponse + if err := json.Unmarshal([]byte(jsonData), &tr); err != nil { + t.Fatalf("unmarshal qwenTokenResponse error: %v", err) + } + + if tr.AccessToken != "at-12345" { + t.Errorf("AccessToken = %q, want %q", tr.AccessToken, "at-12345") + } + if tr.RefreshToken != "rt-67890" { + t.Errorf("RefreshToken = %q, want %q", tr.RefreshToken, "rt-67890") + } + if tr.ExpiresIn != 3600 { + t.Errorf("ExpiresIn = %d, want %d", tr.ExpiresIn, 3600) + } + + // Test error response + errorJSON := `{ + "error": "authorization_pending", + "error_description": "User has not yet authorized" + }` + + var tr2 qwenTokenResponse + if err := json.Unmarshal([]byte(errorJSON), &tr2); err != nil { + t.Fatalf("unmarshal error response error: %v", err) + } + + if tr2.Error != "authorization_pending" { + t.Errorf("Error = %q, want %q", tr2.Error, "authorization_pending") + } +} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index e4d5755d2..d27b939da 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -94,7 +94,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "volcengine", "vllm", "qwen", "mistral": + "volcengine", "vllm", "mistral": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -141,12 +141,30 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "qwen-oauth", "qwenoauth", "qwen-portal": // Qwen OAuth (QR code login) + // Supports model strings like: "qwen-oauth/coder-model", "qwenoauth/vision-model", etc. provider, err := createQwenOAuthProvider() if err != nil { return nil, "", err } return provider, modelID, nil + case "qwen": + // Qwen with API key (DashScope OpenAI-compatible API) + if cfg.APIKey == "" && cfg.APIBase == "" { + return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) + } + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, + ), modelID, nil + case "claude-cli", "claudecli": workspace := cfg.Workspace if workspace == "" { diff --git a/pkg/providers/qwen_provider_test.go b/pkg/providers/qwen_provider_test.go new file mode 100644 index 000000000..560488234 --- /dev/null +++ b/pkg/providers/qwen_provider_test.go @@ -0,0 +1,554 @@ +package providers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestQwenOAuthProvider_GetDefaultModel(t *testing.T) { + provider := NewQwenOAuthProvider() + if got := provider.GetDefaultModel(); got != "coder-model" { + t.Errorf("GetDefaultModel() = %q, want %q", got, "coder-model") + } +} + +func TestQwenOAuthProvider_ChatRoundTrip(t *testing.T) { + expectedContent := "Hello! I am Qwen. How can I help you?" + expectedModel := "coder-model" + expectedPromptTokens := 10 + expectedCompletionTokens := 20 + expectedTotalTokens := 30 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + http.Error(w, "not found", http.StatusNotFound) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + // Verify Authorization header + authHeader := r.Header.Get("Authorization") + if !strings.HasPrefix(authHeader, "Bearer ") { + http.Error(w, "missing authorization", http.StatusUnauthorized) + return + } + + // Verify content type + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + http.Error(w, "invalid content type", http.StatusBadRequest) + return + } + + // Parse request body + var reqBody map[string]any + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + + // Verify model + if reqBody["model"] != expectedModel { + http.Error(w, "unexpected model", http.StatusBadRequest) + return + } + + // Return mock response + resp := map[string]any{ + "id": "chatcmpl-test", + "object": "chat.completion", + "created": time.Now().Unix(), + "model": expectedModel, + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": expectedContent, + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{ + "prompt_tokens": expectedPromptTokens, + "completion_tokens": expectedCompletionTokens, + "total_tokens": expectedTotalTokens, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Create provider with mock token source + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth/coder-model", map[string]any{ + "temperature": 0.7, + "max_tokens": 1024, + }) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + if resp.Content != expectedContent { + t.Errorf("Content = %q, want %q", resp.Content, expectedContent) + } + + if resp.Usage.PromptTokens != expectedPromptTokens { + t.Errorf("PromptTokens = %d, want %d", resp.Usage.PromptTokens, expectedPromptTokens) + } + + if resp.Usage.CompletionTokens != expectedCompletionTokens { + t.Errorf("CompletionTokens = %d, want %d", resp.Usage.CompletionTokens, expectedCompletionTokens) + } + + if resp.Usage.TotalTokens != expectedTotalTokens { + t.Errorf("TotalTokens = %d, want %d", resp.Usage.TotalTokens, expectedTotalTokens) + } +} + +func TestQwenOAuthProvider_ChatWithTools(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]any + json.NewDecoder(r.Body).Decode(&reqBody) + + // Verify tools are present + tools, ok := reqBody["tools"].([]any) + if !ok || len(tools) == 0 { + http.Error(w, "missing tools", http.StatusBadRequest) + return + } + + // Return response with tool call + resp := map[string]any{ + "id": "chatcmpl-test", + "model": "coder-model", + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": "", + "tool_calls": []map[string]any{ + { + "id": "call-123", + "type": "function", + "function": map[string]any{ + "name": "search_web", + "arguments": `{"query": "test"}`, + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + "usage": map[string]any{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Search the web"}} + tools := []protocoltypes.ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "search_web", + Description: "Search the web", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + }, + }, + "required": []string{"query"}, + }, + }, + }, + } + + resp, err := provider.Chat(context.Background(), messages, tools, "qwen-oauth", nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + if len(resp.ToolCalls) != 1 { + t.Fatalf("Expected 1 tool call, got %d", len(resp.ToolCalls)) + } + + if resp.ToolCalls[0].Function.Name != "search_web" { + t.Errorf("Tool name = %q, want %q", resp.ToolCalls[0].Function.Name, "search_web") + } +} + +func TestQwenOAuthProvider_ChatUnauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error": {"message": "Invalid token"}}`)) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "invalid-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + _, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth", nil) + if err == nil { + t.Error("expected error for unauthorized request") + } + + if !strings.Contains(err.Error(), "OAuth token rejected") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestQwenOAuthProvider_ChatRateLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error": {"message": "Rate limit exceeded"}}`)) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + _, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth", nil) + if err == nil { + t.Error("expected error for rate limit") + } + + if !strings.Contains(err.Error(), "rate limit") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestQwenOAuthProvider_ChatModelError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": {"message": "Internal server error", "code": "500"}}`)) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + _, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth", nil) + if err == nil { + t.Error("expected error for server error") + } + + if !strings.Contains(err.Error(), "API error") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestQwenOAuthProvider_ModelNameStripping(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"qwen-oauth/coder-model", "coder-model"}, + {"qwen-oauth/vision-model", "vision-model"}, + {"qwen/coder-model", "coder-model"}, + {"coder-model", "coder-model"}, + {"", "coder-model"}, // default + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody map[string]any + json.NewDecoder(r.Body).Decode(&reqBody) + + model := reqBody["model"].(string) + if model != tt.expected { + t.Errorf("model = %q, want %q", model, tt.expected) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "ok"}}, + }, + "usage": map[string]any{"total_tokens": 1}, + }) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + _, err := provider.Chat(context.Background(), []protocoltypes.Message{{Role: "user", Content: "test"}}, nil, tt.input, nil) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + }) + } +} + +func TestQwenOAuthProvider_ChatContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate slow response + time.Sleep(2 * time.Second) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "ok"}}, + }, + }) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + _, err := provider.Chat(ctx, messages, nil, "qwen-oauth", nil) + if err == nil { + t.Error("expected error for cancelled context") + } +} + +func TestQwenOAuthProvider_ParseResponseInvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{invalid json}`)) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + _, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth", nil) + if err == nil { + t.Error("expected error for invalid JSON") + } + + if !strings.Contains(err.Error(), "parsing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestQwenOAuthProvider_ParseResponseNoChoices(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": "test", + "usage": map[string]any{"total_tokens": 0}, + }) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + _, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth", nil) + if err == nil { + t.Error("expected error for missing choices") + } + + if !strings.Contains(err.Error(), "no choices") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestQwenOAuthProvider_OptionsForwarding(t *testing.T) { + var receivedBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "ok"}}, + }, + "usage": map[string]any{"total_tokens": 1}, + }) + })) + defer server.Close() + + tokenSource := func() (string, error) { + return "test-token", nil + } + + provider := NewQwenOAuthProviderWithTokenSource(tokenSource, server.URL+"/v1") + + messages := []protocoltypes.Message{{Role: "user", Content: "Hello"}} + options := map[string]any{ + "temperature": 0.8, + "max_tokens": 2048, + "top_p": 0.9, + } + + _, err := provider.Chat(context.Background(), messages, nil, "qwen-oauth", options) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + + // Verify options were forwarded + if got, want := receivedBody["temperature"], 0.8; got != want { + t.Errorf("temperature = %v, want %v", got, want) + } + if got, want := receivedBody["max_tokens"], float64(2048); got != want { + t.Errorf("max_tokens = %v, want %v", got, want) + } + if got, want := receivedBody["top_p"], 0.9; got != want { + t.Errorf("top_p = %v, want %v", got, want) + } +} + +func TestConvertMessagesForQwen(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hello"}, + { + Role: "assistant", + Content: "Hi!", + ToolCalls: []protocoltypes.ToolCall{ + { + ID: "call-1", + Type: "function", + Function: &protocoltypes.FunctionCall{ + Name: "search", + Arguments: `{"q": "test"}`, + }, + }, + }, + }, + { + Role: "tool", + Content: "Result", + ToolCallID: "call-1", + }, + } + + result := convertMessagesForQwen(messages) + + if len(result) != 4 { + t.Fatalf("Expected 4 messages, got %d", len(result)) + } + + // Check tool call message + if result[2]["tool_calls"] == nil { + t.Error("Expected tool_calls in assistant message") + } + + // Check tool result message + if result[3]["tool_call_id"] != "call-1" { + t.Errorf("tool_call_id = %q, want %q", result[3]["tool_call_id"], "call-1") + } +} + +func TestConvertToolsForQwen(t *testing.T) { + tools := []protocoltypes.ToolDefinition{ + { + Type: "function", + Function: protocoltypes.ToolFunctionDefinition{ + Name: "search", + Description: "Search the web", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + result := convertToolsForQwen(tools) + + if len(result) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(result)) + } + + tool := result[0] + if tool["type"] != "function" { + t.Errorf("type = %q, want %q", tool["type"], "function") + } + + fn, ok := tool["function"].(map[string]any) + if !ok { + t.Fatal("function is not a map") + } + if fn["name"] != "search" { + t.Errorf("name = %q, want %q", fn["name"], "search") + } + if fn["description"] != "Search the web" { + t.Errorf("description = %q, want %q", fn["description"], "Search the web") + } +} + +func TestCreateQwenOAuthProviderFromStore(t *testing.T) { + // This test verifies the factory function exists and returns correct type + // Full integration test would require mocking the auth store + provider, err := createQwenOAuthProvider() + + // We expect an error since we haven't set up credentials + if err == nil { + // If no error, verify provider type + if provider == nil { + t.Error("Expected provider or error") + } + } +}