cmd/launch: add Qwen code integration (#15900)

This commit is contained in:
Eva H
2026-06-01 19:51:32 -04:00
committed by GitHub
parent 7d3a6c3ae5
commit 4e807fdedd
6 changed files with 1432 additions and 3 deletions

View File

@@ -1760,6 +1760,11 @@ func TestIntegration_InstallHint(t *testing.T) {
input: "unknown",
wantEmpty: true,
},
{
name: "qwen uses official install page",
input: "qwen",
wantURL: "https://qwen.ai/qwencode",
},
{
name: "empty name has no hint",
input: "",
@@ -2016,6 +2021,7 @@ func TestIntegration_AutoInstallable(t *testing.T) {
{"pi", true},
{"hermes", true},
{"cline", true},
{"qwen", true},
{"claude", false},
{"claude-desktop", false},
{"codex", false},

View File

@@ -292,6 +292,7 @@ Supported integrations:
pi Pi
pool Pool
cline Cline
qwen Qwen Code
vscode VS Code (aliases: code)
Examples:

View File

@@ -530,6 +530,81 @@ func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAft
}
}
func TestLaunchIntegration_QwenConfiguresSingleModel(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
binDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("failed to create bin dir: %v", err)
}
writeFakeBinary(t, binDir, "qwen")
t.Setenv("PATH", binDir)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
return "gemma4", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "qwen",
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, ".qwen", "settings.json"))
if err != nil {
t.Fatalf("failed to read qwen config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse qwen config: %v", err)
}
modelCfg := cfg["model"].(map[string]any)
if modelCfg["name"] != "gemma4" {
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
}
modelProviders := cfg["modelProviders"].(map[string]any)
openai := modelProviders["openai"].([]any)
if len(openai) != 1 {
t.Fatalf("expected one provider, got %d", len(openai))
}
saved, err := config.LoadIntegration("qwen")
if err != nil {
t.Fatalf("failed to reload qwen integration config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"gemma4"}); diff != "" {
t.Fatalf("saved models mismatch: %s", diff)
}
if !saved.Onboarded {
t.Fatal("expected qwen integration to be marked onboarded")
}
}
func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -2078,7 +2153,11 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
settingsPath := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil {
t.Fatalf("failed to seed editor settings: %v", err)
}
editor := &launcherEditorRunner{paths: []string{settingsPath}}
withIntegrationOverride(t, "droid", editor)
var multiCalled bool
@@ -2778,7 +2857,11 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
settingsPath := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil {
t.Fatalf("failed to seed editor settings: %v", err)
}
editor := &launcherEditorRunner{paths: []string{settingsPath}}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil {

461
cmd/launch/qwen.go Normal file
View File

@@ -0,0 +1,461 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
const qwenOllamaEnvKey = "OLLAMA_API_KEY"
var qwenGOOS = runtime.GOOS
type Qwen struct{}
func (q *Qwen) String() string { return "Qwen Code" }
func (q *Qwen) findPath() (string, error) {
if p, err := exec.LookPath("qwen"); err == nil {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
var candidates []string
switch qwenGOOS {
case "darwin":
candidates = []string{
"/opt/homebrew/bin/qwen",
"/usr/local/bin/qwen",
filepath.Join(home, ".npm-global", "bin", "qwen"),
filepath.Join(home, ".local", "bin", "qwen"),
filepath.Join(home, "Library", "Application Support", "qwen", "bin", "qwen"),
}
candidates = append(candidates, qwenNVMCandidatePaths(home)...)
case "windows":
candidates = []string{
filepath.Join(qwenWindowsAppData(home), "npm", "qwen.cmd"),
filepath.Join(qwenWindowsAppData(home), "npm", "qwen.exe"),
filepath.Join(qwenWindowsLocalAppData(home), "npm", "qwen.cmd"),
filepath.Join(qwenWindowsLocalAppData(home), "npm", "qwen.exe"),
filepath.Join(home, "AppData", "Local", "Programs", "qwen", "qwen.exe"),
filepath.Join(home, "AppData", "Roaming", "qwen", "bin", "qwen.exe"),
}
default:
candidates = []string{
filepath.Join(home, ".npm-global", "bin", "qwen"),
filepath.Join(home, ".local", "bin", "qwen"),
filepath.Join(home, ".cargo", "bin", "qwen"),
"/usr/local/bin/qwen",
}
candidates = append(candidates, qwenNVMCandidatePaths(home)...)
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("qwen binary not found (checked PATH and common npm install locations)")
}
func qwenNVMCandidatePaths(home string) []string {
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", "qwen"))
if err != nil {
return nil
}
return matches
}
func qwenWindowsAppData(home string) string {
if appData := os.Getenv("APPDATA"); appData != "" {
return appData
}
return filepath.Join(home, "AppData", "Roaming")
}
func qwenWindowsLocalAppData(home string) string {
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
return localAppData
}
return filepath.Join(home, "AppData", "Local")
}
func ensureQwenInstalled() (string, error) {
if path, err := (&Qwen{}).findPath(); err == nil {
return path, nil
}
if err := checkQwenInstallerDependencies(); err != nil {
return "", err
}
ok, err := ConfirmPrompt("Qwen Code is not installed. Install now?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("qwen installation cancelled")
}
bin, args, err := qwenInstallerCommand(qwenGOOS)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "\nInstalling Qwen Code...\n")
shimDir, cleanup, err := qwenInstallShimDir()
if err != nil {
return "", err
}
defer cleanup()
cmd := exec.Command(bin, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = qwenInstallerEnv(os.Environ(), shimDir)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to install qwen: %w", err)
}
path, err := (&Qwen{}).findPath()
if err != nil {
return "", fmt.Errorf("qwen was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
}
fmt.Fprintf(os.Stderr, "%sQwen Code installed successfully%s\n\n", ansiGreen, ansiReset)
return path, nil
}
func qwenInstallShimDir() (string, func(), error) {
dir, err := os.MkdirTemp("", "ollama-qwen-install-*")
if err != nil {
return "", nil, err
}
cleanup := func() {
_ = os.RemoveAll(dir)
}
if qwenGOOS == "windows" {
for _, name := range []string{"qwen.cmd", "qwen.bat"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
cleanup()
return "", nil, err
}
}
return dir, cleanup, nil
}
if err := os.WriteFile(filepath.Join(dir, "qwen"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
cleanup()
return "", nil, err
}
return dir, cleanup, nil
}
func qwenInstallerEnv(env []string, shimDir string) []string {
out := make([]string, 0, len(env)+1)
pathEntry := "PATH=" + shimDir
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if ok && strings.EqualFold(key, "PATH") {
pathEntry = key + "=" + shimDir + string(os.PathListSeparator) + value
continue
}
out = append(out, entry)
}
return append(out, pathEntry)
}
func checkQwenInstallerDependencies() error {
switch qwenGOOS {
case "windows":
if _, err := exec.LookPath("powershell"); err != nil {
return fmt.Errorf("qwen is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch qwen")
}
default:
var missing []string
if _, err := exec.LookPath("curl"); err != nil {
missing = append(missing, "curl: https://curl.se/")
}
if _, err := exec.LookPath("bash"); err != nil {
missing = append(missing, "bash: https://www.gnu.org/software/bash/")
}
if len(missing) > 0 {
return fmt.Errorf("qwen is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch qwen", strings.Join(missing, "\n "))
}
}
return nil
}
func qwenInstallerCommand(goos string) (string, []string, error) {
switch goos {
case "windows":
return "powershell", []string{
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"$installer = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest -UseBasicParsing -Uri 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile $installer; $content = Get-Content -Raw -Path $installer; $content = $content -replace '(?m)^\\s*call qwen\\s*$', 'REM call qwen'; Set-Content -Path $installer -Value $content -Encoding ASCII; & $installer",
}, nil
case "darwin", "linux":
return "bash", []string{
"-c",
"set -o pipefail; curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | sed '/log_info \"Starting Qwen Code...\"/,/exec qwen/d' | bash",
}, nil
default:
return "", nil, fmt.Errorf("unsupported platform for qwen install: %s", goos)
}
}
func (q *Qwen) Run(model string, _ []LaunchModel, args []string) error {
qwenPath, err := q.findPath()
if err != nil {
return fmt.Errorf("qwen is not installed: %w", err)
}
cmd := exec.Command(qwenPath, qwenLaunchArgs(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = qwenLaunchEnv(model)
return cmd.Run()
}
func (q *Qwen) Paths() []string {
path, err := q.configPath()
if err != nil {
return nil
}
return []string{path}
}
func (q *Qwen) Configure(model string) error {
if model == "" {
return nil
}
configPath, err := q.configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
cfg, err := q.readConfig()
if err != nil {
return err
}
applyQwenOllamaConfig(cfg, model)
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data, "qwen")
}
func applyQwenOllamaConfig(cfg map[string]any, model string) {
envCfg := qwenMap(cfg["env"])
envCfg[qwenOllamaEnvKey] = "ollama"
cfg["env"] = envCfg
modelProviders := qwenMap(cfg["modelProviders"])
modelProviders["openai"] = qwenMergeOpenAIProviders(modelProviders["openai"], qwenProvider(model))
cfg["modelProviders"] = modelProviders
security := qwenMap(cfg["security"])
auth := qwenMap(security["auth"])
auth["selectedType"] = "openai"
auth["baseUrl"] = qwenBaseURL()
security["auth"] = auth
cfg["security"] = security
modelCfg := qwenMap(cfg["model"])
modelCfg["name"] = model
cfg["model"] = modelCfg
}
func qwenMap(value any) map[string]any {
if m, ok := value.(map[string]any); ok {
return m
}
return map[string]any{}
}
func qwenMergeOpenAIProviders(value any, provider map[string]any) []any {
merged := []any{provider}
for _, existing := range qwenProviderList(value) {
if qwenIsOllamaProvider(existing) {
continue
}
merged = append(merged, existing)
}
return merged
}
func qwenProviderList(value any) []any {
switch providers := value.(type) {
case []any:
return providers
case []map[string]any:
out := make([]any, 0, len(providers))
for _, provider := range providers {
out = append(out, provider)
}
return out
default:
return nil
}
}
func qwenIsOllamaProvider(value any) bool {
provider, ok := value.(map[string]any)
if !ok {
return false
}
envKey, _ := provider["envKey"].(string)
baseURL, _ := provider["baseUrl"].(string)
return envKey == qwenOllamaEnvKey && strings.TrimRight(baseURL, "/") == qwenBaseURL()
}
func (q *Qwen) CurrentModel() string {
cfg, err := q.readConfig()
if err != nil {
return ""
}
if modelCfg, ok := cfg["model"].(map[string]any); ok {
if name, ok := modelCfg["name"].(string); ok {
return strings.TrimSpace(name)
}
}
modelProviders, ok := cfg["modelProviders"].(map[string]any)
if !ok {
return ""
}
providers, ok := modelProviders["openai"].([]any)
if !ok || len(providers) == 0 {
return ""
}
provider, ok := providers[0].(map[string]any)
if !ok {
return ""
}
name, _ := provider["id"].(string)
return strings.TrimSpace(name)
}
func (q *Qwen) Onboard() error {
return config.MarkIntegrationOnboarded("qwen")
}
func (q *Qwen) RequiresInteractiveOnboarding() bool { return false }
func (q *Qwen) configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine config path")
}
return filepath.Join(home, ".qwen", "settings.json"), nil
}
func (q *Qwen) readConfig() (map[string]any, error) {
configPath, err := q.configPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return map[string]any{}, nil
}
return nil, err
}
cfg := map[string]any{}
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse qwen config: %w", err)
}
return cfg, nil
}
func qwenBaseURL() string {
return strings.TrimRight(envconfig.Host().String(), "/") + "/v1"
}
func qwenProvider(model string) map[string]any {
return map[string]any{
"id": model,
"name": fmt.Sprintf("%s (Ollama)", model),
"baseUrl": qwenBaseURL(),
"envKey": qwenOllamaEnvKey,
}
}
func qwenLaunchArgs(model string, args []string) []string {
launchArgs := append([]string{}, args...)
if !qwenHasFlag(launchArgs, "--auth-type") {
launchArgs = append([]string{"--auth-type", "openai"}, launchArgs...)
}
if model != "" && !qwenHasFlag(launchArgs, "--model", "-m") {
launchArgs = append([]string{"--model", model}, launchArgs...)
}
return launchArgs
}
func qwenLaunchEnv(model string) []string {
env := os.Environ()
env = qwenUpsertEnv(env, "OPENAI_API_KEY", "ollama")
env = qwenUpsertEnv(env, "OPENAI_BASE_URL", qwenBaseURL())
if model != "" {
env = qwenUpsertEnv(env, "OPENAI_MODEL", model)
}
return env
}
func qwenUpsertEnv(env []string, key, value string) []string {
prefix := key + "="
filtered := env[:0]
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
continue
}
filtered = append(filtered, entry)
}
return append(filtered, prefix+value)
}
func qwenHasFlag(args []string, names ...string) bool {
for _, arg := range args {
for _, name := range names {
if arg == name || strings.HasPrefix(arg, name+"=") {
return true
}
}
}
return false
}

862
cmd/launch/qwen_test.go Normal file
View File

@@ -0,0 +1,862 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
func setQwenTestHome(t *testing.T, home string) {
t.Helper()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
}
func TestQwenConfigure(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
q := &Qwen{}
if err := q.Configure("gemma4"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, ".qwen", "settings.json"))
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse config: %v", err)
}
envCfg := cfg["env"].(map[string]any)
if envCfg[qwenOllamaEnvKey] != "ollama" {
t.Fatalf("expected env[%q] to be ollama, got %v", qwenOllamaEnvKey, envCfg[qwenOllamaEnvKey])
}
modelCfg := cfg["model"].(map[string]any)
if modelCfg["name"] != "gemma4" {
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
}
security := cfg["security"].(map[string]any)
auth := security["auth"].(map[string]any)
if auth["selectedType"] != "openai" {
t.Fatalf("expected auth.selectedType openai, got %v", auth["selectedType"])
}
if auth["baseUrl"] != qwenBaseURL() {
t.Fatalf("expected auth.baseUrl %q, got %v", qwenBaseURL(), auth["baseUrl"])
}
modelProviders := cfg["modelProviders"].(map[string]any)
openai := modelProviders["openai"].([]any)
if len(openai) != 1 {
t.Fatalf("expected one openai provider, got %d", len(openai))
}
provider := openai[0].(map[string]any)
if provider["id"] != "gemma4" {
t.Fatalf("expected provider id gemma4, got %v", provider["id"])
}
if provider["name"] != "gemma4 (Ollama)" {
t.Fatalf("expected provider name %q, got %v", "gemma4 (Ollama)", provider["name"])
}
if provider["baseUrl"] != qwenBaseURL() {
t.Fatalf("expected provider baseUrl %q, got %v", qwenBaseURL(), provider["baseUrl"])
}
if provider["envKey"] != qwenOllamaEnvKey {
t.Fatalf("expected provider envKey %q, got %v", qwenOllamaEnvKey, provider["envKey"])
}
}
func TestQwenConfigureBacksUpUnderIntegrationDirectory(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, []byte(`{"original":true}`), 0o644); err != nil {
t.Fatalf("failed to write initial config: %v", err)
}
if err := (&Qwen{}).Configure("gemma4"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
backups, err := filepath.Glob(filepath.Join(fileutil.BackupDir(), "qwen", "settings.json.*"))
if err != nil {
t.Fatalf("failed to glob backups: %v", err)
}
for _, backup := range backups {
data, err := os.ReadFile(backup)
if err != nil {
t.Fatalf("failed to read backup: %v", err)
}
if string(data) == `{"original":true}` {
return
}
}
t.Fatalf("backup with original content not found in %v", backups)
}
func TestQwenConfigureMergesWithExistingSettings(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
initialConfig := []byte(`{
"theme": "dark",
"env": {
"OPENROUTER_API_KEY": "openrouter-key",
"OLLAMA_API_KEY": "old-ollama-key"
},
"modelProviders": {
"openai": [
{
"id": "old-ollama",
"name": "old-ollama (Ollama)",
"envKey": "OLLAMA_API_KEY",
"baseUrl": "` + qwenBaseURL() + `"
},
{
"id": "openrouter/model",
"name": "OpenRouter Model",
"envKey": "OPENROUTER_API_KEY",
"baseUrl": "https://openrouter.ai/api/v1",
"customField": "preserved"
},
{
"id": "remote-ollama",
"name": "Remote Ollama",
"envKey": "OLLAMA_API_KEY",
"baseUrl": "http://10.0.0.20:11434/v1"
}
],
"gemini": [
{
"id": "gemini-2.5-pro",
"envKey": "GEMINI_API_KEY"
}
]
},
"security": {
"auth": {
"selectedType": "qwen-oauth",
"baseUrl": "https://old.example/v1",
"customAuthField": "preserved"
},
"trustedFolders": ["/tmp/project"]
},
"model": {
"name": "old-ollama",
"generationConfig": {
"temperature": 0.2
}
}
}`)
if err := os.WriteFile(configPath, initialConfig, 0o644); err != nil {
t.Fatalf("failed to write initial config: %v", err)
}
if err := (&Qwen{}).Configure("gemma4"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse config: %v", err)
}
if cfg["theme"] != "dark" {
t.Fatalf("expected top-level theme to be preserved, got %v", cfg["theme"])
}
envCfg := cfg["env"].(map[string]any)
if envCfg["OPENROUTER_API_KEY"] != "openrouter-key" {
t.Fatalf("expected OPENROUTER_API_KEY to be preserved, got %v", envCfg["OPENROUTER_API_KEY"])
}
if envCfg[qwenOllamaEnvKey] != "ollama" {
t.Fatalf("expected %s to be updated, got %v", qwenOllamaEnvKey, envCfg[qwenOllamaEnvKey])
}
modelProviders := cfg["modelProviders"].(map[string]any)
gemini := modelProviders["gemini"].([]any)
if len(gemini) != 1 {
t.Fatalf("expected gemini providers to be preserved, got %v", gemini)
}
openai := modelProviders["openai"].([]any)
if len(openai) != 3 {
t.Fatalf("expected new Ollama provider plus preserved OpenRouter and remote Ollama providers, got %v", openai)
}
ollamaProvider := openai[0].(map[string]any)
if ollamaProvider["id"] != "gemma4" {
t.Fatalf("expected Ollama provider to update to gemma4, got %v", ollamaProvider["id"])
}
openRouterProvider := openai[1].(map[string]any)
if openRouterProvider["id"] != "openrouter/model" {
t.Fatalf("expected OpenRouter provider to be preserved, got %v", openRouterProvider["id"])
}
if openRouterProvider["customField"] != "preserved" {
t.Fatalf("expected OpenRouter custom field to be preserved, got %v", openRouterProvider["customField"])
}
remoteOllamaProvider := openai[2].(map[string]any)
if remoteOllamaProvider["id"] != "remote-ollama" {
t.Fatalf("expected remote Ollama provider to be preserved, got %v", remoteOllamaProvider["id"])
}
security := cfg["security"].(map[string]any)
auth := security["auth"].(map[string]any)
if auth["selectedType"] != "openai" {
t.Fatalf("expected selectedType openai, got %v", auth["selectedType"])
}
if auth["baseUrl"] != qwenBaseURL() {
t.Fatalf("expected auth.baseUrl %q, got %v", qwenBaseURL(), auth["baseUrl"])
}
if auth["customAuthField"] != "preserved" {
t.Fatalf("expected custom auth field to be preserved, got %v", auth["customAuthField"])
}
if len(security["trustedFolders"].([]any)) != 1 {
t.Fatalf("expected security.trustedFolders to be preserved, got %v", security["trustedFolders"])
}
modelCfg := cfg["model"].(map[string]any)
if modelCfg["name"] != "gemma4" {
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
}
generationConfig := modelCfg["generationConfig"].(map[string]any)
if generationConfig["temperature"] != 0.2 {
t.Fatalf("expected model generationConfig to be preserved, got %v", generationConfig)
}
}
func TestQwenCurrentModel(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
q := &Qwen{}
if got := q.CurrentModel(); got != "" {
t.Fatalf("expected empty model without config, got %q", got)
}
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, []byte(`{"model":{"name":"llama3.2"}}`), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if got := q.CurrentModel(); got != "llama3.2" {
t.Fatalf("expected current model llama3.2, got %q", got)
}
}
func TestQwenCurrentModelFallsBackToProvider(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, []byte(`{"modelProviders":{"openai":[{"id":"mistral"}]}}`), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if got := (&Qwen{}).CurrentModel(); got != "mistral" {
t.Fatalf("expected provider fallback mistral, got %q", got)
}
}
func TestQwenOnboard(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
if err := (&Qwen{}).Onboard(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
saved, err := config.LoadIntegration("qwen")
if err != nil {
t.Fatalf("failed to load integration config: %v", err)
}
if !saved.Onboarded {
t.Fatal("expected qwen integration to be marked onboarded")
}
}
func TestQwenIntegration(t *testing.T) {
q := &Qwen{}
t.Run("String", func(t *testing.T) {
if got := q.String(); got != "Qwen Code" {
t.Fatalf("String() = %q, want %q", got, "Qwen Code")
}
})
t.Run("implements Runner", func(t *testing.T) {
var _ Runner = q
})
t.Run("implements ManagedSingleModel", func(t *testing.T) {
var _ ManagedSingleModel = q
})
t.Run("implements ManagedInteractiveOnboarding", func(t *testing.T) {
var _ ManagedInteractiveOnboarding = q
})
}
func TestQwenFindPath(t *testing.T) {
q := &Qwen{}
path, err := q.findPath()
if err != nil {
t.Skipf("qwen binary not found, skipping: %v", err)
}
if path == "" {
t.Fatal("expected non-empty path")
}
}
func TestQwenPaths(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "qwen-paths-test")
setQwenTestHome(t, testDir)
q := &Qwen{}
os.MkdirAll(filepath.Join(testDir, ".qwen"), 0o755)
os.WriteFile(filepath.Join(testDir, ".qwen", "settings.json"), []byte("{}"), 0o644)
paths := q.Paths()
if len(paths) != 1 {
t.Fatalf("expected 1 path, got %v", paths)
}
want, err := filepath.EvalSymlinks(filepath.Join(testDir, ".qwen", "settings.json"))
if err != nil {
t.Fatalf("failed to resolve expected path: %v", err)
}
got, err := filepath.EvalSymlinks(paths[0])
if err != nil {
t.Fatalf("failed to resolve returned path: %v", err)
}
if got != want {
t.Fatalf("expected user config path %s, got %s", want, got)
}
}
func TestQwenLaunchArgs(t *testing.T) {
got := qwenLaunchArgs("llama3.2", nil)
want := []string{"--model", "llama3.2", "--auth-type", "openai"}
if !slices.Equal(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
got = qwenLaunchArgs("llama3.2", []string{"--auth-type", "openai"})
want = []string{"--model", "llama3.2", "--auth-type", "openai"}
if !slices.Equal(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
got = qwenLaunchArgs("llama3.2", []string{"-m", "gemma4"})
want = []string{"--auth-type", "openai", "-m", "gemma4"}
if !slices.Equal(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestQwenLaunchEnv(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("OPENAI_BASE_URL", "")
t.Setenv("OPENAI_MODEL", "")
env := qwenLaunchEnv("llama3.2")
if !slices.Contains(env, "OPENAI_API_KEY=ollama") {
t.Fatalf("expected OPENAI_API_KEY override, got %v", env)
}
if !slices.Contains(env, "OPENAI_BASE_URL="+qwenBaseURL()) {
t.Fatalf("expected OPENAI_BASE_URL override, got %v", env)
}
if !slices.Contains(env, "OPENAI_MODEL=llama3.2") {
t.Fatalf("expected OPENAI_MODEL override, got %v", env)
}
}
func TestQwenLaunchEnvOverridesExistingOpenAIEnv(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "real-key")
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
t.Setenv("OPENAI_MODEL", "gpt-4.1")
env := qwenLaunchEnv("llama3.2")
if !slices.Contains(env, "OPENAI_API_KEY=ollama") {
t.Fatalf("expected OPENAI_API_KEY override, got %v", env)
}
if !slices.Contains(env, "OPENAI_BASE_URL="+qwenBaseURL()) {
t.Fatalf("expected OPENAI_BASE_URL override, got %v", env)
}
if !slices.Contains(env, "OPENAI_MODEL=llama3.2") {
t.Fatalf("expected OPENAI_MODEL override, got %v", env)
}
}
func TestQwenRunDoesNotRewriteConfig(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
t.Chdir(tmpDir)
qwenBinDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(qwenBinDir, 0o755); err != nil {
t.Fatalf("failed to create bin dir: %v", err)
}
qwenBin := filepath.Join(qwenBinDir, "qwen")
qwenScript := "#!/bin/sh\nexit 0\n"
if runtime.GOOS == "windows" {
qwenBin = filepath.Join(qwenBinDir, "qwen.bat")
qwenScript = "@echo off\r\nexit /b 0\r\n"
}
if err := os.WriteFile(qwenBin, []byte(qwenScript), 0o755); err != nil {
t.Fatalf("failed to write fake qwen binary: %v", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(qwenBin, 0o755); err != nil {
t.Fatalf("failed to chmod fake qwen binary: %v", err)
}
}
t.Setenv("PATH", qwenBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
initialConfig := []byte(`{"model":{"name":"qwen3:32b"}}`)
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, initialConfig, 0o644); err != nil {
t.Fatalf("failed to write initial config: %v", err)
}
if err := (&Qwen{}).Run("qwen3:32b", nil, nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config after run: %v", err)
}
if string(data) != string(initialConfig) {
t.Fatalf("expected run not to rewrite config, got %s", string(data))
}
}
func TestEnsureQwenInstalled(t *testing.T) {
oldGOOS := qwenGOOS
t.Cleanup(func() { qwenGOOS = oldGOOS })
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
t.Helper()
oldConfirm := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return fn(prompt)
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
}
t.Run("already installed", func(t *testing.T) {
setQwenTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
writeFakeBinary(t, tmpDir, "qwen")
qwenGOOS = runtime.GOOS
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
bin, err := ensureQwenInstalled()
if err != nil {
t.Fatalf("ensureQwenInstalled() error = %v", err)
}
if filepath.Base(bin) == "" {
t.Fatalf("expected qwen binary path, got %q", bin)
}
})
t.Run("missing dependencies", func(t *testing.T) {
setQwenTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
qwenGOOS = "linux"
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
_, err := ensureQwenInstalled()
if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") {
t.Fatalf("expected missing dependency error, got %v", err)
}
})
t.Run("missing and user declines install", func(t *testing.T) {
setQwenTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
writeFakeBinary(t, tmpDir, "curl")
writeFakeBinary(t, tmpDir, "bash")
qwenGOOS = "linux"
withConfirm(t, func(prompt string) (bool, error) {
if !strings.Contains(prompt, "Qwen Code is not installed.") {
t.Fatalf("unexpected prompt: %q", prompt)
}
return false, nil
})
_, err := ensureQwenInstalled()
if err == nil || !strings.Contains(err.Error(), "installation cancelled") {
t.Fatalf("expected cancellation error, got %v", err)
}
})
t.Run("missing and user confirms unix install succeeds", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
qwenGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
installLog := filepath.Join(tmpDir, "bash.log")
qwenPath := filepath.Join(homeDir, ".npm-global", "bin", "qwen")
bashScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
if [ "$1" = "-c" ]; then
/bin/mkdir -p %q
/bin/cat > %q <<'EOS'
#!/bin/sh
exit 0
EOS
/bin/chmod +x %q
fi
exit 0
`, installLog, filepath.Dir(qwenPath), qwenPath, qwenPath)
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
bin, err := ensureQwenInstalled()
if err != nil {
t.Fatalf("ensureQwenInstalled() error = %v", err)
}
if bin != qwenPath {
t.Fatalf("bin = %q, want %q", bin, qwenPath)
}
logData, err := os.ReadFile(installLog)
if err != nil {
t.Fatalf("failed to read install log: %v", err)
}
if !strings.Contains(string(logData), "install-qwen.sh") {
t.Fatalf("expected install-qwen.sh command in log, got:\n%s", string(logData))
}
if !strings.Contains(string(logData), "exec qwen/d") {
t.Fatalf("expected command to remove installer auto-start block, got:\n%s", string(logData))
}
})
t.Run("missing and user confirms windows install succeeds", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
appData := filepath.Join(homeDir, "AppData", "Roaming")
t.Setenv("APPDATA", appData)
t.Setenv("LOCALAPPDATA", filepath.Join(homeDir, "AppData", "Local"))
qwenGOOS = "windows"
installLog := filepath.Join(tmpDir, "powershell.log")
qwenPath := filepath.Join(appData, "npm", "qwen.cmd")
powershellScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
/bin/mkdir -p %q
/bin/cat > %q <<'EOS'
@echo off
exit /b 0
EOS
/bin/chmod +x %q
exit 0
`, installLog, filepath.Dir(qwenPath), qwenPath, qwenPath)
if err := os.WriteFile(filepath.Join(tmpDir, "powershell"), []byte(powershellScript), 0o755); err != nil {
t.Fatalf("failed to write fake powershell: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
bin, err := ensureQwenInstalled()
if err != nil {
t.Fatalf("ensureQwenInstalled() error = %v", err)
}
if bin != qwenPath {
t.Fatalf("bin = %q, want %q", bin, qwenPath)
}
logData, err := os.ReadFile(installLog)
if err != nil {
t.Fatalf("failed to read install log: %v", err)
}
if !strings.Contains(string(logData), "install-qwen.bat") {
t.Fatalf("expected install-qwen.bat command in log, got:\n%s", string(logData))
}
if !strings.Contains(string(logData), "REM call qwen") {
t.Fatalf("expected command to replace installer auto-start call, got:\n%s", string(logData))
}
})
t.Run("install command fails", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
setQwenTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
qwenGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
_, err := ensureQwenInstalled()
if err == nil || !strings.Contains(err.Error(), "failed to install qwen") {
t.Fatalf("expected install failure error, got %v", err)
}
})
}
func TestQwenFindPathFallbacks(t *testing.T) {
oldGOOS := qwenGOOS
t.Cleanup(func() { qwenGOOS = oldGOOS })
t.Run("unix npm global bin", func(t *testing.T) {
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
qwenGOOS = "linux"
target := filepath.Join(homeDir, ".npm-global", "bin", "qwen")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create qwen dir: %v", err)
}
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("failed to write qwen binary: %v", err)
}
got, err := (&Qwen{}).findPath()
if err != nil {
t.Fatalf("findPath() error = %v", err)
}
if got != target {
t.Fatalf("findPath() = %q, want %q", got, target)
}
})
t.Run("windows appdata npm shim", func(t *testing.T) {
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
appData := filepath.Join(homeDir, "AppData", "Roaming")
t.Setenv("APPDATA", appData)
t.Setenv("LOCALAPPDATA", filepath.Join(homeDir, "AppData", "Local"))
qwenGOOS = "windows"
target := filepath.Join(appData, "npm", "qwen.cmd")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create qwen dir: %v", err)
}
if err := os.WriteFile(target, []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
t.Fatalf("failed to write qwen shim: %v", err)
}
got, err := (&Qwen{}).findPath()
if err != nil {
t.Fatalf("findPath() error = %v", err)
}
if got != target {
t.Fatalf("findPath() = %q, want %q", got, target)
}
})
t.Run("unix nvm npm bin", func(t *testing.T) {
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
qwenGOOS = "linux"
target := filepath.Join(homeDir, ".nvm", "versions", "node", "v20.18.1", "bin", "qwen")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create qwen dir: %v", err)
}
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("failed to write qwen binary: %v", err)
}
got, err := (&Qwen{}).findPath()
if err != nil {
t.Fatalf("findPath() error = %v", err)
}
if got != target {
t.Fatalf("findPath() = %q, want %q", got, target)
}
})
}
func TestQwenInstallShimDir(t *testing.T) {
oldGOOS := qwenGOOS
t.Cleanup(func() { qwenGOOS = oldGOOS })
t.Run("unix shim", func(t *testing.T) {
qwenGOOS = "linux"
dir, cleanup, err := qwenInstallShimDir()
if err != nil {
t.Fatalf("qwenInstallShimDir() error = %v", err)
}
defer cleanup()
if _, err := os.Stat(filepath.Join(dir, "qwen")); err != nil {
t.Fatalf("expected qwen shim: %v", err)
}
})
t.Run("windows shim", func(t *testing.T) {
qwenGOOS = "windows"
dir, cleanup, err := qwenInstallShimDir()
if err != nil {
t.Fatalf("qwenInstallShimDir() error = %v", err)
}
defer cleanup()
for _, name := range []string{"qwen.cmd", "qwen.bat"} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatalf("expected %s shim: %v", name, err)
}
}
})
}
func TestQwenInstallerEnvPrependsShimPath(t *testing.T) {
env := qwenInstallerEnv([]string{"FOO=bar", "PATH=/usr/bin"}, "/tmp/qwen-shim")
if !slices.Contains(env, "FOO=bar") {
t.Fatalf("expected unrelated env to be preserved, got %v", env)
}
if !slices.Contains(env, "PATH=/tmp/qwen-shim"+string(os.PathListSeparator)+"/usr/bin") {
t.Fatalf("expected shim path to be prepended, got %v", env)
}
env = qwenInstallerEnv([]string{"Path=C:\\Windows"}, "C:\\qwen-shim")
if !slices.Contains(env, "Path=C:\\qwen-shim"+string(os.PathListSeparator)+"C:\\Windows") {
t.Fatalf("expected existing Path casing to be preserved, got %v", env)
}
}
func TestQwenInstallerCommand(t *testing.T) {
tests := []struct {
name string
goos string
wantBin string
wantParts []string
wantErr bool
}{
{
name: "linux",
goos: "linux",
wantBin: "bash",
wantParts: []string{"-c", "install-qwen.sh", "sed", "exec qwen/d"},
},
{
name: "darwin",
goos: "darwin",
wantBin: "bash",
wantParts: []string{"-c", "install-qwen.sh", "sed", "exec qwen/d"},
},
{
name: "windows",
goos: "windows",
wantBin: "powershell",
wantParts: []string{"-Command", "-UseBasicParsing", "-OutFile", "Get-Content -Raw", "install-qwen.bat", "REM call qwen"},
},
{
name: "unsupported",
goos: "freebsd",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bin, args, err := qwenInstallerCommand(tt.goos)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("qwenInstallerCommand() error = %v", err)
}
if bin != tt.wantBin {
t.Fatalf("bin = %q, want %q", bin, tt.wantBin)
}
joined := strings.Join(args, " ")
for _, part := range tt.wantParts {
if !strings.Contains(joined, part) {
t.Fatalf("args %q missing %q", joined, part)
}
}
})
}
}

View File

@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "cline", "droid", "pi", "pool"}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "cline", "droid", "pi", "pool", "qwen"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -233,6 +233,22 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://code.visualstudio.com",
},
},
{
Name: "qwen",
Runner: &Qwen{},
Description: "Qwen's AI coding agent with tool use",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := (&Qwen{}).findPath()
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureQwenInstalled()
return err
},
URL: "https://qwen.ai/qwencode",
},
},
}
var integrationSpecsByName map[string]*IntegrationSpec