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:
kyeo 2026-02-26 23:58:44 +08:00
parent ba98069a00
commit a7f2eec562
6 changed files with 698 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

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