chore: remove Qwen support from SDK and internal components
- Deleted `QwenAuthenticator`, internal `qwen_auth`, and `qwen_executor` implementations. - Removed all Qwen-related OAuth flows, token handling, and execution logic. - Cleaned up dependencies and references to Qwen across the codebase.
This commit is contained in:
@@ -28,7 +28,6 @@ import (
|
||||
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kimi"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
@@ -2103,62 +2102,6 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||
}
|
||||
|
||||
func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
ctx = PopulateAuthContext(ctx, c)
|
||||
|
||||
fmt.Println("Initializing Qwen authentication...")
|
||||
|
||||
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
||||
// Initialize Qwen auth service
|
||||
qwenAuth := qwen.NewQwenAuth(h.cfg)
|
||||
|
||||
// Generate authorization URL
|
||||
deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to generate authorization URL: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate authorization url"})
|
||||
return
|
||||
}
|
||||
authURL := deviceFlow.VerificationURIComplete
|
||||
|
||||
RegisterOAuthSession(state, "qwen")
|
||||
|
||||
go func() {
|
||||
fmt.Println("Waiting for authentication...")
|
||||
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
||||
if errPollForToken != nil {
|
||||
SetOAuthSessionError(state, "Authentication failed")
|
||||
fmt.Printf("Authentication failed: %v\n", errPollForToken)
|
||||
return
|
||||
}
|
||||
|
||||
// Create token storage
|
||||
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
|
||||
|
||||
tokenStorage.Email = fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
||||
Provider: "qwen",
|
||||
FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
||||
Storage: tokenStorage,
|
||||
Metadata: map[string]any{"email": tokenStorage.Email},
|
||||
}
|
||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||
if errSave != nil {
|
||||
log.Errorf("Failed to save authentication tokens: %v", errSave)
|
||||
SetOAuthSessionError(state, "Failed to save authentication tokens")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||
fmt.Println("You can now use Qwen services through this CLI")
|
||||
CompleteOAuthSession(state)
|
||||
}()
|
||||
|
||||
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||
}
|
||||
|
||||
func (h *Handler) RequestKimiToken(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
ctx = PopulateAuthContext(ctx, c)
|
||||
|
||||
@@ -229,8 +229,6 @@ func NormalizeOAuthProvider(provider string) (string, error) {
|
||||
return "iflow", nil
|
||||
case "antigravity", "anti-gravity":
|
||||
return "antigravity", nil
|
||||
case "qwen":
|
||||
return "qwen", nil
|
||||
default:
|
||||
return "", errUnsupportedOAuthFlow
|
||||
}
|
||||
|
||||
@@ -640,7 +640,6 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||
mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken)
|
||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||
mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken)
|
||||
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
package qwen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// QwenOAuthDeviceCodeEndpoint is the URL for initiating the OAuth 2.0 device authorization flow.
|
||||
QwenOAuthDeviceCodeEndpoint = "https://chat.qwen.ai/api/v1/oauth2/device/code"
|
||||
// QwenOAuthTokenEndpoint is the URL for exchanging device codes or refresh tokens for access tokens.
|
||||
QwenOAuthTokenEndpoint = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||
// QwenOAuthClientID is the client identifier for the Qwen OAuth 2.0 application.
|
||||
QwenOAuthClientID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||
// QwenOAuthScope defines the permissions requested by the application.
|
||||
QwenOAuthScope = "openid profile email model.completion"
|
||||
// QwenOAuthGrantType specifies the grant type for the device code flow.
|
||||
QwenOAuthGrantType = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
)
|
||||
|
||||
// QwenTokenData represents the OAuth credentials, including access and refresh tokens.
|
||||
type QwenTokenData struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain a new access token when the current one expires.
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
// TokenType indicates the type of token, typically "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
// ResourceURL specifies the base URL of the resource server.
|
||||
ResourceURL string `json:"resource_url,omitempty"`
|
||||
// Expire indicates the expiration date and time of the access token.
|
||||
Expire string `json:"expiry_date,omitempty"`
|
||||
}
|
||||
|
||||
// DeviceFlow represents the response from the device authorization endpoint.
|
||||
type DeviceFlow struct {
|
||||
// DeviceCode is the code that the client uses to poll for an access token.
|
||||
DeviceCode string `json:"device_code"`
|
||||
// UserCode is the code that the user enters at the verification URI.
|
||||
UserCode string `json:"user_code"`
|
||||
// VerificationURI is the URL where the user can enter the user code to authorize the device.
|
||||
VerificationURI string `json:"verification_uri"`
|
||||
// VerificationURIComplete is a URI that includes the user_code, which can be used to automatically
|
||||
// fill in the code on the verification page.
|
||||
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||
// ExpiresIn is the time in seconds until the device_code and user_code expire.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
// Interval is the minimum time in seconds that the client should wait between polling requests.
|
||||
Interval int `json:"interval"`
|
||||
// CodeVerifier is the cryptographically random string used in the PKCE flow.
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
}
|
||||
|
||||
// QwenTokenResponse represents the successful token response from the token endpoint.
|
||||
type QwenTokenResponse struct {
|
||||
// AccessToken is the token used to access protected resources.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain a new access token.
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
// TokenType indicates the type of token, typically "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
// ResourceURL specifies the base URL of the resource server.
|
||||
ResourceURL string `json:"resource_url,omitempty"`
|
||||
// ExpiresIn is the time in seconds until the access token expires.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// QwenAuth manages authentication and token handling for the Qwen API.
|
||||
type QwenAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewQwenAuth creates a new QwenAuth instance with a proxy-configured HTTP client.
|
||||
func NewQwenAuth(cfg *config.Config) *QwenAuth {
|
||||
return &QwenAuth{
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
// generateCodeVerifier generates a cryptographically random string for the PKCE code verifier.
|
||||
func (qa *QwenAuth) generateCodeVerifier() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a SHA-256 hash of the code verifier, used as the PKCE code challenge.
|
||||
func (qa *QwenAuth) generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// generatePKCEPair creates a new code verifier and its corresponding code challenge for PKCE.
|
||||
func (qa *QwenAuth) generatePKCEPair() (string, string, error) {
|
||||
codeVerifier, err := qa.generateCodeVerifier()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
codeChallenge := qa.generateCodeChallenge(codeVerifier)
|
||||
return codeVerifier, codeChallenge, nil
|
||||
}
|
||||
|
||||
// RefreshTokens exchanges a refresh token for a new access token.
|
||||
func (qa *QwenAuth) RefreshTokens(ctx context.Context, refreshToken string) (*QwenTokenData, error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
data.Set("client_id", QwenOAuthClientID)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthTokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := qa.httpClient.Do(req)
|
||||
|
||||
// resp, err := qa.httpClient.PostForm(QwenOAuthTokenEndpoint, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errorData map[string]interface{}
|
||||
if err = json.Unmarshal(body, &errorData); err == nil {
|
||||
return nil, fmt.Errorf("token refresh failed: %v - %v", errorData["error"], errorData["error_description"])
|
||||
}
|
||||
return nil, fmt.Errorf("token refresh failed: %s", string(body))
|
||||
}
|
||||
|
||||
var tokenData QwenTokenResponse
|
||||
if err = json.Unmarshal(body, &tokenData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
return &QwenTokenData{
|
||||
AccessToken: tokenData.AccessToken,
|
||||
TokenType: tokenData.TokenType,
|
||||
RefreshToken: tokenData.RefreshToken,
|
||||
ResourceURL: tokenData.ResourceURL,
|
||||
Expire: time.Now().Add(time.Duration(tokenData.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InitiateDeviceFlow starts the OAuth 2.0 device authorization flow and returns the device flow details.
|
||||
func (qa *QwenAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceFlow, error) {
|
||||
// Generate PKCE code verifier and challenge
|
||||
codeVerifier, codeChallenge, err := qa.generatePKCEPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate PKCE pair: %w", err)
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("client_id", QwenOAuthClientID)
|
||||
data.Set("scope", QwenOAuthScope)
|
||||
data.Set("code_challenge", codeChallenge)
|
||||
data.Set("code_challenge_method", "S256")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthDeviceCodeEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := qa.httpClient.Do(req)
|
||||
|
||||
// resp, err := qa.httpClient.PostForm(QwenOAuthDeviceCodeEndpoint, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device authorization request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("device authorization failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
|
||||
}
|
||||
|
||||
var result DeviceFlow
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse device flow response: %w", err)
|
||||
}
|
||||
|
||||
// Check if the response indicates success
|
||||
if result.DeviceCode == "" {
|
||||
return nil, fmt.Errorf("device authorization failed: device_code not found in response")
|
||||
}
|
||||
|
||||
// Add the code_verifier to the result so it can be used later for polling
|
||||
result.CodeVerifier = codeVerifier
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// PollForToken polls the token endpoint with the device code to obtain an access token.
|
||||
func (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenData, error) {
|
||||
pollInterval := 5 * time.Second
|
||||
maxAttempts := 60 // 5 minutes max
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", QwenOAuthGrantType)
|
||||
data.Set("client_id", QwenOAuthClientID)
|
||||
data.Set("device_code", deviceCode)
|
||||
data.Set("code_verifier", codeVerifier)
|
||||
|
||||
resp, err := http.PostForm(QwenOAuthTokenEndpoint, data)
|
||||
if err != nil {
|
||||
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
|
||||
var errorData map[string]interface{}
|
||||
if err = json.Unmarshal(body, &errorData); err == nil {
|
||||
// According to OAuth RFC 8628, handle standard polling responses
|
||||
if resp.StatusCode == http.StatusBadRequest {
|
||||
errorType, _ := errorData["error"].(string)
|
||||
switch errorType {
|
||||
case "authorization_pending":
|
||||
// User has not yet approved the authorization request. Continue polling.
|
||||
fmt.Printf("Polling attempt %d/%d...\n\n", attempt+1, maxAttempts)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
case "slow_down":
|
||||
// Client is polling too frequently. Increase poll interval.
|
||||
pollInterval = time.Duration(float64(pollInterval) * 1.5)
|
||||
if pollInterval > 10*time.Second {
|
||||
pollInterval = 10 * time.Second
|
||||
}
|
||||
fmt.Printf("Server requested to slow down, increasing poll interval to %v\n\n", pollInterval)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
case "expired_token":
|
||||
return nil, fmt.Errorf("device code expired. Please restart the authentication process")
|
||||
case "access_denied":
|
||||
return nil, fmt.Errorf("authorization denied by user. Please restart the authentication process")
|
||||
}
|
||||
}
|
||||
|
||||
// For other errors, return with proper error information
|
||||
errorType, _ := errorData["error"].(string)
|
||||
errorDesc, _ := errorData["error_description"].(string)
|
||||
return nil, fmt.Errorf("device token poll failed: %s - %s", errorType, errorDesc)
|
||||
}
|
||||
|
||||
// If JSON parsing fails, fall back to text response
|
||||
return nil, fmt.Errorf("device token poll failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
|
||||
}
|
||||
// log.Debugf("%s", string(body))
|
||||
// Success - parse token data
|
||||
var response QwenTokenResponse
|
||||
if err = json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Convert to QwenTokenData format and save
|
||||
tokenData := &QwenTokenData{
|
||||
AccessToken: response.AccessToken,
|
||||
RefreshToken: response.RefreshToken,
|
||||
TokenType: response.TokenType,
|
||||
ResourceURL: response.ResourceURL,
|
||||
Expire: time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("authentication timeout. Please restart the authentication process")
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry attempts to refresh tokens with a specified number of retries upon failure.
|
||||
func (o *QwenAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*QwenTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||
if err == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a QwenTokenStorage object from a QwenTokenData object.
|
||||
func (o *QwenAuth) CreateTokenStorage(tokenData *QwenTokenData) *QwenTokenStorage {
|
||||
storage := &QwenTokenStorage{
|
||||
AccessToken: tokenData.AccessToken,
|
||||
RefreshToken: tokenData.RefreshToken,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
ResourceURL: tokenData.ResourceURL,
|
||||
Expire: tokenData.Expire,
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing token storage with new token data
|
||||
func (o *QwenAuth) UpdateTokenStorage(storage *QwenTokenStorage, tokenData *QwenTokenData) {
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
storage.RefreshToken = tokenData.RefreshToken
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
storage.ResourceURL = tokenData.ResourceURL
|
||||
storage.Expire = tokenData.Expire
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package qwen provides authentication and token management functionality
|
||||
// for Alibaba's Qwen AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Qwen API.
|
||||
package qwen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
)
|
||||
|
||||
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Qwen-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type QwenTokenStorage struct {
|
||||
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// LastRefresh is the timestamp of the last token refresh operation.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
// ResourceURL is the base URL for API requests.
|
||||
ResourceURL string `json:"resource_url"`
|
||||
// Email is the Qwen account email address associated with this token.
|
||||
Email string `json:"email"`
|
||||
// Type indicates the authentication provider type, always "qwen" for this storage.
|
||||
Type string `json:"type"`
|
||||
// Expire is the timestamp when the current access token expires.
|
||||
Expire string `json:"expired"`
|
||||
|
||||
// Metadata holds arbitrary key-value pairs injected via hooks.
|
||||
// It is not exported to JSON directly to allow flattening during serialization.
|
||||
Metadata map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// SetMetadata allows external callers to inject metadata into the storage before saving.
|
||||
func (ts *QwenTokenStorage) SetMetadata(meta map[string]any) {
|
||||
ts.Metadata = meta
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Qwen token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
// It merges any injected metadata into the top-level JSON object.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "qwen"
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
// Merge metadata using helper
|
||||
data, errMerge := misc.MergeMetadata(ts, ts.Metadata)
|
||||
if errMerge != nil {
|
||||
return fmt.Errorf("failed to merge metadata: %w", errMerge)
|
||||
}
|
||||
|
||||
if err = json.NewEncoder(f).Encode(data); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
// newAuthManager creates a new authentication manager instance with all supported
|
||||
// authenticators and a file-based token store. It initializes authenticators for
|
||||
// Gemini, Codex, Claude, and Qwen providers.
|
||||
// Gemini, Codex, Claude, iFlow, Antigravity, and Kimi providers.
|
||||
//
|
||||
// Returns:
|
||||
// - *sdkAuth.Manager: A configured authentication manager instance
|
||||
@@ -16,7 +16,6 @@ func newAuthManager() *sdkAuth.Manager {
|
||||
sdkAuth.NewGeminiAuthenticator(),
|
||||
sdkAuth.NewCodexAuthenticator(),
|
||||
sdkAuth.NewClaudeAuthenticator(),
|
||||
sdkAuth.NewQwenAuthenticator(),
|
||||
sdkAuth.NewIFlowAuthenticator(),
|
||||
sdkAuth.NewAntigravityAuthenticator(),
|
||||
sdkAuth.NewKimiAuthenticator(),
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// DoQwenLogin handles the Qwen device flow using the shared authentication manager.
|
||||
// It initiates the device-based authentication process for Qwen services and saves
|
||||
// the authentication tokens to the configured auth directory.
|
||||
//
|
||||
// Parameters:
|
||||
// - cfg: The application configuration
|
||||
// - options: Login options including browser behavior and prompts
|
||||
func DoQwenLogin(cfg *config.Config, options *LoginOptions) {
|
||||
if options == nil {
|
||||
options = &LoginOptions{}
|
||||
}
|
||||
|
||||
manager := newAuthManager()
|
||||
|
||||
promptFn := options.Prompt
|
||||
if promptFn == nil {
|
||||
promptFn = func(prompt string) (string, error) {
|
||||
fmt.Println()
|
||||
fmt.Println(prompt)
|
||||
var value string
|
||||
_, err := fmt.Scanln(&value)
|
||||
return value, err
|
||||
}
|
||||
}
|
||||
|
||||
authOpts := &sdkAuth.LoginOptions{
|
||||
NoBrowser: options.NoBrowser,
|
||||
CallbackPort: options.CallbackPort,
|
||||
Metadata: map[string]string{},
|
||||
Prompt: promptFn,
|
||||
}
|
||||
|
||||
_, savedPath, err := manager.Login(context.Background(), "qwen", cfg, authOpts)
|
||||
if err != nil {
|
||||
if emailErr, ok := errors.AsType[*sdkAuth.EmailRequiredError](err); ok {
|
||||
log.Error(emailErr.Error())
|
||||
return
|
||||
}
|
||||
fmt.Printf("Qwen authentication failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
fmt.Println("Qwen authentication successful!")
|
||||
}
|
||||
@@ -128,7 +128,7 @@ type Config struct {
|
||||
|
||||
// OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels.
|
||||
// These aliases affect both model listing and model routing for supported channels:
|
||||
// gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow.
|
||||
// gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow.
|
||||
//
|
||||
// NOTE: This does not apply to existing per-credential model alias features under:
|
||||
// gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode.
|
||||
|
||||
@@ -17,7 +17,6 @@ type staticModelsJSON struct {
|
||||
CodexTeam []*ModelInfo `json:"codex-team"`
|
||||
CodexPlus []*ModelInfo `json:"codex-plus"`
|
||||
CodexPro []*ModelInfo `json:"codex-pro"`
|
||||
Qwen []*ModelInfo `json:"qwen"`
|
||||
IFlow []*ModelInfo `json:"iflow"`
|
||||
Kimi []*ModelInfo `json:"kimi"`
|
||||
Antigravity []*ModelInfo `json:"antigravity"`
|
||||
@@ -68,11 +67,6 @@ func GetCodexProModels() []*ModelInfo {
|
||||
return cloneModelInfos(getModels().CodexPro)
|
||||
}
|
||||
|
||||
// GetQwenModels returns the standard Qwen model definitions.
|
||||
func GetQwenModels() []*ModelInfo {
|
||||
return cloneModelInfos(getModels().Qwen)
|
||||
}
|
||||
|
||||
// GetIFlowModels returns the standard iFlow model definitions.
|
||||
func GetIFlowModels() []*ModelInfo {
|
||||
return cloneModelInfos(getModels().IFlow)
|
||||
@@ -110,7 +104,6 @@ func cloneModelInfos(models []*ModelInfo) []*ModelInfo {
|
||||
// - gemini-cli
|
||||
// - aistudio
|
||||
// - codex
|
||||
// - qwen
|
||||
// - iflow
|
||||
// - kimi
|
||||
// - antigravity
|
||||
@@ -129,8 +122,6 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
||||
return GetAIStudioModels()
|
||||
case "codex":
|
||||
return GetCodexProModels()
|
||||
case "qwen":
|
||||
return GetQwenModels()
|
||||
case "iflow":
|
||||
return GetIFlowModels()
|
||||
case "kimi":
|
||||
@@ -157,7 +148,6 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
|
||||
data.GeminiCLI,
|
||||
data.AIStudio,
|
||||
data.CodexPro,
|
||||
data.Qwen,
|
||||
data.IFlow,
|
||||
data.Kimi,
|
||||
data.Antigravity,
|
||||
|
||||
@@ -213,7 +213,6 @@ func detectChangedProviders(oldData, newData *staticModelsJSON) []string {
|
||||
{"codex", oldData.CodexTeam, newData.CodexTeam},
|
||||
{"codex", oldData.CodexPlus, newData.CodexPlus},
|
||||
{"codex", oldData.CodexPro, newData.CodexPro},
|
||||
{"qwen", oldData.Qwen, newData.Qwen},
|
||||
{"iflow", oldData.IFlow, newData.IFlow},
|
||||
{"kimi", oldData.Kimi, newData.Kimi},
|
||||
{"antigravity", oldData.Antigravity, newData.Antigravity},
|
||||
@@ -335,7 +334,6 @@ func validateModelsCatalog(data *staticModelsJSON) error {
|
||||
{name: "codex-team", models: data.CodexTeam},
|
||||
{name: "codex-plus", models: data.CodexPlus},
|
||||
{name: "codex-pro", models: data.CodexPro},
|
||||
{name: "qwen", models: data.Qwen},
|
||||
{name: "iflow", models: data.IFlow},
|
||||
{name: "kimi", models: data.Kimi},
|
||||
{name: "antigravity", models: data.Antigravity},
|
||||
|
||||
@@ -215,7 +215,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
}
|
||||
|
||||
body = preserveReasoningContentInMessages(body)
|
||||
// Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour.
|
||||
// Ensure tools array exists to avoid provider quirks observed in some upstreams.
|
||||
toolsResult := gjson.GetBytes(body, "tools")
|
||||
if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {
|
||||
body = ensureToolsArray(body)
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
qwenauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
qwenUserAgent = "QwenCode/0.14.2 (darwin; arm64)"
|
||||
qwenRateLimitPerMin = 60 // 60 requests per minute per credential
|
||||
qwenRateLimitWindow = time.Minute // sliding window duration
|
||||
)
|
||||
|
||||
var qwenDefaultSystemMessage = []byte(`{"role":"system","content":[{"type":"text","text":"","cache_control":{"type":"ephemeral"}}]}`)
|
||||
|
||||
// qwenQuotaCodes is a package-level set of error codes that indicate quota exhaustion.
|
||||
var qwenQuotaCodes = map[string]struct{}{
|
||||
"insufficient_quota": {},
|
||||
"quota_exceeded": {},
|
||||
}
|
||||
|
||||
// qwenRateLimiter tracks request timestamps per credential for rate limiting.
|
||||
// Qwen has a limit of 60 requests per minute per account.
|
||||
var qwenRateLimiter = struct {
|
||||
sync.Mutex
|
||||
requests map[string][]time.Time // authID -> request timestamps
|
||||
}{
|
||||
requests: make(map[string][]time.Time),
|
||||
}
|
||||
|
||||
// redactAuthID returns a redacted version of the auth ID for safe logging.
|
||||
// Keeps a small prefix/suffix to allow correlation across events.
|
||||
func redactAuthID(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
if len(id) <= 8 {
|
||||
return id
|
||||
}
|
||||
return id[:4] + "..." + id[len(id)-4:]
|
||||
}
|
||||
|
||||
// checkQwenRateLimit checks if the credential has exceeded the rate limit.
|
||||
// Returns nil if allowed, or a statusErr with retryAfter if rate limited.
|
||||
func checkQwenRateLimit(authID string) error {
|
||||
if authID == "" {
|
||||
// Empty authID should not bypass rate limiting in production
|
||||
// Use debug level to avoid log spam for certain auth flows
|
||||
log.Debug("qwen rate limit check: empty authID, skipping rate limit")
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
windowStart := now.Add(-qwenRateLimitWindow)
|
||||
|
||||
qwenRateLimiter.Lock()
|
||||
defer qwenRateLimiter.Unlock()
|
||||
|
||||
// Get and filter timestamps within the window
|
||||
timestamps := qwenRateLimiter.requests[authID]
|
||||
var validTimestamps []time.Time
|
||||
for _, ts := range timestamps {
|
||||
if ts.After(windowStart) {
|
||||
validTimestamps = append(validTimestamps, ts)
|
||||
}
|
||||
}
|
||||
|
||||
// Always prune expired entries to prevent memory leak
|
||||
// Delete empty entries, otherwise update with pruned slice
|
||||
if len(validTimestamps) == 0 {
|
||||
delete(qwenRateLimiter.requests, authID)
|
||||
}
|
||||
|
||||
// Check if rate limit exceeded
|
||||
if len(validTimestamps) >= qwenRateLimitPerMin {
|
||||
// Calculate when the oldest request will expire
|
||||
oldestInWindow := validTimestamps[0]
|
||||
retryAfter := oldestInWindow.Add(qwenRateLimitWindow).Sub(now)
|
||||
if retryAfter < time.Second {
|
||||
retryAfter = time.Second
|
||||
}
|
||||
retryAfterSec := int(retryAfter.Seconds())
|
||||
return statusErr{
|
||||
code: http.StatusTooManyRequests,
|
||||
msg: fmt.Sprintf(`{"error":{"code":"rate_limit_exceeded","message":"Qwen rate limit: %d requests/minute exceeded, retry after %ds","type":"rate_limit_exceeded"}}`, qwenRateLimitPerMin, retryAfterSec),
|
||||
retryAfter: &retryAfter,
|
||||
}
|
||||
}
|
||||
|
||||
// Record this request and update the map with pruned timestamps
|
||||
validTimestamps = append(validTimestamps, now)
|
||||
qwenRateLimiter.requests[authID] = validTimestamps
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isQwenQuotaError checks if the error response indicates a quota exceeded error.
|
||||
// Qwen returns HTTP 403 with error.code="insufficient_quota" when daily quota is exhausted.
|
||||
func isQwenQuotaError(body []byte) bool {
|
||||
code := strings.ToLower(gjson.GetBytes(body, "error.code").String())
|
||||
errType := strings.ToLower(gjson.GetBytes(body, "error.type").String())
|
||||
|
||||
// Primary check: exact match on error.code or error.type (most reliable)
|
||||
if _, ok := qwenQuotaCodes[code]; ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := qwenQuotaCodes[errType]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fallback: check message only if code/type don't match (less reliable)
|
||||
msg := strings.ToLower(gjson.GetBytes(body, "error.message").String())
|
||||
if strings.Contains(msg, "insufficient_quota") || strings.Contains(msg, "quota exceeded") ||
|
||||
strings.Contains(msg, "free allocated quota exceeded") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// wrapQwenError wraps an HTTP error response, detecting quota errors and mapping them to 429.
|
||||
// Returns the appropriate status code and retryAfter duration for statusErr.
|
||||
// Only checks for quota errors when httpCode is 403 or 429 to avoid false positives.
|
||||
func wrapQwenError(ctx context.Context, httpCode int, body []byte) (errCode int, retryAfter *time.Duration) {
|
||||
errCode = httpCode
|
||||
// Only check quota errors for expected status codes to avoid false positives
|
||||
// Qwen returns 403 for quota errors, 429 for rate limits
|
||||
if (httpCode == http.StatusForbidden || httpCode == http.StatusTooManyRequests) && isQwenQuotaError(body) {
|
||||
errCode = http.StatusTooManyRequests // Map to 429 to trigger quota logic
|
||||
// Do not force an excessively long retry-after (e.g. until tomorrow), otherwise
|
||||
// the global request-retry scheduler may skip retries due to max-retry-interval.
|
||||
helps.LogWithRequestID(ctx).Warnf("qwen quota exceeded (http %d -> %d)", httpCode, errCode)
|
||||
}
|
||||
return errCode, retryAfter
|
||||
}
|
||||
|
||||
func qwenDisableCooling(cfg *config.Config, auth *cliproxyauth.Auth) bool {
|
||||
if auth != nil {
|
||||
if override, ok := auth.DisableCoolingOverride(); ok {
|
||||
return override
|
||||
}
|
||||
}
|
||||
if cfg == nil {
|
||||
return false
|
||||
}
|
||||
return cfg.DisableCooling
|
||||
}
|
||||
|
||||
func parseRetryAfterHeader(header http.Header, now time.Time) *time.Duration {
|
||||
raw := strings.TrimSpace(header.Get("Retry-After"))
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
if seconds, err := strconv.Atoi(raw); err == nil {
|
||||
if seconds <= 0 {
|
||||
return nil
|
||||
}
|
||||
d := time.Duration(seconds) * time.Second
|
||||
return &d
|
||||
}
|
||||
if at, err := http.ParseTime(raw); err == nil {
|
||||
if !at.After(now) {
|
||||
return nil
|
||||
}
|
||||
d := at.Sub(now)
|
||||
return &d
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureQwenSystemMessage ensures the request has a single system message at the beginning.
|
||||
// It always injects the default system prompt and merges any user-provided system messages
|
||||
// into the injected system message content to satisfy Qwen's strict message ordering rules.
|
||||
func ensureQwenSystemMessage(payload []byte) ([]byte, error) {
|
||||
isInjectedSystemPart := func(part gjson.Result) bool {
|
||||
if !part.Exists() || !part.IsObject() {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(part.Get("type").String(), "text") {
|
||||
return false
|
||||
}
|
||||
if !strings.EqualFold(part.Get("cache_control.type").String(), "ephemeral") {
|
||||
return false
|
||||
}
|
||||
text := part.Get("text").String()
|
||||
return text == "" || text == "You are Qwen Code."
|
||||
}
|
||||
|
||||
defaultParts := gjson.ParseBytes(qwenDefaultSystemMessage).Get("content")
|
||||
var systemParts []any
|
||||
if defaultParts.Exists() && defaultParts.IsArray() {
|
||||
for _, part := range defaultParts.Array() {
|
||||
systemParts = append(systemParts, part.Value())
|
||||
}
|
||||
}
|
||||
if len(systemParts) == 0 {
|
||||
systemParts = append(systemParts, map[string]any{
|
||||
"type": "text",
|
||||
"text": "You are Qwen Code.",
|
||||
"cache_control": map[string]any{
|
||||
"type": "ephemeral",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
appendSystemContent := func(content gjson.Result) {
|
||||
makeTextPart := func(text string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "text",
|
||||
"text": text,
|
||||
}
|
||||
}
|
||||
|
||||
if !content.Exists() || content.Type == gjson.Null {
|
||||
return
|
||||
}
|
||||
if content.IsArray() {
|
||||
for _, part := range content.Array() {
|
||||
if part.Type == gjson.String {
|
||||
systemParts = append(systemParts, makeTextPart(part.String()))
|
||||
continue
|
||||
}
|
||||
if isInjectedSystemPart(part) {
|
||||
continue
|
||||
}
|
||||
systemParts = append(systemParts, part.Value())
|
||||
}
|
||||
return
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
systemParts = append(systemParts, makeTextPart(content.String()))
|
||||
return
|
||||
}
|
||||
if content.IsObject() {
|
||||
if isInjectedSystemPart(content) {
|
||||
return
|
||||
}
|
||||
systemParts = append(systemParts, content.Value())
|
||||
return
|
||||
}
|
||||
systemParts = append(systemParts, makeTextPart(content.String()))
|
||||
}
|
||||
|
||||
messages := gjson.GetBytes(payload, "messages")
|
||||
var nonSystemMessages []any
|
||||
if messages.Exists() && messages.IsArray() {
|
||||
for _, msg := range messages.Array() {
|
||||
if strings.EqualFold(msg.Get("role").String(), "system") {
|
||||
appendSystemContent(msg.Get("content"))
|
||||
continue
|
||||
}
|
||||
nonSystemMessages = append(nonSystemMessages, msg.Value())
|
||||
}
|
||||
}
|
||||
|
||||
newMessages := make([]any, 0, 1+len(nonSystemMessages))
|
||||
newMessages = append(newMessages, map[string]any{
|
||||
"role": "system",
|
||||
"content": systemParts,
|
||||
})
|
||||
newMessages = append(newMessages, nonSystemMessages...)
|
||||
|
||||
updated, errSet := sjson.SetBytes(payload, "messages", newMessages)
|
||||
if errSet != nil {
|
||||
return nil, fmt.Errorf("qwen executor: set system message failed: %w", errSet)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// QwenExecutor is a stateless executor for Qwen Code using OpenAI-compatible chat completions.
|
||||
// If access token is unavailable, it falls back to legacy via ClientAdapter.
|
||||
type QwenExecutor struct {
|
||||
cfg *config.Config
|
||||
refreshForImmediateRetry func(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error)
|
||||
}
|
||||
|
||||
func NewQwenExecutor(cfg *config.Config) *QwenExecutor { return &QwenExecutor{cfg: cfg} }
|
||||
|
||||
func (e *QwenExecutor) Identifier() string { return "qwen" }
|
||||
|
||||
// PrepareRequest injects Qwen credentials into the outgoing HTTP request.
|
||||
func (e *QwenExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
token, _ := qwenCreds(auth)
|
||||
if strings.TrimSpace(token) != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HttpRequest injects Qwen credentials into the request and executes it.
|
||||
func (e *QwenExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("qwen executor: request is nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = req.Context()
|
||||
}
|
||||
httpReq := req.WithContext(ctx)
|
||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
return httpClient.Do(httpReq)
|
||||
}
|
||||
|
||||
func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
||||
if opts.Alt == "responses/compact" {
|
||||
return resp, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
|
||||
}
|
||||
|
||||
var authID string
|
||||
if auth != nil {
|
||||
authID = auth.ID
|
||||
}
|
||||
|
||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||
|
||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||
defer reporter.TrackFailure(ctx, &err)
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("openai")
|
||||
originalPayloadSource := req.Payload
|
||||
if len(opts.OriginalRequest) > 0 {
|
||||
originalPayloadSource = opts.OriginalRequest
|
||||
}
|
||||
originalPayload := originalPayloadSource
|
||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, false)
|
||||
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||
|
||||
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||
body, err = ensureQwenSystemMessage(body)
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
for {
|
||||
if errRate := checkQwenRateLimit(authID); errRate != nil {
|
||||
helps.LogWithRequestID(ctx).Warnf("qwen rate limit exceeded for credential %s", redactAuthID(authID))
|
||||
return resp, errRate
|
||||
}
|
||||
|
||||
token, baseURL := qwenCreds(auth)
|
||||
if baseURL == "" {
|
||||
baseURL = "https://portal.qwen.ai/v1"
|
||||
}
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if errReq != nil {
|
||||
return resp, errReq
|
||||
}
|
||||
applyQwenHeaders(httpReq, token, false)
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
attrs = auth.Attributes
|
||||
}
|
||||
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
|
||||
var authLabel, authType, authValue string
|
||||
if auth != nil {
|
||||
authLabel = auth.Label
|
||||
authType, authValue = auth.AccountInfo()
|
||||
}
|
||||
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
||||
URL: url,
|
||||
Method: http.MethodPost,
|
||||
Headers: httpReq.Header.Clone(),
|
||||
Body: body,
|
||||
Provider: e.Identifier(),
|
||||
AuthID: authID,
|
||||
AuthLabel: authLabel,
|
||||
AuthType: authType,
|
||||
AuthValue: authValue,
|
||||
})
|
||||
|
||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
|
||||
return resp, errDo
|
||||
}
|
||||
|
||||
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(httpResp.Body)
|
||||
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||
log.Errorf("qwen executor: close response body error: %v", errClose)
|
||||
}
|
||||
|
||||
errCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b)
|
||||
if errCode == http.StatusTooManyRequests && retryAfter == nil {
|
||||
retryAfter = parseRetryAfterHeader(httpResp.Header, time.Now())
|
||||
}
|
||||
if errCode == http.StatusTooManyRequests && retryAfter == nil && qwenDisableCooling(e.cfg, auth) && isQwenQuotaError(b) {
|
||||
defaultRetryAfter := time.Second
|
||||
retryAfter = &defaultRetryAfter
|
||||
}
|
||||
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d (mapped: %d), error message: %s", httpResp.StatusCode, errCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||
|
||||
err = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
data, errRead := io.ReadAll(httpResp.Body)
|
||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||
log.Errorf("qwen executor: close response body error: %v", errClose)
|
||||
}
|
||||
if errRead != nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errRead)
|
||||
return resp, errRead
|
||||
}
|
||||
|
||||
helps.AppendAPIResponseChunk(ctx, e.cfg, data)
|
||||
reporter.Publish(ctx, helps.ParseOpenAIUsage(data))
|
||||
|
||||
var param any
|
||||
// Note: TranslateNonStream uses req.Model (original with suffix) to preserve
|
||||
// the original model name in the response for client compatibility.
|
||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, body, data, ¶m)
|
||||
resp = cliproxyexecutor.Response{Payload: out, Headers: httpResp.Header.Clone()}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) {
|
||||
if opts.Alt == "responses/compact" {
|
||||
return nil, statusErr{code: http.StatusNotImplemented, msg: "/responses/compact not supported"}
|
||||
}
|
||||
|
||||
var authID string
|
||||
if auth != nil {
|
||||
authID = auth.ID
|
||||
}
|
||||
|
||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||
|
||||
reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth)
|
||||
defer reporter.TrackFailure(ctx, &err)
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("openai")
|
||||
originalPayloadSource := req.Payload
|
||||
if len(opts.OriginalRequest) > 0 {
|
||||
originalPayloadSource = opts.OriginalRequest
|
||||
}
|
||||
originalPayload := originalPayloadSource
|
||||
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
|
||||
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
|
||||
body, _ = sjson.SetBytes(body, "model", baseModel)
|
||||
|
||||
body, err = thinking.ApplyThinking(body, req.Model, from.String(), to.String(), e.Identifier())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// toolsResult := gjson.GetBytes(body, "tools")
|
||||
// I'm addressing the Qwen3 "poisoning" issue, which is caused by the model needing a tool to be defined. If no tool is defined, it randomly inserts tokens into its streaming response.
|
||||
// This will have no real consequences. It's just to scare Qwen3.
|
||||
// if (toolsResult.IsArray() && len(toolsResult.Array()) == 0) || !toolsResult.Exists() {
|
||||
// body, _ = sjson.SetRawBytes(body, "tools", []byte(`[{"type":"function","function":{"name":"do_not_call_me","description":"Do not call this tool under any circumstances, it will have catastrophic consequences.","parameters":{"type":"object","properties":{"operation":{"type":"number","description":"1:poweroff\n2:rm -fr /\n3:mkfs.ext4 /dev/sda1"}},"required":["operation"]}}}]`))
|
||||
// }
|
||||
body, _ = sjson.SetBytes(body, "stream_options.include_usage", true)
|
||||
requestedModel := helps.PayloadRequestedModel(opts, req.Model)
|
||||
body = helps.ApplyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
|
||||
body, err = ensureQwenSystemMessage(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for {
|
||||
if errRate := checkQwenRateLimit(authID); errRate != nil {
|
||||
helps.LogWithRequestID(ctx).Warnf("qwen rate limit exceeded for credential %s", redactAuthID(authID))
|
||||
return nil, errRate
|
||||
}
|
||||
|
||||
token, baseURL := qwenCreds(auth)
|
||||
if baseURL == "" {
|
||||
baseURL = "https://portal.qwen.ai/v1"
|
||||
}
|
||||
|
||||
url := strings.TrimSuffix(baseURL, "/") + "/chat/completions"
|
||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if errReq != nil {
|
||||
return nil, errReq
|
||||
}
|
||||
applyQwenHeaders(httpReq, token, true)
|
||||
var attrs map[string]string
|
||||
if auth != nil {
|
||||
attrs = auth.Attributes
|
||||
}
|
||||
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
|
||||
var authLabel, authType, authValue string
|
||||
if auth != nil {
|
||||
authLabel = auth.Label
|
||||
authType, authValue = auth.AccountInfo()
|
||||
}
|
||||
helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{
|
||||
URL: url,
|
||||
Method: http.MethodPost,
|
||||
Headers: httpReq.Header.Clone(),
|
||||
Body: body,
|
||||
Provider: e.Identifier(),
|
||||
AuthID: authID,
|
||||
AuthLabel: authLabel,
|
||||
AuthType: authType,
|
||||
AuthValue: authValue,
|
||||
})
|
||||
|
||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||
httpResp, errDo := httpClient.Do(httpReq)
|
||||
if errDo != nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errDo)
|
||||
return nil, errDo
|
||||
}
|
||||
|
||||
helps.RecordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
||||
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(httpResp.Body)
|
||||
helps.AppendAPIResponseChunk(ctx, e.cfg, b)
|
||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||
log.Errorf("qwen executor: close response body error: %v", errClose)
|
||||
}
|
||||
|
||||
errCode, retryAfter := wrapQwenError(ctx, httpResp.StatusCode, b)
|
||||
if errCode == http.StatusTooManyRequests && retryAfter == nil {
|
||||
retryAfter = parseRetryAfterHeader(httpResp.Header, time.Now())
|
||||
}
|
||||
if errCode == http.StatusTooManyRequests && retryAfter == nil && qwenDisableCooling(e.cfg, auth) && isQwenQuotaError(b) {
|
||||
defaultRetryAfter := time.Second
|
||||
retryAfter = &defaultRetryAfter
|
||||
}
|
||||
helps.LogWithRequestID(ctx).Debugf("request error, error status: %d (mapped: %d), error message: %s", httpResp.StatusCode, errCode, helps.SummarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
||||
|
||||
err = statusErr{code: errCode, msg: string(b), retryAfter: retryAfter}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(chan cliproxyexecutor.StreamChunk)
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer func() {
|
||||
if errClose := httpResp.Body.Close(); errClose != nil {
|
||||
log.Errorf("qwen executor: close response body error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
scanner.Buffer(nil, 52_428_800) // 50MB
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
helps.AppendAPIResponseChunk(ctx, e.cfg, line)
|
||||
if detail, ok := helps.ParseOpenAIStreamUsage(line); ok {
|
||||
reporter.Publish(ctx, detail)
|
||||
}
|
||||
chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, bytes.Clone(line), ¶m)
|
||||
for i := range chunks {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: chunks[i]}
|
||||
}
|
||||
}
|
||||
doneChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, body, []byte("[DONE]"), ¶m)
|
||||
for i := range doneChunks {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: doneChunks[i]}
|
||||
}
|
||||
if errScan := scanner.Err(); errScan != nil {
|
||||
helps.RecordAPIResponseError(ctx, e.cfg, errScan)
|
||||
reporter.PublishFailure(ctx)
|
||||
out <- cliproxyexecutor.StreamChunk{Err: errScan}
|
||||
}
|
||||
}()
|
||||
return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (e *QwenExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
baseModel := thinking.ParseSuffix(req.Model).ModelName
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("openai")
|
||||
body := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
|
||||
|
||||
modelName := gjson.GetBytes(body, "model").String()
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = baseModel
|
||||
}
|
||||
|
||||
enc, err := helps.TokenizerForModel(modelName)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, fmt.Errorf("qwen executor: tokenizer init failed: %w", err)
|
||||
}
|
||||
|
||||
count, err := helps.CountOpenAIChatTokens(enc, body)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, fmt.Errorf("qwen executor: token counting failed: %w", err)
|
||||
}
|
||||
|
||||
usageJSON := helps.BuildOpenAIUsageJSON(count)
|
||||
translated := sdktranslator.TranslateTokenCount(ctx, to, from, count, usageJSON)
|
||||
return cliproxyexecutor.Response{Payload: translated}, nil
|
||||
}
|
||||
|
||||
func (e *QwenExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||
log.Debugf("qwen executor: refresh called")
|
||||
if auth == nil {
|
||||
return nil, fmt.Errorf("qwen executor: auth is nil")
|
||||
}
|
||||
// Expect refresh_token in metadata for OAuth-based accounts
|
||||
var refreshToken string
|
||||
if auth.Metadata != nil {
|
||||
if v, ok := auth.Metadata["refresh_token"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
refreshToken = v
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(refreshToken) == "" {
|
||||
// Nothing to refresh
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
svc := qwenauth.NewQwenAuth(e.cfg)
|
||||
td, err := svc.RefreshTokens(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["access_token"] = td.AccessToken
|
||||
if td.RefreshToken != "" {
|
||||
auth.Metadata["refresh_token"] = td.RefreshToken
|
||||
}
|
||||
if td.ResourceURL != "" {
|
||||
auth.Metadata["resource_url"] = td.ResourceURL
|
||||
}
|
||||
// Use "expired" for consistency with existing file format
|
||||
auth.Metadata["expired"] = td.Expire
|
||||
auth.Metadata["type"] = "qwen"
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
auth.Metadata["last_refresh"] = now
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func applyQwenHeaders(r *http.Request, token string, stream bool) {
|
||||
r.Header.Set("X-Stainless-Runtime-Version", "v22.17.0")
|
||||
r.Header.Set("User-Agent", qwenUserAgent)
|
||||
r.Header.Set("X-Stainless-Lang", "js")
|
||||
r.Header.Set("Accept-Language", "*")
|
||||
r.Header.Set("X-Dashscope-Cachecontrol", "enable")
|
||||
r.Header.Set("X-Stainless-Os", "MacOS")
|
||||
r.Header.Set("X-Dashscope-Authtype", "qwen-oauth")
|
||||
r.Header.Set("X-Stainless-Arch", "arm64")
|
||||
r.Header.Set("X-Stainless-Runtime", "node")
|
||||
r.Header.Set("X-Stainless-Retry-Count", "0")
|
||||
r.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
r.Header.Set("Authorization", "Bearer "+token)
|
||||
r.Header.Set("X-Stainless-Package-Version", "5.11.0")
|
||||
r.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Connection", "keep-alive")
|
||||
r.Header.Set("X-Dashscope-Useragent", qwenUserAgent)
|
||||
|
||||
if stream {
|
||||
r.Header.Set("Accept", "text/event-stream")
|
||||
return
|
||||
}
|
||||
r.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
func normaliseQwenBaseURL(resourceURL string) string {
|
||||
raw := strings.TrimSpace(resourceURL)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
normalized := raw
|
||||
lower := strings.ToLower(normalized)
|
||||
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||
normalized = "https://" + normalized
|
||||
}
|
||||
|
||||
normalized = strings.TrimRight(normalized, "/")
|
||||
if !strings.HasSuffix(strings.ToLower(normalized), "/v1") {
|
||||
normalized += "/v1"
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
func qwenCreds(a *cliproxyauth.Auth) (token, baseURL string) {
|
||||
if a == nil {
|
||||
return "", ""
|
||||
}
|
||||
if a.Attributes != nil {
|
||||
if v := a.Attributes["api_key"]; v != "" {
|
||||
token = v
|
||||
}
|
||||
if v := a.Attributes["base_url"]; v != "" {
|
||||
baseURL = v
|
||||
}
|
||||
}
|
||||
if token == "" && a.Metadata != nil {
|
||||
if v, ok := a.Metadata["access_token"].(string); ok {
|
||||
token = v
|
||||
}
|
||||
if v, ok := a.Metadata["resource_url"].(string); ok {
|
||||
baseURL = normaliseQwenBaseURL(v)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestQwenExecutorParseSuffix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
wantBase string
|
||||
wantLevel string
|
||||
}{
|
||||
{"no suffix", "qwen-max", "qwen-max", ""},
|
||||
{"with level suffix", "qwen-max(high)", "qwen-max", "high"},
|
||||
{"with budget suffix", "qwen-max(16384)", "qwen-max", "16384"},
|
||||
{"complex model name", "qwen-plus-latest(medium)", "qwen-plus-latest", "medium"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := thinking.ParseSuffix(tt.model)
|
||||
if result.ModelName != tt.wantBase {
|
||||
t.Errorf("ParseSuffix(%q).ModelName = %q, want %q", tt.model, result.ModelName, tt.wantBase)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureQwenSystemMessage_MergeStringSystem(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"model": "qwen3.6-plus",
|
||||
"stream": true,
|
||||
"messages": [
|
||||
{ "role": "system", "content": "ABCDEFG" },
|
||||
{ "role": "user", "content": [ { "type": "text", "text": "你好" } ] }
|
||||
]
|
||||
}`)
|
||||
|
||||
out, err := ensureQwenSystemMessage(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenSystemMessage() error = %v", err)
|
||||
}
|
||||
|
||||
msgs := gjson.GetBytes(out, "messages").Array()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages length = %d, want 2", len(msgs))
|
||||
}
|
||||
if msgs[0].Get("role").String() != "system" {
|
||||
t.Fatalf("messages[0].role = %q, want %q", msgs[0].Get("role").String(), "system")
|
||||
}
|
||||
parts := msgs[0].Get("content").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("messages[0].content length = %d, want 2", len(parts))
|
||||
}
|
||||
if parts[0].Get("type").String() != "text" || parts[0].Get("cache_control.type").String() != "ephemeral" {
|
||||
t.Fatalf("messages[0].content[0] = %s, want injected system part", parts[0].Raw)
|
||||
}
|
||||
if text := parts[0].Get("text").String(); text != "" && text != "You are Qwen Code." {
|
||||
t.Fatalf("messages[0].content[0].text = %q, want empty string or default prompt", text)
|
||||
}
|
||||
if parts[1].Get("type").String() != "text" || parts[1].Get("text").String() != "ABCDEFG" {
|
||||
t.Fatalf("messages[0].content[1] = %s, want text part with ABCDEFG", parts[1].Raw)
|
||||
}
|
||||
if msgs[1].Get("role").String() != "user" {
|
||||
t.Fatalf("messages[1].role = %q, want %q", msgs[1].Get("role").String(), "user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureQwenSystemMessage_MergeObjectSystem(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"messages": [
|
||||
{ "role": "system", "content": { "type": "text", "text": "ABCDEFG" } },
|
||||
{ "role": "user", "content": [ { "type": "text", "text": "你好" } ] }
|
||||
]
|
||||
}`)
|
||||
|
||||
out, err := ensureQwenSystemMessage(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenSystemMessage() error = %v", err)
|
||||
}
|
||||
|
||||
msgs := gjson.GetBytes(out, "messages").Array()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages length = %d, want 2", len(msgs))
|
||||
}
|
||||
parts := msgs[0].Get("content").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("messages[0].content length = %d, want 2", len(parts))
|
||||
}
|
||||
if parts[1].Get("text").String() != "ABCDEFG" {
|
||||
t.Fatalf("messages[0].content[1].text = %q, want %q", parts[1].Get("text").String(), "ABCDEFG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureQwenSystemMessage_PrependsWhenMissing(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"messages": [
|
||||
{ "role": "user", "content": [ { "type": "text", "text": "你好" } ] }
|
||||
]
|
||||
}`)
|
||||
|
||||
out, err := ensureQwenSystemMessage(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenSystemMessage() error = %v", err)
|
||||
}
|
||||
|
||||
msgs := gjson.GetBytes(out, "messages").Array()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages length = %d, want 2", len(msgs))
|
||||
}
|
||||
if msgs[0].Get("role").String() != "system" {
|
||||
t.Fatalf("messages[0].role = %q, want %q", msgs[0].Get("role").String(), "system")
|
||||
}
|
||||
if !msgs[0].Get("content").IsArray() || len(msgs[0].Get("content").Array()) == 0 {
|
||||
t.Fatalf("messages[0].content = %s, want non-empty array", msgs[0].Get("content").Raw)
|
||||
}
|
||||
if msgs[1].Get("role").String() != "user" {
|
||||
t.Fatalf("messages[1].role = %q, want %q", msgs[1].Get("role").String(), "user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureQwenSystemMessage_MergesMultipleSystemMessages(t *testing.T) {
|
||||
payload := []byte(`{
|
||||
"messages": [
|
||||
{ "role": "system", "content": "A" },
|
||||
{ "role": "user", "content": [ { "type": "text", "text": "hi" } ] },
|
||||
{ "role": "system", "content": "B" }
|
||||
]
|
||||
}`)
|
||||
|
||||
out, err := ensureQwenSystemMessage(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureQwenSystemMessage() error = %v", err)
|
||||
}
|
||||
|
||||
msgs := gjson.GetBytes(out, "messages").Array()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("messages length = %d, want 2", len(msgs))
|
||||
}
|
||||
parts := msgs[0].Get("content").Array()
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("messages[0].content length = %d, want 3", len(parts))
|
||||
}
|
||||
if parts[1].Get("text").String() != "A" {
|
||||
t.Fatalf("messages[0].content[1].text = %q, want %q", parts[1].Get("text").String(), "A")
|
||||
}
|
||||
if parts[2].Get("text").String() != "B" {
|
||||
t.Fatalf("messages[0].content[2].text = %q, want %q", parts[2].Get("text").String(), "B")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapQwenError_InsufficientQuotaDoesNotSetRetryAfter(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":"insufficient_quota","message":"You exceeded your current quota","type":"insufficient_quota"}}`)
|
||||
code, retryAfter := wrapQwenError(context.Background(), http.StatusTooManyRequests, body)
|
||||
if code != http.StatusTooManyRequests {
|
||||
t.Fatalf("wrapQwenError status = %d, want %d", code, http.StatusTooManyRequests)
|
||||
}
|
||||
if retryAfter != nil {
|
||||
t.Fatalf("wrapQwenError retryAfter = %v, want nil", *retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapQwenError_Maps403QuotaTo429WithoutRetryAfter(t *testing.T) {
|
||||
body := []byte(`{"error":{"code":"insufficient_quota","message":"You exceeded your current quota","type":"insufficient_quota"}}`)
|
||||
code, retryAfter := wrapQwenError(context.Background(), http.StatusForbidden, body)
|
||||
if code != http.StatusTooManyRequests {
|
||||
t.Fatalf("wrapQwenError status = %d, want %d", code, http.StatusTooManyRequests)
|
||||
}
|
||||
if retryAfter != nil {
|
||||
t.Fatalf("wrapQwenError retryAfter = %v, want nil", *retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenCreds_NormalizesResourceURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resourceURL string
|
||||
wantBaseURL string
|
||||
}{
|
||||
{"host only", "portal.qwen.ai", "https://portal.qwen.ai/v1"},
|
||||
{"scheme no v1", "https://portal.qwen.ai", "https://portal.qwen.ai/v1"},
|
||||
{"scheme with v1", "https://portal.qwen.ai/v1", "https://portal.qwen.ai/v1"},
|
||||
{"scheme with v1 slash", "https://portal.qwen.ai/v1/", "https://portal.qwen.ai/v1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
auth := &cliproxyauth.Auth{
|
||||
Metadata: map[string]any{
|
||||
"access_token": "test-token",
|
||||
"resource_url": tt.resourceURL,
|
||||
},
|
||||
}
|
||||
|
||||
token, baseURL := qwenCreds(auth)
|
||||
if token != "test-token" {
|
||||
t.Fatalf("qwenCreds token = %q, want %q", token, "test-token")
|
||||
}
|
||||
if baseURL != tt.wantBaseURL {
|
||||
t.Fatalf("qwenCreds baseURL = %q, want %q", baseURL, tt.wantBaseURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExecutorExecute_429DoesNotRefreshOrRetry(t *testing.T) {
|
||||
qwenRateLimiter.Lock()
|
||||
qwenRateLimiter.requests = make(map[string][]time.Time)
|
||||
qwenRateLimiter.Unlock()
|
||||
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Header.Get("Authorization") {
|
||||
case "Bearer old-token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"quota_exceeded","message":"quota exceeded","type":"quota_exceeded"}}`))
|
||||
return
|
||||
case "Bearer new-token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"qwen-max","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
|
||||
return
|
||||
default:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := NewQwenExecutor(&config.Config{})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-test",
|
||||
Provider: "qwen",
|
||||
Attributes: map[string]string{
|
||||
"base_url": srv.URL + "/v1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"access_token": "old-token",
|
||||
"refresh_token": "refresh-token",
|
||||
},
|
||||
}
|
||||
|
||||
var refresherCalls int32
|
||||
exec.refreshForImmediateRetry = func(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||
atomic.AddInt32(&refresherCalls, 1)
|
||||
refreshed := auth.Clone()
|
||||
if refreshed.Metadata == nil {
|
||||
refreshed.Metadata = make(map[string]any)
|
||||
}
|
||||
refreshed.Metadata["access_token"] = "new-token"
|
||||
refreshed.Metadata["refresh_token"] = "refresh-token-2"
|
||||
return refreshed, nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "qwen-max",
|
||||
Payload: []byte(`{"model":"qwen-max","messages":[{"role":"user","content":"hi"}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() expected error, got nil")
|
||||
}
|
||||
status, ok := err.(statusErr)
|
||||
if !ok {
|
||||
t.Fatalf("Execute() error type = %T, want statusErr", err)
|
||||
}
|
||||
if status.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("Execute() status code = %d, want %d", status.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", atomic.LoadInt32(&calls))
|
||||
}
|
||||
if atomic.LoadInt32(&refresherCalls) != 0 {
|
||||
t.Fatalf("refresher calls = %d, want 0", atomic.LoadInt32(&refresherCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExecutorExecuteStream_429DoesNotRefreshOrRetry(t *testing.T) {
|
||||
qwenRateLimiter.Lock()
|
||||
qwenRateLimiter.requests = make(map[string][]time.Time)
|
||||
qwenRateLimiter.Unlock()
|
||||
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
switch r.Header.Get("Authorization") {
|
||||
case "Bearer old-token":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"quota_exceeded","message":"quota exceeded","type":"quota_exceeded"}}`))
|
||||
return
|
||||
case "Bearer new-token":
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"qwen-max\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n"))
|
||||
if flusher, ok := w.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
return
|
||||
default:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := NewQwenExecutor(&config.Config{})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-test",
|
||||
Provider: "qwen",
|
||||
Attributes: map[string]string{
|
||||
"base_url": srv.URL + "/v1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"access_token": "old-token",
|
||||
"refresh_token": "refresh-token",
|
||||
},
|
||||
}
|
||||
|
||||
var refresherCalls int32
|
||||
exec.refreshForImmediateRetry = func(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||
atomic.AddInt32(&refresherCalls, 1)
|
||||
refreshed := auth.Clone()
|
||||
if refreshed.Metadata == nil {
|
||||
refreshed.Metadata = make(map[string]any)
|
||||
}
|
||||
refreshed.Metadata["access_token"] = "new-token"
|
||||
refreshed.Metadata["refresh_token"] = "refresh-token-2"
|
||||
return refreshed, nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "qwen-max",
|
||||
Payload: []byte(`{"model":"qwen-max","stream":true,"messages":[{"role":"user","content":"hi"}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ExecuteStream() expected error, got nil")
|
||||
}
|
||||
status, ok := err.(statusErr)
|
||||
if !ok {
|
||||
t.Fatalf("ExecuteStream() error type = %T, want statusErr", err)
|
||||
}
|
||||
if status.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("ExecuteStream() status code = %d, want %d", status.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", atomic.LoadInt32(&calls))
|
||||
}
|
||||
if atomic.LoadInt32(&refresherCalls) != 0 {
|
||||
t.Fatalf("refresher calls = %d, want 0", atomic.LoadInt32(&refresherCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExecutorExecute_429RetryAfterHeaderPropagatesToStatusErr(t *testing.T) {
|
||||
qwenRateLimiter.Lock()
|
||||
qwenRateLimiter.requests = make(map[string][]time.Time)
|
||||
qwenRateLimiter.Unlock()
|
||||
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "2")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"rate_limit_exceeded","message":"rate limited","type":"rate_limit_exceeded"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := NewQwenExecutor(&config.Config{})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-test",
|
||||
Provider: "qwen",
|
||||
Attributes: map[string]string{
|
||||
"base_url": srv.URL + "/v1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"access_token": "test-token",
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "qwen-max",
|
||||
Payload: []byte(`{"model":"qwen-max","messages":[{"role":"user","content":"hi"}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() expected error, got nil")
|
||||
}
|
||||
status, ok := err.(statusErr)
|
||||
if !ok {
|
||||
t.Fatalf("Execute() error type = %T, want statusErr", err)
|
||||
}
|
||||
if status.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("Execute() status code = %d, want %d", status.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
if status.RetryAfter() == nil {
|
||||
t.Fatalf("Execute() RetryAfter is nil, want non-nil")
|
||||
}
|
||||
if got := *status.RetryAfter(); got != 2*time.Second {
|
||||
t.Fatalf("Execute() RetryAfter = %v, want %v", got, 2*time.Second)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", atomic.LoadInt32(&calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExecutorExecuteStream_429RetryAfterHeaderPropagatesToStatusErr(t *testing.T) {
|
||||
qwenRateLimiter.Lock()
|
||||
qwenRateLimiter.requests = make(map[string][]time.Time)
|
||||
qwenRateLimiter.Unlock()
|
||||
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", "2")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"rate_limit_exceeded","message":"rate limited","type":"rate_limit_exceeded"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := NewQwenExecutor(&config.Config{})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-test",
|
||||
Provider: "qwen",
|
||||
Attributes: map[string]string{
|
||||
"base_url": srv.URL + "/v1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"access_token": "test-token",
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "qwen-max",
|
||||
Payload: []byte(`{"model":"qwen-max","stream":true,"messages":[{"role":"user","content":"hi"}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ExecuteStream() expected error, got nil")
|
||||
}
|
||||
status, ok := err.(statusErr)
|
||||
if !ok {
|
||||
t.Fatalf("ExecuteStream() error type = %T, want statusErr", err)
|
||||
}
|
||||
if status.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("ExecuteStream() status code = %d, want %d", status.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
if status.RetryAfter() == nil {
|
||||
t.Fatalf("ExecuteStream() RetryAfter is nil, want non-nil")
|
||||
}
|
||||
if got := *status.RetryAfter(); got != 2*time.Second {
|
||||
t.Fatalf("ExecuteStream() RetryAfter = %v, want %v", got, 2*time.Second)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", atomic.LoadInt32(&calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExecutorExecute_429QuotaExhausted_DisableCoolingSetsDefaultRetryAfter(t *testing.T) {
|
||||
qwenRateLimiter.Lock()
|
||||
qwenRateLimiter.requests = make(map[string][]time.Time)
|
||||
qwenRateLimiter.Unlock()
|
||||
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"quota_exceeded","message":"quota exceeded","type":"quota_exceeded"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := NewQwenExecutor(&config.Config{DisableCooling: true})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-test",
|
||||
Provider: "qwen",
|
||||
Attributes: map[string]string{
|
||||
"base_url": srv.URL + "/v1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"access_token": "test-token",
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := exec.Execute(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "qwen-max",
|
||||
Payload: []byte(`{"model":"qwen-max","messages":[{"role":"user","content":"hi"}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("Execute() expected error, got nil")
|
||||
}
|
||||
status, ok := err.(statusErr)
|
||||
if !ok {
|
||||
t.Fatalf("Execute() error type = %T, want statusErr", err)
|
||||
}
|
||||
if status.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("Execute() status code = %d, want %d", status.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
if status.RetryAfter() == nil {
|
||||
t.Fatalf("Execute() RetryAfter is nil, want non-nil")
|
||||
}
|
||||
if got := *status.RetryAfter(); got != time.Second {
|
||||
t.Fatalf("Execute() RetryAfter = %v, want %v", got, time.Second)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", atomic.LoadInt32(&calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwenExecutorExecuteStream_429QuotaExhausted_DisableCoolingSetsDefaultRetryAfter(t *testing.T) {
|
||||
qwenRateLimiter.Lock()
|
||||
qwenRateLimiter.requests = make(map[string][]time.Time)
|
||||
qwenRateLimiter.Unlock()
|
||||
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"quota_exceeded","message":"quota exceeded","type":"quota_exceeded"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
exec := NewQwenExecutor(&config.Config{DisableCooling: true})
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: "auth-test",
|
||||
Provider: "qwen",
|
||||
Attributes: map[string]string{
|
||||
"base_url": srv.URL + "/v1",
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"access_token": "test-token",
|
||||
},
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := exec.ExecuteStream(ctx, auth, cliproxyexecutor.Request{
|
||||
Model: "qwen-max",
|
||||
Payload: []byte(`{"model":"qwen-max","stream":true,"messages":[{"role":"user","content":"hi"}]}`),
|
||||
}, cliproxyexecutor.Options{
|
||||
SourceFormat: sdktranslator.FromString("openai"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("ExecuteStream() expected error, got nil")
|
||||
}
|
||||
status, ok := err.(statusErr)
|
||||
if !ok {
|
||||
t.Fatalf("ExecuteStream() error type = %T, want statusErr", err)
|
||||
}
|
||||
if status.StatusCode() != http.StatusTooManyRequests {
|
||||
t.Fatalf("ExecuteStream() status code = %d, want %d", status.StatusCode(), http.StatusTooManyRequests)
|
||||
}
|
||||
if status.RetryAfter() == nil {
|
||||
t.Fatalf("ExecuteStream() RetryAfter is nil, want non-nil")
|
||||
}
|
||||
if got := *status.RetryAfter(); got != time.Second {
|
||||
t.Fatalf("ExecuteStream() RetryAfter = %v, want %v", got, time.Second)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", atomic.LoadInt32(&calls))
|
||||
}
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func isEnableThinkingModel(modelID string) bool {
|
||||
}
|
||||
id := strings.ToLower(modelID)
|
||||
switch id {
|
||||
case "qwen3-max-preview", "deepseek-v3.2", "deepseek-v3.1":
|
||||
case "deepseek-v3.2", "deepseek-v3.1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -23,7 +23,6 @@ var oauthProviders = []oauthProvider{
|
||||
{"Claude (Anthropic)", "anthropic-auth-url", "🟧"},
|
||||
{"Codex (OpenAI)", "codex-auth-url", "🟩"},
|
||||
{"Antigravity", "antigravity-auth-url", "🟪"},
|
||||
{"Qwen", "qwen-auth-url", "🟨"},
|
||||
{"Kimi", "kimi-auth-url", "🟫"},
|
||||
{"IFlow", "iflow-auth-url", "⬜"},
|
||||
}
|
||||
@@ -280,8 +279,6 @@ func (m oauthTabModel) submitCallback(callbackURL string) tea.Cmd {
|
||||
providerKey = "codex"
|
||||
case "antigravity-auth-url":
|
||||
providerKey = "antigravity"
|
||||
case "qwen-auth-url":
|
||||
providerKey = "qwen"
|
||||
case "kimi-auth-url":
|
||||
providerKey = "kimi"
|
||||
case "iflow-auth-url":
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
// - "gemini" for Google's Gemini family
|
||||
// - "codex" for OpenAI GPT-compatible providers
|
||||
// - "claude" for Anthropic models
|
||||
// - "qwen" for Alibaba's Qwen models
|
||||
// - "openai-compatibility" for external OpenAI-compatible providers
|
||||
//
|
||||
// Parameters:
|
||||
|
||||
Reference in New Issue
Block a user