feat: add Qwen OAuth authentication with QR code login support
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Implement Qwen (通义千问) OAuth authentication using device code flow (QR code scan). Features: - QR code scan login via chat.qwen.ai - Automatic token refresh before expiration - OpenAI-compatible API endpoint (https://portal.qwen.ai/v1) - Multiple protocol aliases: qwen-oauth, qwenoauth, qwen-portal - Default model: coder-model (Qwen Coder for code generation) Usage: picoclaw auth login --provider qwen picoclaw agent -m qwen-oauth "Hello!" Configuration: { "model_name": "qwen-coder", "model": "qwen-oauth/coder-model", "auth_method": "oauth" } Reference: https://github.com/openclaw/openclaw/tree/main/extensions/qwen-portal-auth
This commit is contained in:
parent
ba98069a00
commit
a7f2eec562
6 changed files with 698 additions and 2 deletions
|
|
@ -15,7 +15,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 authLoginCmd(provider string, useDeviceCode bool) error {
|
||||
switch provider {
|
||||
|
|
@ -25,6 +25,8 @@ func authLoginCmd(provider string, useDeviceCode bool) error {
|
|||
return authLoginPasteToken(provider)
|
||||
case "google-antigravity", "antigravity":
|
||||
return authLoginGoogleAntigravity()
|
||||
case "qwen", "qwen-oauth", "qwenoauth", "qwen-portal":
|
||||
return authLoginQwen()
|
||||
default:
|
||||
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||
}
|
||||
|
|
@ -435,3 +437,57 @@ func isAnthropicModel(model string) bool {
|
|||
return model == "anthropic" ||
|
||||
strings.HasPrefix(model, "anthropic/")
|
||||
}
|
||||
|
||||
// isQwenOAuthModel checks if a model string belongs to qwen-oauth provider
|
||||
func isQwenOAuthModel(model string) bool {
|
||||
return model == "qwen-oauth" ||
|
||||
model == "qwenoauth" ||
|
||||
model == "qwen-portal" ||
|
||||
strings.HasPrefix(model, "qwen-oauth/")
|
||||
}
|
||||
|
||||
// authLoginQwen performs the Qwen Portal OAuth device-code (QR scan) login flow.
|
||||
func authLoginQwen() error {
|
||||
cred, err := auth.LoginQwenQRCode()
|
||||
if err != nil {
|
||||
return fmt.Errorf("login failed: %w", err)
|
||||
}
|
||||
|
||||
if err = auth.SetCredential("qwen", cred); err != nil {
|
||||
return fmt.Errorf("failed to save credentials: %w", err)
|
||||
}
|
||||
|
||||
appCfg, err := internal.LoadConfig()
|
||||
if err == nil {
|
||||
// Update or add qwen-oauth in ModelList
|
||||
foundQwen := false
|
||||
for i := range appCfg.ModelList {
|
||||
if isQwenOAuthModel(appCfg.ModelList[i].Model) {
|
||||
appCfg.ModelList[i].AuthMethod = "oauth"
|
||||
foundQwen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no qwen-oauth in ModelList, add it
|
||||
if !foundQwen {
|
||||
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
|
||||
ModelName: "qwen-coder",
|
||||
Model: "qwen-oauth/coder-model",
|
||||
AuthMethod: "oauth",
|
||||
})
|
||||
}
|
||||
|
||||
// Update default model to use Qwen
|
||||
appCfg.Agents.Defaults.ModelName = "qwen-coder"
|
||||
|
||||
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||
return fmt.Errorf("could not update config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n✓ Qwen OAuth login successful!")
|
||||
fmt.Println("Default model set to: qwen-coder (Qwen Coder)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func newLoginCommand() *cobra.Command {
|
|||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, qwen)")
|
||||
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||
_ = cmd.MarkFlagRequired("provider")
|
||||
|
||||
|
|
|
|||
|
|
@ -564,3 +564,14 @@ func openBrowser(url string) error {
|
|||
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
// QwenOAuthConfig returns the OAuth configuration for Qwen Portal (通义千问).
|
||||
// Uses device code flow (QR code scan) for authentication.
|
||||
// Reference: https://github.com/openclaw/openclaw/tree/main/extensions/qwen-portal-auth
|
||||
func QwenOAuthConfig() OAuthProviderConfig {
|
||||
return OAuthProviderConfig{
|
||||
Issuer: "https://chat.qwen.ai",
|
||||
ClientID: "f0304373b74a44d2b584a3fb70ca9e56",
|
||||
Scopes: "openid profile email model.completion",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
331
pkg/auth/qwen_oauth.go
Normal file
331
pkg/auth/qwen_oauth.go
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Qwen 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
|
||||
}
|
||||
|
||||
// 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", uuid.New().String())
|
||||
|
||||
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"
|
||||
}
|
||||
|
|
@ -139,6 +139,14 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
|||
case "antigravity":
|
||||
return NewAntigravityProvider(), modelID, nil
|
||||
|
||||
case "qwen-oauth", "qwenoauth", "qwen-portal":
|
||||
// Qwen OAuth (QR code login)
|
||||
provider, err := createQwenOAuthProvider()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return provider, modelID, nil
|
||||
|
||||
case "claude-cli", "claudecli":
|
||||
workspace := cfg.Workspace
|
||||
if workspace == "" {
|
||||
|
|
|
|||
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 Qwen Portal 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 Qwen Portal 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
|
||||
}
|
||||
|
||||
// createQwenOAuthProvider 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