feat: add Qwen OAuth QR code login support
This commit is contained in:
parent
ec6da7a530
commit
a531a4c196
6 changed files with 758 additions and 10 deletions
|
|
@ -46,6 +46,12 @@ func agentCmd() {
|
||||||
modelOverride = args[i+1]
|
modelOverride = args[i+1]
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
|
// Treat bare positional arguments (not starting with "-") as model name override.
|
||||||
|
// e.g. `picoclaw agent qwen` sets model to "qwen"
|
||||||
|
if !strings.HasPrefix(args[i], "-") && modelOverride == "" {
|
||||||
|
modelOverride = args[i]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity, qwen"
|
||||||
|
|
||||||
func authCmd() {
|
func authCmd() {
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
|
|
@ -48,7 +48,7 @@ func authHelp() {
|
||||||
fmt.Println(" models List available Antigravity models")
|
fmt.Println(" models List available Antigravity models")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Login options:")
|
fmt.Println("Login options:")
|
||||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity, qwen)")
|
||||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Examples:")
|
fmt.Println("Examples:")
|
||||||
|
|
@ -56,6 +56,7 @@ func authHelp() {
|
||||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||||
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
||||||
|
fmt.Println(" picoclaw auth login --provider qwen")
|
||||||
fmt.Println(" picoclaw auth models")
|
fmt.Println(" picoclaw auth models")
|
||||||
fmt.Println(" picoclaw auth logout --provider openai")
|
fmt.Println(" picoclaw auth logout --provider openai")
|
||||||
fmt.Println(" picoclaw auth status")
|
fmt.Println(" picoclaw auth status")
|
||||||
|
|
@ -91,6 +92,8 @@ func authLoginCmd() {
|
||||||
authLoginPasteToken(provider)
|
authLoginPasteToken(provider)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
authLoginGoogleAntigravity()
|
authLoginGoogleAntigravity()
|
||||||
|
case "qwen":
|
||||||
|
authLoginQwen()
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
fmt.Printf("Unsupported provider: %s\n", provider)
|
||||||
fmt.Println(supportedProvidersMsg)
|
fmt.Println(supportedProvidersMsg)
|
||||||
|
|
@ -356,10 +359,14 @@ func authLogoutCmd() {
|
||||||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||||
appCfg.ModelList[i].AuthMethod = ""
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
}
|
}
|
||||||
|
case "qwen":
|
||||||
|
if isQwenModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].AuthMethod = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clear AuthMethod in Providers (legacy)
|
// Clear AuthMethod in Providers (legacy)
|
||||||
|
|
@ -505,8 +512,67 @@ func isOpenAIModel(model string) bool {
|
||||||
strings.HasPrefix(model, "openai/")
|
strings.HasPrefix(model, "openai/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isQwenModel checks if a model string belongs to the qwen provider
|
||||||
|
func isQwenModel(model string) bool {
|
||||||
|
return model == "qwen" ||
|
||||||
|
model == "qwen-oauth" ||
|
||||||
|
strings.HasPrefix(model, "qwen/") ||
|
||||||
|
strings.HasPrefix(model, "qwen-oauth/")
|
||||||
|
}
|
||||||
|
|
||||||
// isAnthropicModel checks if a model string belongs to anthropic provider
|
// isAnthropicModel checks if a model string belongs to anthropic provider
|
||||||
func isAnthropicModel(model string) bool {
|
func isAnthropicModel(model string) bool {
|
||||||
return model == "anthropic" ||
|
return model == "anthropic" ||
|
||||||
strings.HasPrefix(model, "anthropic/")
|
strings.HasPrefix(model, "anthropic/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// authLoginQwen performs the Qwen Portal OAuth device-code (QR scan) login flow.
|
||||||
|
func authLoginQwen() {
|
||||||
|
cred, err := auth.LoginQwenQRCode()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Login failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = auth.SetCredential("qwen", cred); err != nil {
|
||||||
|
fmt.Printf("Failed to save credentials: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, cfgErr := loadConfig()
|
||||||
|
if cfgErr == nil {
|
||||||
|
// Update or add qwen-portal entry in ModelList.
|
||||||
|
found := false
|
||||||
|
for i := range appCfg.ModelList {
|
||||||
|
if isQwenModel(appCfg.ModelList[i].Model) {
|
||||||
|
appCfg.ModelList[i].Model = "qwen-portal/coder-model"
|
||||||
|
appCfg.ModelList[i].APIBase = "https://portal.qwen.ai/v1"
|
||||||
|
appCfg.ModelList[i].APIKey = "qwen-oauth"
|
||||||
|
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||||
|
ModelName: "qwen-coder",
|
||||||
|
Model: "qwen-portal/coder-model",
|
||||||
|
APIBase: "https://portal.qwen.ai/v1",
|
||||||
|
APIKey: "qwen-oauth",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default model.
|
||||||
|
appCfg.Agents.Defaults.ModelName = "qwen-coder"
|
||||||
|
|
||||||
|
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
||||||
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n✓ Qwen OAuth login successful!")
|
||||||
|
fmt.Println("Default model set to: qwen-coder (coder-model)")
|
||||||
|
fmt.Println("Available models: qwen-coder, qwen-vision")
|
||||||
|
fmt.Println("Try it: picoclaw agent -m \"你好\" --model qwen-coder")
|
||||||
|
}
|
||||||
|
|
|
||||||
336
pkg/auth/qwen_oauth.go
Normal file
336
pkg/auth/qwen_oauth.go
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
// Qwen Portal API base URL (OpenAI-compatible endpoint)
|
||||||
|
// This is the same endpoint used by OpenClaw
|
||||||
|
qwenPortalBaseURL = "https://portal.qwen.ai/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qwenDeviceAuthorization is returned by the device/code endpoint.
|
||||||
|
type qwenDeviceAuthorization struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURI string `json:"verification_uri"`
|
||||||
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// qwenTokenResponse is returned by the token polling endpoint.
|
||||||
|
type qwenTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
// Error fields for pending/error states
|
||||||
|
Error string `json:"error"`
|
||||||
|
ErrorDescription string `json:"error_description"`
|
||||||
|
// Resource URL (base URL for API calls)
|
||||||
|
ResourceURL string `json:"resource_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePKCE generates a PKCE (RFC 7636) verifier and S256 challenge pair.
|
||||||
|
func generatePKCE() (verifier, challenge string, err error) {
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(raw); err != nil {
|
||||||
|
return "", "", fmt.Errorf("generating PKCE verifier: %w", err)
|
||||||
|
}
|
||||||
|
verifier = base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
|
||||||
|
h := sha256.Sum256([]byte(verifier))
|
||||||
|
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
||||||
|
return verifier, challenge, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRequestID generates a random UUID-style request ID.
|
||||||
|
func generateRequestID() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestQwenDeviceCode requests a device authorization from chat.qwen.ai.
|
||||||
|
func requestQwenDeviceCode(challenge string) (*qwenDeviceAuthorization, error) {
|
||||||
|
body := url.Values{}
|
||||||
|
body.Set("client_id", qwenClientID)
|
||||||
|
body.Set("scope", qwenOAuthScope)
|
||||||
|
body.Set("code_challenge", challenge)
|
||||||
|
body.Set("code_challenge_method", "S256")
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", qwenDeviceCodeEndpoint, strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("x-request-id", generateRequestID())
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("device code request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("device code request failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var da qwenDeviceAuthorization
|
||||||
|
if err := json.Unmarshal(respBody, &da); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing device code response: %w", err)
|
||||||
|
}
|
||||||
|
if da.DeviceCode == "" || da.UserCode == "" {
|
||||||
|
return nil, fmt.Errorf("invalid device code response: missing device_code or user_code")
|
||||||
|
}
|
||||||
|
return &da, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollQwenToken polls the token endpoint until the user authorizes or the code expires.
|
||||||
|
func pollQwenToken(deviceCode, verifier string, interval, expiresIn int) (*qwenTokenResponse, error) {
|
||||||
|
body := url.Values{}
|
||||||
|
body.Set("grant_type", qwenDeviceGrantType)
|
||||||
|
body.Set("client_id", qwenClientID)
|
||||||
|
body.Set("device_code", deviceCode)
|
||||||
|
body.Set("code_verifier", verifier)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||||
|
pollInterval := time.Duration(interval) * time.Second
|
||||||
|
if pollInterval < 3*time.Second {
|
||||||
|
pollInterval = 3 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", qwenTokenEndpoint, strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
// transient network error — keep polling
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
var tok qwenTokenResponse
|
||||||
|
if err := json.Unmarshal(respBody, &tok); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tok.Error {
|
||||||
|
case "":
|
||||||
|
// Success — must have access_token
|
||||||
|
if tok.AccessToken != "" {
|
||||||
|
return &tok, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("token response missing access_token")
|
||||||
|
case "authorization_pending":
|
||||||
|
// User has not yet authorized; keep polling
|
||||||
|
continue
|
||||||
|
case "slow_down":
|
||||||
|
// Server asking us to back off
|
||||||
|
pollInterval += 5 * time.Second
|
||||||
|
continue
|
||||||
|
case "expired_token":
|
||||||
|
return nil, fmt.Errorf("device code expired — please run the login command again")
|
||||||
|
case "access_denied":
|
||||||
|
return nil, fmt.Errorf("authorization denied by user")
|
||||||
|
default:
|
||||||
|
desc := tok.ErrorDescription
|
||||||
|
if desc == "" {
|
||||||
|
desc = tok.Error
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("OAuth error: %s", desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("timed out waiting for authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginQwenQRCode performs the Qwen Portal OAuth device-code flow.
|
||||||
|
// It prints a verification URL for the user to open and authorize, then polls
|
||||||
|
// until the token is granted.
|
||||||
|
func LoginQwenQRCode() (*AuthCredential, error) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("=== Qwen (通义千问) OAuth Login ===")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 1. Generate PKCE
|
||||||
|
verifier, challenge, err := generatePKCE()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Request device code
|
||||||
|
fmt.Println("Requesting authorization code from chat.qwen.ai...")
|
||||||
|
da, err := requestQwenDeviceCode(challenge)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("requesting device code: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Show the user how to authorize
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("──────────────────────────────────────────────────")
|
||||||
|
|
||||||
|
verifyURL := da.VerificationURIComplete
|
||||||
|
if verifyURL == "" {
|
||||||
|
verifyURL = da.VerificationURI
|
||||||
|
}
|
||||||
|
if verifyURL != "" {
|
||||||
|
fmt.Printf(" 1. Open this URL in your browser:\n\n %s\n\n", verifyURL)
|
||||||
|
}
|
||||||
|
if da.UserCode != "" && da.VerificationURIComplete == "" {
|
||||||
|
fmt.Printf(" 2. Enter the code: %s\n\n", da.UserCode)
|
||||||
|
}
|
||||||
|
fmt.Println(" 3. Log in with your Qwen / Alibaba Cloud account")
|
||||||
|
fmt.Println(" 4. Click \"Authorize\" in the browser")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("──────────────────────────────────────────────────")
|
||||||
|
|
||||||
|
// Try to display a simple QR code hint
|
||||||
|
if verifyURL != "" {
|
||||||
|
printSimpleQRHint(verifyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Waiting for authorization (expires in %ds)...\n", da.ExpiresIn)
|
||||||
|
|
||||||
|
// 4. Poll for token
|
||||||
|
tok, err := pollQwenToken(da.DeviceCode, verifier, da.Interval, da.ExpiresIn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Build credential
|
||||||
|
expiresAt := time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
|
||||||
|
|
||||||
|
cred := &AuthCredential{
|
||||||
|
AccessToken: tok.AccessToken,
|
||||||
|
RefreshToken: tok.RefreshToken,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
Provider: "qwen",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
}
|
||||||
|
return cred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshQwenCredentials exchanges a refresh_token for a new access_token.
|
||||||
|
func RefreshQwenCredentials(cred *AuthCredential) (*AuthCredential, error) {
|
||||||
|
if cred == nil || cred.RefreshToken == "" {
|
||||||
|
return nil, fmt.Errorf("no refresh token available — please run: picoclaw auth login --provider qwen")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := url.Values{}
|
||||||
|
body.Set("grant_type", "refresh_token")
|
||||||
|
body.Set("refresh_token", cred.RefreshToken)
|
||||||
|
body.Set("client_id", qwenClientID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", qwenTokenEndpoint, strings.NewReader(body.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("refreshing Qwen token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode == http.StatusBadRequest {
|
||||||
|
return nil, fmt.Errorf("Qwen refresh token expired — please run: picoclaw auth login --provider qwen")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("token refresh failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tok qwenTokenResponse
|
||||||
|
if err := json.Unmarshal(respBody, &tok); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing refresh response: %w", err)
|
||||||
|
}
|
||||||
|
if tok.AccessToken == "" {
|
||||||
|
return nil, fmt.Errorf("refresh response missing access_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
newCred := *cred
|
||||||
|
newCred.AccessToken = tok.AccessToken
|
||||||
|
if tok.RefreshToken != "" {
|
||||||
|
newCred.RefreshToken = tok.RefreshToken
|
||||||
|
}
|
||||||
|
if tok.ExpiresIn > 0 {
|
||||||
|
newCred.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
|
||||||
|
}
|
||||||
|
return &newCred, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateQwenTokenSource returns a closure that provides a valid Qwen OAuth
|
||||||
|
// access token, automatically refreshing it when expired.
|
||||||
|
func CreateQwenTokenSource() func() (string, error) {
|
||||||
|
return func() (string, error) {
|
||||||
|
cred, err := GetCredential("qwen")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("loading qwen credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil || cred.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("not authenticated with Qwen — run: picoclaw auth login --provider qwen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh if token expires within 5 minutes
|
||||||
|
if !cred.ExpiresAt.IsZero() && time.Until(cred.ExpiresAt) < 5*time.Minute {
|
||||||
|
newCred, refreshErr := RefreshQwenCredentials(cred)
|
||||||
|
if refreshErr == nil {
|
||||||
|
_ = SetCredential("qwen", newCred)
|
||||||
|
return newCred.AccessToken, nil
|
||||||
|
}
|
||||||
|
// Refresh failed but token may still be valid; fall through
|
||||||
|
}
|
||||||
|
return cred.AccessToken, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSimpleQRHint prints a minimal hint to help users open the URL.
|
||||||
|
func printSimpleQRHint(verifyURL string) {
|
||||||
|
// Print a text-based QR code placeholder — real QR rendering would require
|
||||||
|
// an external library; here we just give a clear visual cue.
|
||||||
|
fmt.Println(" ┌─────────────────────────────────────────┐")
|
||||||
|
fmt.Println(" │ Scan or open the URL above in browser │")
|
||||||
|
fmt.Println(" └─────────────────────────────────────────┘")
|
||||||
|
_ = verifyURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsQwenOAuthModel reports whether a model string belongs to the qwen-oauth protocol.
|
||||||
|
func IsQwenOAuthModel(model string) bool {
|
||||||
|
return strings.HasPrefix(model, "qwen-oauth/") || model == "qwen-oauth"
|
||||||
|
}
|
||||||
|
|
@ -162,13 +162,43 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
|
// Qwen (通义千问) - OAuth 扫码登录
|
||||||
|
// Login: picoclaw auth login --provider qwen
|
||||||
|
// API documentation: https://help.aliyun.com/zh/model-studio/
|
||||||
|
// Models: https://chat.qwen.ai
|
||||||
|
{
|
||||||
|
ModelName: "qwen-coder",
|
||||||
|
Model: "qwen-portal/coder-model",
|
||||||
|
APIBase: "https://portal.qwen.ai/v1",
|
||||||
|
APIKey: "qwen-oauth", // Special marker: use OAuth token from auth store
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "qwen-vision",
|
||||||
|
Model: "qwen-portal/vision-model",
|
||||||
|
APIBase: "https://portal.qwen.ai/v1",
|
||||||
|
APIKey: "qwen-oauth",
|
||||||
|
AuthMethod: "oauth",
|
||||||
|
},
|
||||||
|
// Qwen with API Key (alternative) - DashScope API
|
||||||
{
|
{
|
||||||
ModelName: "qwen-plus",
|
ModelName: "qwen-plus",
|
||||||
Model: "qwen/qwen-plus",
|
Model: "qwen/qwen-plus",
|
||||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ModelName: "qwen3-max",
|
||||||
|
Model: "qwen/qwen3-max",
|
||||||
|
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ModelName: "qwen3-flash",
|
||||||
|
Model: "qwen/qwen3-flash",
|
||||||
|
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
|
APIKey: "",
|
||||||
|
},
|
||||||
|
|
||||||
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
||||||
|
|
||||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||||
// It uses the protocol prefix in the Model field to determine which provider to create.
|
// It uses the protocol prefix in the Model field to determine which provider to create.
|
||||||
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
|
// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot, qwen-oauth
|
||||||
// Returns the provider, the model ID (without protocol prefix), and any error.
|
// Returns the provider, the model ID (without protocol prefix), and any error.
|
||||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
|
|
@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
"volcengine", "vllm", "qwen", "mistral":
|
"volcengine", "vllm", "mistral":
|
||||||
// All other OpenAI-compatible HTTP providers
|
// All other OpenAI-compatible HTTP providers
|
||||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||||
|
|
@ -150,6 +150,26 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
}
|
}
|
||||||
return provider, modelID, nil
|
return provider, modelID, nil
|
||||||
|
|
||||||
|
case "qwen-oauth", "qwenoauth", "qwen-portal":
|
||||||
|
// Qwen via Alibaba Cloud OAuth (QR-code scan login, no API key needed)
|
||||||
|
// Uses https://portal.qwen.ai/v1 endpoint
|
||||||
|
provider, err := createQwenOAuthProvider()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return provider, modelID, nil
|
||||||
|
|
||||||
|
case "qwen":
|
||||||
|
// Qwen via DashScope API Key (OpenAI-compatible HTTP API)
|
||||||
|
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||||
|
return nil, "", fmt.Errorf("api_key or api_base is required for qwen protocol (model: %s)", cfg.Model)
|
||||||
|
}
|
||||||
|
apiBase := cfg.APIBase
|
||||||
|
if apiBase == "" {
|
||||||
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
|
}
|
||||||
|
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +202,7 @@ func getDefaultAPIBase(protocol string) string {
|
||||||
return "https://api.cerebras.ai/v1"
|
return "https://api.cerebras.ai/v1"
|
||||||
case "volcengine":
|
case "volcengine":
|
||||||
return "https://ark.cn-beijing.volces.com/api/v3"
|
return "https://ark.cn-beijing.volces.com/api/v3"
|
||||||
case "qwen":
|
case "qwen", "qwen-oauth":
|
||||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
case "vllm":
|
case "vllm":
|
||||||
return "http://localhost:8000/v1"
|
return "http://localhost:8000/v1"
|
||||||
|
|
|
||||||
290
pkg/providers/qwen_provider.go
Normal file
290
pkg/providers/qwen_provider.go
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// qwenOAuthBaseURL is the Qwen Portal API endpoint (OpenAI-compatible).
|
||||||
|
// This is the same endpoint used by OpenClaw for Qwen OAuth authentication.
|
||||||
|
// Reference: https://github.com/openclaw/openclaw/tree/main/extensions/qwen-portal-auth
|
||||||
|
// Available models: https://chat.qwen.ai
|
||||||
|
// - coder-model: Qwen Coder (code generation and understanding)
|
||||||
|
// - vision-model: Qwen Vision (image understanding)
|
||||||
|
qwenOAuthBaseURL = "https://portal.qwen.ai/v1"
|
||||||
|
qwenOAuthDefaultModel = "coder-model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QwenOAuthProvider implements LLMProvider using Alibaba Cloud DashScope API
|
||||||
|
// authenticated via Qwen OAuth (QR-code scan). It uses the OpenAI-compatible
|
||||||
|
// /chat/completions endpoint so no extra SDK is required.
|
||||||
|
type QwenOAuthProvider struct {
|
||||||
|
tokenSource func() (string, error)
|
||||||
|
apiBase string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQwenOAuthProvider creates a QwenOAuthProvider that reads credentials from
|
||||||
|
// the auth store and refreshes them transparently.
|
||||||
|
func NewQwenOAuthProvider() *QwenOAuthProvider {
|
||||||
|
return &QwenOAuthProvider{
|
||||||
|
tokenSource: auth.CreateQwenTokenSource(),
|
||||||
|
apiBase: strings.TrimRight(qwenOAuthBaseURL, "/"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 120 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQwenOAuthProviderWithTokenSource creates a QwenOAuthProvider with a
|
||||||
|
// custom token-source (useful for testing).
|
||||||
|
func NewQwenOAuthProviderWithTokenSource(
|
||||||
|
tokenSource func() (string, error),
|
||||||
|
apiBase string,
|
||||||
|
) *QwenOAuthProvider {
|
||||||
|
base := qwenOAuthBaseURL
|
||||||
|
if apiBase != "" {
|
||||||
|
base = strings.TrimRight(apiBase, "/")
|
||||||
|
}
|
||||||
|
return &QwenOAuthProvider{
|
||||||
|
tokenSource: tokenSource,
|
||||||
|
apiBase: base,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 120 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultModel returns the default Qwen model.
|
||||||
|
func (p *QwenOAuthProvider) GetDefaultModel() string {
|
||||||
|
return qwenOAuthDefaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat implements LLMProvider.Chat using the DashScope OpenAI-compatible API.
|
||||||
|
func (p *QwenOAuthProvider) Chat(
|
||||||
|
ctx context.Context,
|
||||||
|
messages []Message,
|
||||||
|
tools []ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) (*LLMResponse, error) {
|
||||||
|
token, err := p.tokenSource()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("qwen oauth: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if model == "" || model == "qwen-oauth" {
|
||||||
|
model = qwenOAuthDefaultModel
|
||||||
|
}
|
||||||
|
// Strip protocol prefixes.
|
||||||
|
model = strings.TrimPrefix(model, "qwen-oauth/")
|
||||||
|
model = strings.TrimPrefix(model, "qwen/")
|
||||||
|
|
||||||
|
logger.DebugCF("provider.qwen_oauth", "Starting chat", map[string]any{
|
||||||
|
"model": model,
|
||||||
|
})
|
||||||
|
|
||||||
|
reqBody, err := p.buildRequestBody(messages, tools, model, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("building request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := p.apiBase + "/chat/completions"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("qwen request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
return nil, fmt.Errorf("qwen OAuth token rejected (%d) – run: picoclaw auth login --provider qwen",
|
||||||
|
resp.StatusCode)
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusTooManyRequests {
|
||||||
|
return nil, fmt.Errorf("qwen rate limit exceeded (429)")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("qwen API error (%d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.parseResponse(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRequestBody serialises the chat request as an OpenAI-compatible JSON body.
|
||||||
|
func (p *QwenOAuthProvider) buildRequestBody(
|
||||||
|
messages []Message,
|
||||||
|
tools []ToolDefinition,
|
||||||
|
model string,
|
||||||
|
options map[string]any,
|
||||||
|
) ([]byte, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": convertMessagesForQwen(messages),
|
||||||
|
"stream": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward supported options.
|
||||||
|
if v, ok := options["temperature"]; ok {
|
||||||
|
body["temperature"] = v
|
||||||
|
}
|
||||||
|
if v, ok := options["max_tokens"]; ok {
|
||||||
|
body["max_tokens"] = v
|
||||||
|
}
|
||||||
|
if v, ok := options["top_p"]; ok {
|
||||||
|
body["top_p"] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tools) > 0 {
|
||||||
|
body["tools"] = convertToolsForQwen(tools)
|
||||||
|
body["tool_choice"] = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Marshal(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseResponse parses an OpenAI-compatible chat/completions response.
|
||||||
|
func (p *QwenOAuthProvider) parseResponse(body []byte) (*LLMResponse, error) {
|
||||||
|
var raw struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
ToolCalls []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Function struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Arguments string `json:"arguments"`
|
||||||
|
} `json:"function"`
|
||||||
|
} `json:"tool_calls"`
|
||||||
|
} `json:"message"`
|
||||||
|
FinishReason string `json:"finish_reason"`
|
||||||
|
} `json:"choices"`
|
||||||
|
Usage struct {
|
||||||
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
|
TotalTokens int `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
Error *struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing qwen response: %w (body: %.300s)", err, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw.Error != nil && raw.Error.Message != "" {
|
||||||
|
return nil, fmt.Errorf("qwen API error [%s]: %s", raw.Error.Code, raw.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(raw.Choices) == 0 {
|
||||||
|
return nil, fmt.Errorf("qwen returned no choices (body: %.300s)", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
choice := raw.Choices[0]
|
||||||
|
llmResp := &LLMResponse{
|
||||||
|
Content: choice.Message.Content,
|
||||||
|
Usage: &protocoltypes.UsageInfo{
|
||||||
|
PromptTokens: raw.Usage.PromptTokens,
|
||||||
|
CompletionTokens: raw.Usage.CompletionTokens,
|
||||||
|
TotalTokens: raw.Usage.TotalTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range choice.Message.ToolCalls {
|
||||||
|
llmResp.ToolCalls = append(llmResp.ToolCalls, protocoltypes.ToolCall{
|
||||||
|
ID: tc.ID,
|
||||||
|
Type: tc.Type,
|
||||||
|
Function: &protocoltypes.FunctionCall{
|
||||||
|
Name: tc.Function.Name,
|
||||||
|
Arguments: tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return llmResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertMessagesForQwen converts internal Message slice to OpenAI-compatible format.
|
||||||
|
func convertMessagesForQwen(messages []Message) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(messages))
|
||||||
|
for _, m := range messages {
|
||||||
|
entry := map[string]any{
|
||||||
|
"role": m.Role,
|
||||||
|
"content": m.Content,
|
||||||
|
}
|
||||||
|
if m.ToolCallID != "" {
|
||||||
|
entry["tool_call_id"] = m.ToolCallID
|
||||||
|
}
|
||||||
|
if len(m.ToolCalls) > 0 {
|
||||||
|
tcs := make([]map[string]any, 0, len(m.ToolCalls))
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
tcs = append(tcs, map[string]any{
|
||||||
|
"id": tc.ID,
|
||||||
|
"type": tc.Type,
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": tc.Function.Name,
|
||||||
|
"arguments": tc.Function.Arguments,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
entry["tool_calls"] = tcs
|
||||||
|
}
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToolsForQwen converts internal ToolDefinition slice to OpenAI-compatible format.
|
||||||
|
func convertToolsForQwen(tools []ToolDefinition) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(tools))
|
||||||
|
for _, t := range tools {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"type": "function",
|
||||||
|
"function": map[string]any{
|
||||||
|
"name": t.Function.Name,
|
||||||
|
"description": t.Function.Description,
|
||||||
|
"parameters": t.Function.Parameters,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// createQwenOAuthProviderFromStore creates a QwenOAuthProvider using stored credentials.
|
||||||
|
func createQwenOAuthProvider() (LLMProvider, error) {
|
||||||
|
cred, err := getCredential("qwen")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("loading qwen credentials: %w", err)
|
||||||
|
}
|
||||||
|
if cred == nil {
|
||||||
|
return nil, fmt.Errorf("no credentials for qwen. Run: picoclaw auth login --provider qwen")
|
||||||
|
}
|
||||||
|
// Always use the standard DashScope URL - it's defined in the provider constant
|
||||||
|
return NewQwenOAuthProvider(), nil
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue