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]
|
||||
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"
|
||||
)
|
||||
|
||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity, qwen"
|
||||
|
||||
func authCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
|
|
@ -48,7 +48,7 @@ func authHelp() {
|
|||
fmt.Println(" models List available Antigravity models")
|
||||
fmt.Println()
|
||||
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()
|
||||
fmt.Println("Examples:")
|
||||
|
|
@ -56,6 +56,7 @@ func authHelp() {
|
|||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
||||
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
||||
fmt.Println(" picoclaw auth login --provider qwen")
|
||||
fmt.Println(" picoclaw auth models")
|
||||
fmt.Println(" picoclaw auth logout --provider openai")
|
||||
fmt.Println(" picoclaw auth status")
|
||||
|
|
@ -91,6 +92,8 @@ func authLoginCmd() {
|
|||
authLoginPasteToken(provider)
|
||||
case "google-antigravity", "antigravity":
|
||||
authLoginGoogleAntigravity()
|
||||
case "qwen":
|
||||
authLoginQwen()
|
||||
default:
|
||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
||||
fmt.Println(supportedProvidersMsg)
|
||||
|
|
@ -356,10 +359,14 @@ func authLogoutCmd() {
|
|||
if isAnthropicModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "google-antigravity", "antigravity":
|
||||
if isAntigravityModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
case "qwen":
|
||||
if isQwenModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clear AuthMethod in Providers (legacy)
|
||||
|
|
@ -505,8 +512,67 @@ func isOpenAIModel(model string) bool {
|
|||
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
|
||||
func isAnthropicModel(model string) bool {
|
||||
return 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: "",
|
||||
},
|
||||
|
||||
// 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",
|
||||
Model: "qwen/qwen-plus",
|
||||
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ func ExtractProtocol(model string) (protocol, modelID string) {
|
|||
|
||||
// CreateProviderFromConfig creates a provider based on the ModelConfig.
|
||||
// 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.
|
||||
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
|
||||
if cfg == nil {
|
||||
|
|
@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
|
||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||
"volcengine", "vllm", "qwen", "mistral":
|
||||
"volcengine", "vllm", "mistral":
|
||||
// All other OpenAI-compatible HTTP providers
|
||||
if cfg.APIKey == "" && cfg.APIBase == "" {
|
||||
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
|
||||
|
|
@ -150,6 +150,26 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
}
|
||||
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:
|
||||
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"
|
||||
case "volcengine":
|
||||
return "https://ark.cn-beijing.volces.com/api/v3"
|
||||
case "qwen":
|
||||
case "qwen", "qwen-oauth":
|
||||
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
case "vllm":
|
||||
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