feat: add Qwen OAuth QR code login support

This commit is contained in:
kyeo 2026-02-25 14:57:02 +08:00
parent ec6da7a530
commit a531a4c196
6 changed files with 758 additions and 10 deletions

View file

@ -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]
}
} }
} }

View file

@ -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
View 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"
}

View file

@ -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
{ {

View file

@ -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"

View 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
}