diff --git a/README.md b/README.md index 1e5a52d24..e511fbe3f 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ To launch a specific integration: ollama launch claude ``` -Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode). +Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [DeepSeek Harness](https://docs.ollama.com/integrations/deepseek-harness), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode). ### AI assistant diff --git a/app/ui/app/public/launch-icons/deepseek-harness.svg b/app/ui/app/public/launch-icons/deepseek-harness.svg new file mode 100644 index 000000000..4f770e51f --- /dev/null +++ b/app/ui/app/public/launch-icons/deepseek-harness.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/ui/app/src/components/LaunchCommands.tsx b/app/ui/app/src/components/LaunchCommands.tsx index dcf2ab937..e050681dc 100644 --- a/app/ui/app/src/components/LaunchCommands.tsx +++ b/app/ui/app/src/components/LaunchCommands.tsx @@ -77,6 +77,14 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [ description: "Factory's coding agent across terminal and IDEs", icon: "/launch-icons/droid.svg", }, + { + id: "dsh", + name: "DeepSeek Harness", + command: "ollama launch dsh", + description: "DeepSeek's open-source agent harness", + icon: "/launch-icons/deepseek-harness.svg", + iconClassName: "h-7 w-7", + }, { id: "pi", name: "Pi", diff --git a/cmd/launch/deepseek_harness.go b/cmd/launch/deepseek_harness.go new file mode 100644 index 000000000..07778dbc6 --- /dev/null +++ b/cmd/launch/deepseek_harness.go @@ -0,0 +1,520 @@ +package launch + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + + "github.com/ollama/ollama/cmd/config" + "github.com/ollama/ollama/cmd/internal/fileutil" + "github.com/ollama/ollama/envconfig" + "github.com/ollama/ollama/types/model" + "gopkg.in/yaml.v3" +) + +const ( + deepSeekHarnessIntegrationName = "dsh" + deepSeekHarnessNpmPackage = "@deepseek-ai/dsh@latest" + deepSeekHarnessProvider = "ollama" + deepSeekHarnessAPIKeyEnv = "OLLAMA_LAUNCH_DSH_API_KEY" + deepSeekHarnessWebSettings = "web-search-deepseek" +) + +var ( + deepSeekHarnessLookPath = exec.LookPath + deepSeekHarnessCommand = exec.Command + deepSeekHarnessGOOS = runtime.GOOS +) + +// DeepSeekHarness is the Ollama-managed DeepSeek Harness integration. +// It redirects only the settings provider for this invocation to an +// Ollama-owned document. The user's normal DSH_HOME, profiles, sessions, +// credentials, and patch layers remain available and untouched. +type DeepSeekHarness struct{} + +func (d *DeepSeekHarness) String() string { return "DeepSeek Harness" } + +func (d *DeepSeekHarness) Run(_ string, _ []LaunchModel, args []string) error { + if err := validateDeepSeekHarnessArgs(args); err != nil { + return err + } + + bin, err := deepSeekHarnessLookPath("dsh") + if err != nil { + return fmt.Errorf("dsh is not installed: %w", err) + } + patchPath, err := deepSeekHarnessPatchPath() + if err != nil { + return err + } + + cmd, err := deepSeekHarnessExecutableCommand(bin, deepSeekHarnessLaunchArgs(patchPath, args)) + if err != nil { + return err + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = deepSeekHarnessLaunchEnv(os.Environ()) + return cmd.Run() +} + +func deepSeekHarnessLaunchArgs(patchPath string, args []string) []string { + launchArgs := []string{"web", "--patch", patchPath} + return append(launchArgs, args...) +} + +func validateDeepSeekHarnessArgs(args []string) error { + for _, arg := range args { + if arg == "--patch" || strings.HasPrefix(arg, "--patch=") { + return fmt.Errorf("conflicting extra argument %q: ollama launch dsh manages --patch", arg) + } + } + return nil +} + +func deepSeekHarnessLaunchEnv(env []string) []string { + return deepSeekHarnessUpsertEnv(env, deepSeekHarnessAPIKeyEnv, "ollama") +} + +func deepSeekHarnessUpsertEnv(env []string, key, value string) []string { + prefix := key + "=" + out := make([]string, 0, len(env)+1) + for _, entry := range env { + if strings.HasPrefix(entry, prefix) { + continue + } + out = append(out, entry) + } + return append(out, prefix+value) +} + +func ensureDeepSeekHarnessInstalled() (string, error) { + if path, err := deepSeekHarnessLookPath("dsh"); err == nil { + return path, nil + } + npm, err := deepSeekHarnessLookPath("npm") + if err != nil { + return "", fmt.Errorf("dsh is not installed and npm (Node.js) is required\n\nInstall Node.js first:\n https://nodejs.org/\n\nThen re-run:\n ollama launch dsh") + } + + ok, err := ConfirmPrompt("DeepSeek Harness is not installed. Install with npm?") + if err != nil { + return "", err + } + if !ok { + return "", fmt.Errorf("deepseek harness installation cancelled") + } + + fmt.Fprintln(os.Stderr, "\nInstalling DeepSeek Harness...") + cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage}) + if err != nil { + return "", err + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to install deepseek harness: %w", err) + } + + path, err := deepSeekHarnessLookPath("dsh") + if err != nil { + return "", fmt.Errorf("deepseek harness was installed but dsh was not found on PATH\n\nYou may need to restart your shell") + } + fmt.Fprintf(os.Stderr, "%sDeepSeek Harness installed successfully%s\n\n", ansiGreen, ansiReset) + return path, nil +} + +func deepSeekHarnessExecutableCommand(bin string, args []string) (*exec.Cmd, error) { + return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"}, args) +} + +func deepSeekHarnessNpmCommand(bin string, args []string) (*exec.Cmd, error) { + return deepSeekHarnessNodeShimCommand(bin, []string{"node_modules", "npm", "bin", "npm-cli.js"}, args) +} + +// Windows npm binaries are .cmd shims, which cannot be passed safely to +// CreateProcess with an argv. Invoke their JavaScript entrypoints with Node so +// passthrough arguments remain data rather than cmd.exe syntax. +func deepSeekHarnessNodeShimCommand(shim string, entrypointParts, args []string) (*exec.Cmd, error) { + if deepSeekHarnessGOOS != "windows" || !deepSeekHarnessIsCommandShim(shim) { + return deepSeekHarnessCommand(shim, args...), nil + } + + node, err := deepSeekHarnessLookPath("node") + if err != nil { + return nil, fmt.Errorf("node is required to run %s on Windows: %w", filepath.Base(shim), err) + } + entrypoint := filepath.Join(append([]string{filepath.Dir(shim)}, entrypointParts...)...) + if _, err := os.Stat(entrypoint); err != nil { + return nil, fmt.Errorf("resolve Windows entrypoint for %s: %w", filepath.Base(shim), err) + } + return deepSeekHarnessCommand(node, append([]string{entrypoint}, args...)...), nil +} + +func deepSeekHarnessIsCommandShim(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".cmd" || ext == ".bat" +} + +func (d *DeepSeekHarness) Paths() []string { + settingsPath, settingsErr := deepSeekHarnessSettingsPath() + patchPath, patchErr := deepSeekHarnessPatchPath() + if settingsErr != nil || patchErr != nil { + return nil + } + return []string{settingsPath, patchPath} +} + +func (d *DeepSeekHarness) Configure(modelName string) error { + return d.ConfigureWithModels(modelName, []LaunchModel{fallbackLaunchModel(modelName)}) +} + +func (d *DeepSeekHarness) ConfigureWithModels(primary string, models []LaunchModel) error { + if strings.TrimSpace(primary) == "" { + return nil + } + if len(models) == 0 { + models = []LaunchModel{fallbackLaunchModel(primary)} + } + if selected, ok := findLaunchModel(models, primary); ok { + primary = selected.Name + } + + settingsPath, err := deepSeekHarnessSettingsPath() + if err != nil { + return err + } + settings, err := readDeepSeekHarnessYAMLDocument(settingsPath) + if err != nil { + return fmt.Errorf("parse deepseek harness launch settings: %w", err) + } + if err := applyDeepSeekHarnessSettings(settings, primary, models, shouldManageOllamaWebSearch()); err != nil { + return err + } + + settingsData, err := yaml.Marshal(settings) + if err != nil { + return err + } + if err := writeDeepSeekHarnessFile(settingsPath, settingsData); err != nil { + return err + } + + patchPath, err := deepSeekHarnessPatchPath() + if err != nil { + return err + } + patchData, err := yaml.Marshal([]map[string]any{ + { + "id": "settings", + "config": map[string]any{ + "path": settingsPath, + }, + }, + }) + if err != nil { + return err + } + return writeDeepSeekHarnessFile(patchPath, patchData) +} + +func readDeepSeekHarnessYAML(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]any), nil + } + return nil, err + } + settings := make(map[string]any) + if err := yaml.Unmarshal(data, &settings); err != nil { + return nil, err + } + if settings == nil { + settings = make(map[string]any) + } + return settings, nil +} + +func readDeepSeekHarnessYAMLDocument(path string) (*yaml.Node, error) { + document := &yaml.Node{Kind: yaml.DocumentNode} + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}} + return document, nil + } + if err := yaml.Unmarshal(data, document); err != nil { + return nil, err + } + if len(document.Content) == 0 || document.Content[0].Kind == yaml.ScalarNode && document.Content[0].Tag == "!!null" { + document.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}} + } + if document.Content[0].Kind != yaml.MappingNode { + return nil, fmt.Errorf("settings root must be a mapping") + } + return document, nil +} + +func applyDeepSeekHarnessSettings(document *yaml.Node, primary string, models []LaunchModel, manageWebSearch bool) error { + settings := document.Content[0] + selected := deepSeekHarnessEnsureYAMLMapping(settings, "agent-default-model") + for key, value := range map[string]string{ + "provider": deepSeekHarnessProvider, + "model": primary, + } { + if err := deepSeekHarnessSetYAMLValue(selected, key, value); err != nil { + return err + } + } + + llm := deepSeekHarnessEnsureYAMLMapping(settings, "llm-pi-ai") + providers := deepSeekHarnessEnsureYAMLMapping(llm, "providers") + provider := deepSeekHarnessEnsureYAMLMapping(providers, deepSeekHarnessProvider) + for key, value := range map[string]any{ + "displayName": "Ollama", + "apiKeyEnv": deepSeekHarnessAPIKeyEnv, + "api": "openai-completions", + "baseURL": deepSeekHarnessBaseURL(), + "models": deepSeekHarnessModelConfigs(primary, models), + } { + if err := deepSeekHarnessSetYAMLValue(provider, key, value); err != nil { + return err + } + } + + if !manageWebSearch { + return nil + } + + // Harness's bundled search provider appends /messages to this /v1 base and + // sends the Anthropic web_search server tool. This is separate from the main + // model provider above; Harness does not expose a configured way to send the + // native OpenAI Responses web_search tool. + web := deepSeekHarnessEnsureYAMLMapping(settings, deepSeekHarnessWebSettings) + for key, value := range map[string]string{ + "apiKeyEnv": deepSeekHarnessAPIKeyEnv, + "baseURL": deepSeekHarnessBaseURL(), + "model": primary, + } { + if err := deepSeekHarnessSetYAMLValue(web, key, value); err != nil { + return err + } + } + return nil +} + +func deepSeekHarnessEnsureYAMLMapping(mapping *yaml.Node, key string) *yaml.Node { + if value := deepSeekHarnessYAMLValue(mapping, key); value != nil && value.Kind == yaml.MappingNode { + return value + } + value := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + deepSeekHarnessSetYAMLNode(mapping, key, value) + return value +} + +func deepSeekHarnessSetYAMLValue(mapping *yaml.Node, key string, value any) error { + node := &yaml.Node{} + if err := node.Encode(value); err != nil { + return err + } + deepSeekHarnessSetYAMLNode(mapping, key, node) + return nil +} + +func deepSeekHarnessSetYAMLNode(mapping *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + mapping.Content[i+1] = value + return + } + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + value, + ) +} + +func deepSeekHarnessYAMLValue(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +func deepSeekHarnessModelConfigs(primary string, models []LaunchModel) []any { + ordered := append([]LaunchModel(nil), models...) + if selected, ok := findLaunchModel(ordered, primary); ok { + ordered = append([]LaunchModel{selected}, removeLaunchModel(ordered, primary)...) + } else { + ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...) + } + + configs := make([]any, 0, len(ordered)) + seen := make(map[string]bool, len(ordered)) + for _, item := range ordered { + if item.Name == "" || seen[item.Name] { + continue + } + seen[item.Name] = true + entry := map[string]any{ + "id": item.Name, + "name": item.Name, + "input": []string{"text"}, + } + if slices.Contains(item.Capabilities, model.CapabilityVision) { + entry["input"] = []string{"text", "image"} + } + if item.ContextLength > 0 { + entry["contextWindow"] = item.ContextLength + } + if item.MaxOutputTokens > 0 { + entry["maxTokens"] = item.MaxOutputTokens + } + configs = append(configs, entry) + } + return configs +} + +func (d *DeepSeekHarness) CurrentModel() string { + settingsPath, err := deepSeekHarnessSettingsPath() + if err != nil { + return "" + } + if !deepSeekHarnessPatchHealthy(settingsPath) { + return "" + } + settings, err := readDeepSeekHarnessYAML(settingsPath) + if err != nil { + return "" + } + + selected, _ := settings["agent-default-model"].(map[string]any) + if selected == nil || selected["provider"] != deepSeekHarnessProvider { + return "" + } + modelName, _ := selected["model"].(string) + if modelName == "" { + return "" + } + + llm, _ := settings["llm-pi-ai"].(map[string]any) + providers, _ := llm["providers"].(map[string]any) + provider, _ := providers[deepSeekHarnessProvider].(map[string]any) + if !deepSeekHarnessProviderHealthy(provider, modelName) { + return "" + } + if !shouldManageOllamaWebSearch() { + return modelName + } + web, _ := settings[deepSeekHarnessWebSettings].(map[string]any) + if !deepSeekHarnessWebProviderHealthy(web, modelName) { + return "" + } + return modelName +} + +func deepSeekHarnessPatchHealthy(settingsPath string) bool { + patchPath, err := deepSeekHarnessPatchPath() + if err != nil { + return false + } + data, err := os.ReadFile(patchPath) + if err != nil { + return false + } + var patches []struct { + ID string `yaml:"id"` + Config struct { + Path string `yaml:"path"` + } `yaml:"config"` + } + if err := yaml.Unmarshal(data, &patches); err != nil || len(patches) != 1 { + return false + } + return patches[0].ID == "settings" && patches[0].Config.Path == settingsPath +} + +func deepSeekHarnessProviderHealthy(provider map[string]any, modelName string) bool { + if provider == nil || provider["api"] != "openai-completions" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv { + return false + } + baseURL, _ := provider["baseURL"].(string) + if strings.TrimRight(baseURL, "/") != strings.TrimRight(deepSeekHarnessBaseURL(), "/") { + return false + } + models, _ := provider["models"].([]any) + for _, raw := range models { + entry, _ := raw.(map[string]any) + if entry["id"] == modelName { + return true + } + } + return false +} + +func deepSeekHarnessWebProviderHealthy(web map[string]any, modelName string) bool { + if web == nil || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != modelName { + return false + } + baseURL, _ := web["baseURL"].(string) + return strings.TrimRight(baseURL, "/") == strings.TrimRight(deepSeekHarnessBaseURL(), "/") +} + +func (d *DeepSeekHarness) Onboard() error { + return config.MarkIntegrationOnboarded(deepSeekHarnessIntegrationName) +} + +func (d *DeepSeekHarness) RequiresInteractiveOnboarding() bool { return false } + +func deepSeekHarnessBaseURL() string { + return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1" +} + +func deepSeekHarnessConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".ollama", "launch", "dsh"), nil +} + +func deepSeekHarnessSettingsPath() (string, error) { + dir, err := deepSeekHarnessConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "settings.yaml"), nil +} + +func deepSeekHarnessPatchPath() (string, error) { + dir, err := deepSeekHarnessConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "ollama.cordis.yml"), nil +} + +func writeDeepSeekHarnessFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + if err := os.Chmod(dir, 0o700); err != nil { + return err + } + if err := fileutil.WriteWithBackup(path, data, deepSeekHarnessIntegrationName); err != nil { + return err + } + return os.Chmod(path, 0o600) +} diff --git a/cmd/launch/deepseek_harness_test.go b/cmd/launch/deepseek_harness_test.go new file mode 100644 index 000000000..219fbeda4 --- /dev/null +++ b/cmd/launch/deepseek_harness_test.go @@ -0,0 +1,461 @@ +package launch + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + "github.com/ollama/ollama/types/model" + "gopkg.in/yaml.v3" +) + +func TestDeepSeekHarnessRegistry(t *testing.T) { + spec, err := LookupIntegrationSpec("deepseek-harness") + if err != nil { + t.Fatal(err) + } + if spec.Name != deepSeekHarnessIntegrationName { + t.Fatalf("canonical name = %q, want %q", spec.Name, deepSeekHarnessIntegrationName) + } + if spec.Runner.String() != "DeepSeek Harness" { + t.Fatalf("display name = %q", spec.Runner.String()) + } + if got := strings.Join(spec.Install.Command, " "); got != "npm install -g @deepseek-ai/dsh@latest" { + t.Fatalf("install command = %q", got) + } +} + +func TestDeepSeekHarnessConfigurePreservesSettingsAndIsIdempotent(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:12345") + + settingsPath, err := deepSeekHarnessSettingsPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + t.Fatal(err) + } + existing := []byte("# keep-comment\ndefaults: &defaults\n mode: dark\ntheme: *defaults\nagent-default-model:\n # keep-reasoning-comment\n reasoningEffort: high\nllm-pi-ai:\n providers:\n custom:\n api: openai-completions\n baseURL: https://example.invalid/v1\n models:\n - id: custom-model\n ollama:\n # keep-retry-comment\n retryPolicy:\n maxAttempts: 2\nweb-search-deepseek:\n maxUses: 3\n") + if err := os.WriteFile(settingsPath, existing, 0o600); err != nil { + t.Fatal(err) + } + + models := []LaunchModel{ + {Name: "qwen3.5:latest", ContextLength: 262144, MaxOutputTokens: 32768, Capabilities: []model.Capability{model.CapabilityVision}}, + {Name: "kimi-k2.6:cloud", ContextLength: 262144, MaxOutputTokens: 262144}, + } + dsh := &DeepSeekHarness{} + if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil { + t.Fatal(err) + } + firstSettings, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + patchPath, err := deepSeekHarnessPatchPath() + if err != nil { + t.Fatal(err) + } + firstPatch, err := os.ReadFile(patchPath) + if err != nil { + t.Fatal(err) + } + + if err := dsh.ConfigureWithModels("qwen3.5", models); err != nil { + t.Fatal(err) + } + secondSettings, _ := os.ReadFile(settingsPath) + secondPatch, _ := os.ReadFile(patchPath) + if string(firstSettings) != string(secondSettings) || string(firstPatch) != string(secondPatch) { + t.Fatal("repeated configuration changed Ollama-managed files") + } + for _, preserved := range []string{"# keep-comment", "&defaults", "*defaults", "# keep-reasoning-comment", "# keep-retry-comment"} { + if !strings.Contains(string(firstSettings), preserved) { + t.Fatalf("settings did not preserve %q:\n%s", preserved, firstSettings) + } + } + + var settings map[string]any + if err := yaml.Unmarshal(firstSettings, &settings); err != nil { + t.Fatal(err) + } + if theme, _ := settings["theme"].(map[string]any); theme["mode"] != "dark" { + t.Fatalf("unrelated settings were not preserved: %#v", settings["theme"]) + } + selected, _ := settings["agent-default-model"].(map[string]any) + if selected["provider"] != deepSeekHarnessProvider || selected["model"] != "qwen3.5:latest" { + t.Fatalf("default model = %#v", selected) + } + if selected["reasoningEffort"] != "high" { + t.Fatalf("default model settings were not preserved: %#v", selected) + } + llm, _ := settings["llm-pi-ai"].(map[string]any) + providers, _ := llm["providers"].(map[string]any) + if providers["custom"] == nil { + t.Fatal("custom provider was removed") + } + provider, _ := providers[deepSeekHarnessProvider].(map[string]any) + if provider["baseURL"] != "http://127.0.0.1:12345/v1" || provider["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv { + t.Fatalf("Ollama provider = %#v", provider) + } + retryPolicy, _ := provider["retryPolicy"].(map[string]any) + if retryPolicy["maxAttempts"] != 2 { + t.Fatalf("Ollama provider settings were not preserved: %#v", provider) + } + configuredModels, _ := provider["models"].([]any) + if len(configuredModels) != 2 { + t.Fatalf("configured models = %#v", configuredModels) + } + local, _ := configuredModels[0].(map[string]any) + if local["id"] != "qwen3.5:latest" || local["contextWindow"] != 262144 || local["maxTokens"] != 32768 { + t.Fatalf("local model = %#v", local) + } + if got, _ := local["input"].([]any); !slices.Equal(got, []any{"text", "image"}) { + t.Fatalf("local model input = %#v", local["input"]) + } + cloud, _ := configuredModels[1].(map[string]any) + if cloud["id"] != "kimi-k2.6:cloud" || cloud["contextWindow"] != 262144 || cloud["maxTokens"] != 262144 { + t.Fatalf("cloud model = %#v", cloud) + } + web, _ := settings[deepSeekHarnessWebSettings].(map[string]any) + if web["baseURL"] != "http://127.0.0.1:12345/v1" || web["apiKeyEnv"] != deepSeekHarnessAPIKeyEnv || web["model"] != "qwen3.5:latest" { + t.Fatalf("Ollama web search provider = %#v", web) + } + if web["maxUses"] != 3 { + t.Fatalf("existing web search settings were not preserved: %#v", web) + } + + var patches []map[string]any + if err := yaml.Unmarshal(firstPatch, &patches); err != nil { + t.Fatal(err) + } + if len(patches) != 1 || patches[0]["id"] != "settings" { + t.Fatalf("patches = %#v", patches) + } + patchConfig, _ := patches[0]["config"].(map[string]any) + if patchConfig["path"] != settingsPath { + t.Fatalf("settings patch path = %#v", patchConfig["path"]) + } + if got := dsh.CurrentModel(); got != "qwen3.5:latest" { + t.Fatalf("CurrentModel() = %q", got) + } +} + +func TestDeepSeekHarnessConfigureSkipsWebSearchWhenCloudDisabled(t *testing.T) { + home := t.TempDir() + setTestHome(t, home) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/status" { + fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`) + return + } + http.NotFound(w, r) + })) + t.Cleanup(srv.Close) + t.Setenv("OLLAMA_HOST", srv.URL) + + settingsPath, err := deepSeekHarnessSettingsPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + t.Fatal(err) + } + existing := []byte("web-search-deepseek:\n maxUses: 3\n") + if err := os.WriteFile(settingsPath, existing, 0o600); err != nil { + t.Fatal(err) + } + + dsh := &DeepSeekHarness{} + if err := dsh.ConfigureWithModels("qwen3.5", []LaunchModel{{Name: "qwen3.5:latest"}}); err != nil { + t.Fatal(err) + } + + settings, err := readDeepSeekHarnessYAML(settingsPath) + if err != nil { + t.Fatal(err) + } + web, _ := settings[deepSeekHarnessWebSettings].(map[string]any) + if len(web) != 1 || web["maxUses"] != 3 { + t.Fatalf("web search settings = %#v", web) + } + if got := dsh.CurrentModel(); got != "qwen3.5:latest" { + t.Fatalf("CurrentModel() = %q", got) + } +} + +func TestDeepSeekHarnessCurrentModelRejectsDrift(t *testing.T) { + setTestHome(t, t.TempDir()) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + dsh := &DeepSeekHarness{} + if err := dsh.Configure("qwen3.5"); err != nil { + t.Fatal(err) + } + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999") + if got := dsh.CurrentModel(); got != "" { + t.Fatalf("CurrentModel() = %q for stale endpoint", got) + } +} + +func TestDeepSeekHarnessCurrentModelRejectsPatchDrift(t *testing.T) { + setTestHome(t, t.TempDir()) + t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434") + dsh := &DeepSeekHarness{} + if err := dsh.Configure("qwen3.5"); err != nil { + t.Fatal(err) + } + patchPath, err := deepSeekHarnessPatchPath() + if err != nil { + t.Fatal(err) + } + + for name, data := range map[string][]byte{ + "missing": nil, + "malformed": []byte("["), + "wrong path": []byte(`- id: settings + config: + path: /tmp/not-managed.yaml +`), + } { + t.Run(name, func(t *testing.T) { + if err := dsh.Configure("qwen3.5"); err != nil { + t.Fatal(err) + } + if data == nil { + if err := os.Remove(patchPath); err != nil { + t.Fatal(err) + } + } else if err := os.WriteFile(patchPath, data, 0o600); err != nil { + t.Fatal(err) + } + if got := dsh.CurrentModel(); got != "" { + t.Fatalf("CurrentModel() = %q", got) + } + }) + } +} + +func TestDeepSeekHarnessConfigureRejectsMalformedSettingsWithoutOverwrite(t *testing.T) { + setTestHome(t, t.TempDir()) + settingsPath, err := deepSeekHarnessSettingsPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + t.Fatal(err) + } + malformed := []byte("llm-pi-ai: [") + if err := os.WriteFile(settingsPath, malformed, 0o600); err != nil { + t.Fatal(err) + } + + err = (&DeepSeekHarness{}).Configure("qwen3.5") + if err == nil || !strings.Contains(err.Error(), "parse deepseek harness launch settings") { + t.Fatalf("Configure() error = %v", err) + } + got, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(got, malformed) { + t.Fatalf("malformed settings were overwritten: %q", got) + } +} + +func TestDeepSeekHarnessConfigureAcceptsNullSettings(t *testing.T) { + setTestHome(t, t.TempDir()) + settingsPath, err := deepSeekHarnessSettingsPath() + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(settingsPath), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(settingsPath, []byte("null\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := (&DeepSeekHarness{}).Configure("qwen3.5"); err != nil { + t.Fatal(err) + } +} + +func TestDeepSeekHarnessRunUsesManagedPatchAndCredential(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + home := t.TempDir() + setTestHome(t, home) + binDir := t.TempDir() + logPath := filepath.Join(home, "dsh-invocation") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_TEST_LOG\"\nprintf '%s\\n' \"$OLLAMA_LAUNCH_DSH_API_KEY\" >> \"$DSH_TEST_LOG\"\n" + bin := filepath.Join(binDir, "dsh") + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator))) + t.Setenv("DSH_TEST_LOG", logPath) + t.Setenv(deepSeekHarnessAPIKeyEnv, "do-not-keep") + + dsh := &DeepSeekHarness{} + if err := dsh.Configure("qwen3.5"); err != nil { + t.Fatal(err) + } + if err := dsh.Run("qwen3.5", nil, []string{"--port", "0"}); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + patchPath, _ := deepSeekHarnessPatchPath() + want := "web\n--patch\n" + patchPath + "\n--port\n0\nollama\n" + if string(data) != want { + t.Fatalf("invocation = %q, want %q", data, want) + } +} + +func TestDeepSeekHarnessRejectsManagedPatchArgument(t *testing.T) { + for _, args := range [][]string{{"--patch", "other.yml"}, {"--patch=other.yml"}} { + if err := (&DeepSeekHarness{}).Run("qwen3.5", nil, args); err == nil || !strings.Contains(err.Error(), "manages --patch") { + t.Fatalf("Run(%v) error = %v", args, err) + } + } +} + +func TestEnsureDeepSeekHarnessInstalledUsesPublicNpmPackage(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX shell test binary") + } + + home := t.TempDir() + binDir := t.TempDir() + logPath := filepath.Join(home, "npm-invocation") + npm := filepath.Join(binDir, "npm") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DSH_NPM_LOG\"\nprintf '#!/bin/sh\\nexit 0\\n' > \"$DSH_INSTALLED_BIN\"\nchmod +x \"$DSH_INSTALLED_BIN\"\n" + if err := os.WriteFile(npm, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + dshBin := filepath.Join(binDir, "dsh") + t.Setenv("PATH", strings.Join([]string{binDir, "/bin", "/usr/bin"}, string(os.PathListSeparator))) + t.Setenv("DSH_NPM_LOG", logPath) + t.Setenv("DSH_INSTALLED_BIN", dshBin) + + restore := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true}) + defer restore() + path, err := ensureDeepSeekHarnessInstalled() + if err != nil { + t.Fatal(err) + } + if path != dshBin { + t.Fatalf("installed path = %q, want %q", path, dshBin) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if string(data) != "install\n-g\n@deepseek-ai/dsh@latest\n" { + t.Fatalf("npm invocation = %q", data) + } +} + +func TestDeepSeekHarnessLaunchArgs(t *testing.T) { + got := deepSeekHarnessLaunchArgs("/tmp/ollama.cordis.yml", []string{"--port", "0"}) + want := []string{"web", "--patch", "/tmp/ollama.cordis.yml", "--port", "0"} + if !slices.Equal(got, want) { + t.Fatalf("launch args = %v, want %v", got, want) + } +} + +func TestDeepSeekHarnessWindowsNodeShims(t *testing.T) { + root := t.TempDir() + node := filepath.Join(root, "node.exe") + dsh := filepath.Join(root, "npm", "dsh.cmd") + npm := filepath.Join(root, "node", "npm.cmd") + dshEntrypoint := filepath.Join(filepath.Dir(dsh), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js") + npmEntrypoint := filepath.Join(filepath.Dir(npm), "node_modules", "npm", "bin", "npm-cli.js") + for _, path := range []string{node, dsh, npm, dshEntrypoint, npmEntrypoint} { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o755); err != nil { + t.Fatal(err) + } + } + + originalGOOS := deepSeekHarnessGOOS + originalLookPath := deepSeekHarnessLookPath + deepSeekHarnessGOOS = "windows" + deepSeekHarnessLookPath = func(file string) (string, error) { + if file == "node" { + return node, nil + } + return "", exec.ErrNotFound + } + t.Cleanup(func() { + deepSeekHarnessGOOS = originalGOOS + deepSeekHarnessLookPath = originalLookPath + }) + + t.Run("dsh", func(t *testing.T) { + cmd, err := deepSeekHarnessExecutableCommand(dsh, []string{"web", "--port", "0", "a&b"}) + if err != nil { + t.Fatal(err) + } + want := []string{node, dshEntrypoint, "web", "--port", "0", "a&b"} + if !slices.Equal(cmd.Args, want) { + t.Fatalf("command args = %v, want %v", cmd.Args, want) + } + }) + + t.Run("npm", func(t *testing.T) { + cmd, err := deepSeekHarnessNpmCommand(npm, []string{"install", "-g", deepSeekHarnessNpmPackage}) + if err != nil { + t.Fatal(err) + } + want := []string{node, npmEntrypoint, "install", "-g", deepSeekHarnessNpmPackage} + if !slices.Equal(cmd.Args, want) { + t.Fatalf("command args = %v, want %v", cmd.Args, want) + } + }) +} + +func TestDeepSeekHarnessWindowsNodeShimRequiresEntrypoint(t *testing.T) { + originalGOOS := deepSeekHarnessGOOS + originalLookPath := deepSeekHarnessLookPath + deepSeekHarnessGOOS = "windows" + deepSeekHarnessLookPath = func(file string) (string, error) { + if file == "node" { + return filepath.Join(t.TempDir(), "node.exe"), nil + } + return "", exec.ErrNotFound + } + t.Cleanup(func() { + deepSeekHarnessGOOS = originalGOOS + deepSeekHarnessLookPath = originalLookPath + }) + + _, err := deepSeekHarnessExecutableCommand(filepath.Join(t.TempDir(), "dsh.cmd"), nil) + if err == nil || !strings.Contains(err.Error(), "resolve Windows entrypoint") { + t.Fatalf("error = %v", err) + } +} + +func TestDeepSeekHarnessInstallDependencyError(t *testing.T) { + originalLookPath := deepSeekHarnessLookPath + deepSeekHarnessLookPath = func(file string) (string, error) { return "", exec.ErrNotFound } + t.Cleanup(func() { deepSeekHarnessLookPath = originalLookPath }) + _, err := ensureDeepSeekHarnessInstalled() + if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required") { + t.Fatalf("error = %v", err) + } +} diff --git a/cmd/launch/integrations_test.go b/cmd/launch/integrations_test.go index 38c77fc4f..cccc84736 100644 --- a/cmd/launch/integrations_test.go +++ b/cmd/launch/integrations_test.go @@ -67,6 +67,8 @@ func TestIntegrationLookup(t *testing.T) { {"muse", "muse", true, "Muse Code"}, {"muse alias", "muse-code", true, "Muse Code"}, {"droid", "droid", true, "Droid"}, + {"dsh", "dsh", true, "DeepSeek Harness"}, + {"deepseek harness alias", "deepseek-harness", true, "DeepSeek Harness"}, {"opencode", "opencode", true, "OpenCode"}, {"omp", "omp", true, "OMP"}, {"pool", "pool", true, "Pool"}, @@ -88,7 +90,7 @@ func TestIntegrationLookup(t *testing.T) { } func TestIntegrationRegistry(t *testing.T) { - expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"} + expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "chatgpt", "kimi", "muse", "droid", "dsh", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"} for _, name := range expectedIntegrations { t.Run(name, func(t *testing.T) { r, ok := integrations[name] diff --git a/cmd/launch/launch.go b/cmd/launch/launch.go index 671efa78d..86c69c984 100644 --- a/cmd/launch/launch.go +++ b/cmd/launch/launch.go @@ -296,6 +296,7 @@ Supported integrations: copilot Copilot CLI (aliases: copilot-cli) omp OMP droid Droid + dsh DeepSeek Harness (alias: deepseek-harness) kimi Kimi Code CLI muse Muse Code (aliases: muse-code) pi Pi @@ -312,6 +313,7 @@ Examples: ollama launch chatgpt --restore ollama launch hermes ollama launch hermes-desktop + ollama launch dsh ollama launch droid --config (does not auto-launch) ollama launch codex --restore ollama launch codex -- --sandbox workspace-write`, diff --git a/cmd/launch/launch_test.go b/cmd/launch/launch_test.go index b1137b9de..2c4fd6fb5 100644 --- a/cmd/launch/launch_test.go +++ b/cmd/launch/launch_test.go @@ -151,6 +151,21 @@ func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models [ return r.Configure(primary) } +type launcherCanonicalManagedListRunner struct { + launcherManagedListRunner +} + +func (r *launcherCanonicalManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error { + r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models)) + r.configured = append(r.configured, primary) + if selected, ok := findLaunchModel(models, primary); ok { + r.currentModel = selected.Name + } else { + r.currentModel = primary + } + return nil +} + type launcherManagedAutodiscoveryRunner struct { launcherManagedRunner autodiscoveryConfigures int @@ -1089,6 +1104,51 @@ func TestLaunchIntegration_ManagedSingleIntegrationCanConfigureWithModelList(t * } } +func TestLaunchIntegration_ManagedSingleIntegrationSavesCanonicalModel(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":"qwen3.5:latest"}]}`) + 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) + + runner := &launcherCanonicalManagedListRunner{} + withIntegrationOverride(t, "stubmanaged", runner) + + request := IntegrationLaunchRequest{Name: "stubmanaged", ModelOverride: "qwen3.5"} + if err := LaunchIntegration(context.Background(), request); err != nil { + t.Fatalf("first LaunchIntegration returned error: %v", err) + } + + saved, err := config.LoadIntegration("stubmanaged") + if err != nil { + t.Fatalf("failed to reload managed integration config: %v", err) + } + if diff := compareStrings(saved.Models, []string{"qwen3.5:latest"}); diff != "" { + t.Fatalf("saved models mismatch: %s", diff) + } + + if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil { + t.Fatalf("second LaunchIntegration returned error: %v", err) + } + if diff := compareStrings(runner.configured, []string{"qwen3.5"}); diff != "" { + t.Fatalf("expected second launch to skip configuration: %s", diff) + } +} + func TestLaunchIntegration_ManagedAutodiscoverySkipsModelPicker(t *testing.T) { tmpDir := t.TempDir() setLaunchTestHome(t, tmpDir) diff --git a/cmd/launch/models.go b/cmd/launch/models.go index 37afb6c62..abec3a226 100644 --- a/cmd/launch/models.go +++ b/cmd/launch/models.go @@ -323,6 +323,9 @@ func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, mo if err != nil { return fmt.Errorf("setup failed: %w", err) } + if current := managed.CurrentModel(); current != "" { + model = current + } if err := config.SaveIntegration(name, []string{model}); err != nil { return fmt.Errorf("failed to save: %w", err) } diff --git a/cmd/launch/registry.go b/cmd/launch/registry.go index 0cd186d2e..3e5aa845e 100644 --- a/cmd/launch/registry.go +++ b/cmd/launch/registry.go @@ -33,7 +33,7 @@ type IntegrationInfo struct { Description string } -var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"} +var launcherIntegrationOrder = []string{"claude", "chatgpt", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "dsh", "pi", "pool", "qwen"} var integrationSpecs = []*IntegrationSpec{ { @@ -166,6 +166,24 @@ var integrationSpecs = []*IntegrationSpec{ URL: "https://docs.factory.ai/cli/getting-started/quickstart", }, }, + { + Name: deepSeekHarnessIntegrationName, + Runner: &DeepSeekHarness{}, + Aliases: []string{"deepseek-harness"}, + Description: "DeepSeek's open-source agent harness", + Install: IntegrationInstallSpec{ + CheckInstalled: func() bool { + _, err := deepSeekHarnessLookPath("dsh") + return err == nil + }, + EnsureInstalled: func() error { + _, err := ensureDeepSeekHarnessInstalled() + return err + }, + URL: "https://github.com/deepseek-ai/deepseek-harness", + Command: []string{"npm", "install", "-g", deepSeekHarnessNpmPackage}, + }, + }, { Name: "opencode", Runner: &OpenCode{}, diff --git a/docs/images/launch-icons/deepseek-harness.svg b/docs/images/launch-icons/deepseek-harness.svg new file mode 100644 index 000000000..4f770e51f --- /dev/null +++ b/docs/images/launch-icons/deepseek-harness.svg @@ -0,0 +1,4 @@ + + + + diff --git a/docs/integrations/deepseek-harness.mdx b/docs/integrations/deepseek-harness.mdx new file mode 100644 index 000000000..b22719ce5 --- /dev/null +++ b/docs/integrations/deepseek-harness.mdx @@ -0,0 +1,50 @@ +--- +title: DeepSeek Harness +--- + +[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) is an open-source coding agent. + +DeepSeek Harness is currently a developer preview. Its upstream configuration may change between releases. + +## Setup + +```shell +ollama launch dsh +``` + +Ollama installs `@deepseek-ai/dsh` if needed. To choose a model: + +```shell +ollama launch dsh --model qwen3.5 +ollama launch dsh --model qwen3.5:cloud +``` + +To configure without starting: + +```shell +ollama launch dsh --config +``` + +## Web search + +Web search is enabled automatically. It requires Ollama cloud access and a model that supports tools. Run `ollama signin` if needed. + +## Configuration + +Ollama stores its settings in `~/.ollama/launch/dsh/settings.yaml`. These settings load last and set the model, provider, and web search connection. Repeated launches preserve other settings in this file. Ollama does not change `~/.dsh/settings.yaml`, profiles, sessions, or credentials. + +Launch rejects additional `--patch` arguments. Pass other Harness arguments after `--`: + +```shell +ollama launch dsh -- --port 3081 +``` + +## Manual install + +DeepSeek Harness requires Node.js. To install it manually: + +```shell +npm install -g @deepseek-ai/dsh@latest +``` + +Then run `ollama launch dsh`. On Windows, install Node.js for Windows. diff --git a/docs/integrations/index.mdx b/docs/integrations/index.mdx index 1e835e542..84c697068 100644 --- a/docs/integrations/index.mdx +++ b/docs/integrations/index.mdx @@ -17,6 +17,10 @@ Run `ollama launch` to see the latest integrations you can run from the terminal Open-source coding agent that edits, runs, and iterates on code. + + + DeepSeek's open-source agent harness with subagents and web search. + ## Connect an assistant