mirror of
https://github.com/ollama/ollama.git
synced 2026-10-22 19:41:46 +00:00
launch: add DeepSeek Harness integration (#17733)
This commit is contained in:
parent
7ce88bd686
commit
39df91c982
13 changed files with 1139 additions and 3 deletions
520
cmd/launch/deepseek_harness.go
Normal file
520
cmd/launch/deepseek_harness.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
461
cmd/launch/deepseek_harness_test.go
Normal file
461
cmd/launch/deepseek_harness_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue