diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 98ea51103..cb7a4e1f7 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -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] + } } } diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go index 55eb3cec3..7258607f7 100644 --- a/cmd/picoclaw/cmd_auth.go +++ b/cmd/picoclaw/cmd_auth.go @@ -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 Provider to login with (openai, anthropic, google-antigravity)") + fmt.Println(" --provider 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") +} diff --git a/pkg/auth/qwen_oauth.go b/pkg/auth/qwen_oauth.go new file mode 100644 index 000000000..b75f70440 --- /dev/null +++ b/pkg/auth/qwen_oauth.go @@ -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" +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b96ee4d89..d2aebad7c 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -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 { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 7d5566eef..a5338310e 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -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" diff --git a/pkg/providers/qwen_provider.go b/pkg/providers/qwen_provider.go new file mode 100644 index 000000000..87207b3b6 --- /dev/null +++ b/pkg/providers/qwen_provider.go @@ -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 +}