mirror of
https://github.com/ollama/ollama.git
synced 2026-07-23 09:10:53 -05:00
launch: enriched model inventory (#16230)
This commit is contained in:
@@ -44,7 +44,7 @@ func (c *Claude) findPath() (string, error) {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (c *Claude) Run(model string, args []string) error {
|
||||
func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
|
||||
claudePath, err := c.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")
|
||||
|
||||
@@ -130,7 +130,7 @@ func (c *ClaudeDesktop) SkipModelReadiness() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) Run(_ string, _ []string) error {
|
||||
func (c *ClaudeDesktop) Run(_ string, _ []LaunchModel, _ []string) error {
|
||||
return errClaudeDesktopUnsupported()
|
||||
}
|
||||
|
||||
|
||||
@@ -932,7 +932,7 @@ func TestClaudeDesktopRunReturnsUnsupported(t *testing.T) {
|
||||
)
|
||||
|
||||
for _, args := range [][]string{nil, {"--foo"}} {
|
||||
err := (&ClaudeDesktop{}).Run("qwen3.5", args)
|
||||
err := (&ClaudeDesktop{}).Run("qwen3.5", nil, args)
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type Cline struct{}
|
||||
|
||||
func (c *Cline) String() string { return "Cline" }
|
||||
|
||||
func (c *Cline) Run(model string, args []string) error {
|
||||
func (c *Cline) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if _, err := exec.LookPath("cline"); err != nil {
|
||||
return fmt.Errorf("cline is not installed, install with: npm install -g cline")
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (c *Cline) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cline) Edit(models []string) error {
|
||||
func (c *Cline) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -66,10 +66,10 @@ func (c *Cline) Edit(models []string) error {
|
||||
baseURL := envconfig.Host().String()
|
||||
config["ollamaBaseUrl"] = baseURL
|
||||
config["actModeApiProvider"] = "ollama"
|
||||
config["actModeOllamaModelId"] = models[0]
|
||||
config["actModeOllamaModelId"] = models[0].Name
|
||||
config["actModeOllamaBaseUrl"] = baseURL
|
||||
config["planModeApiProvider"] = "ollama"
|
||||
config["planModeOllamaModelId"] = models[0]
|
||||
config["planModeOllamaModelId"] = models[0].Name
|
||||
config["planModeOllamaBaseUrl"] = baseURL
|
||||
|
||||
config["welcomeViewCompleted"] = true
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestClineEdit(t *testing.T) {
|
||||
t.Run("creates config from scratch", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestClineEdit(t *testing.T) {
|
||||
data, _ := json.Marshal(existing)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
|
||||
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -93,10 +93,10 @@ func TestClineEdit(t *testing.T) {
|
||||
t.Run("updates model on re-edit", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestClineEdit(t *testing.T) {
|
||||
t.Run("uses first model as primary", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
if err := c.Edit([]string{"kimi-k2.5:cloud", "glm-5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud", "glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
@@ -48,12 +43,12 @@ func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func (c *Codex) Run(model string, args []string) error {
|
||||
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
|
||||
if err := checkCodexVersion(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureCodexConfig(model); err != nil {
|
||||
if err := ensureCodexConfig(model, models); err != nil {
|
||||
return fmt.Errorf("failed to configure codex: %w", err)
|
||||
}
|
||||
|
||||
@@ -74,7 +69,7 @@ func (c *Codex) Run(model string, args []string) error {
|
||||
|
||||
// ensureCodexConfig writes a Codex profile and model catalog so Codex uses the
|
||||
// local Ollama server and has model metadata available.
|
||||
func ensureCodexConfig(modelName string) error {
|
||||
func ensureCodexConfig(modelName string, models []LaunchModel) error {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -86,7 +81,7 @@ func ensureCodexConfig(modelName string) error {
|
||||
}
|
||||
|
||||
catalogPath := codexModelCatalogPathForConfig(configPath)
|
||||
if err := writeCodexModelCatalog(catalogPath, modelName); err != nil {
|
||||
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -577,8 +572,15 @@ func codexRootLineHasKey(line, key string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
func writeCodexModelCatalog(catalogPath, modelName string) error {
|
||||
entry := buildCodexModelEntry(modelName)
|
||||
func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel {
|
||||
if model, ok := findLaunchModel(models, modelName); ok {
|
||||
return model.WithCloudLimits()
|
||||
}
|
||||
return fallbackLaunchModel(modelName)
|
||||
}
|
||||
|
||||
func writeCodexModelCatalog(catalogPath string, model LaunchModel) error {
|
||||
entry := buildCodexModelEntry(model)
|
||||
|
||||
catalog := map[string]any{
|
||||
"models": []any{entry},
|
||||
@@ -592,40 +594,28 @@ func writeCodexModelCatalog(catalogPath, modelName string) error {
|
||||
return os.WriteFile(catalogPath, data, 0o644)
|
||||
}
|
||||
|
||||
func buildCodexModelEntry(modelName string) map[string]any {
|
||||
func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
|
||||
modelName := launchModel.Name
|
||||
contextWindow := codexFallbackContextWindow
|
||||
hasVision := false
|
||||
systemPrompt := ""
|
||||
|
||||
if launchModel.ContextLength > 0 {
|
||||
contextWindow = launchModel.ContextLength
|
||||
} else if launchModel.Details.ContextLength > 0 {
|
||||
contextWindow = launchModel.Details.ContextLength
|
||||
}
|
||||
if l, ok := lookupCloudModelLimit(modelName); ok {
|
||||
contextWindow = l.Context
|
||||
}
|
||||
|
||||
client := api.NewClient(envconfig.Host(), http.DefaultClient)
|
||||
resp, err := client.Show(context.Background(), &api.ShowRequest{Model: modelName})
|
||||
if err == nil {
|
||||
systemPrompt = resp.System
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||
hasVision = true
|
||||
}
|
||||
|
||||
if !isCloudModelName(modelName) {
|
||||
if n, ok := modelInfoContextLength(resp.ModelInfo); ok {
|
||||
contextWindow = n
|
||||
}
|
||||
if resp.Details.Format != "safetensors" {
|
||||
if ctxLen := envconfig.ContextLength(); ctxLen > 0 {
|
||||
contextWindow = int(ctxLen)
|
||||
}
|
||||
if numCtx := parseNumCtx(resp.Parameters); numCtx > 0 {
|
||||
contextWindow = numCtx
|
||||
}
|
||||
}
|
||||
if !isCloudModelName(modelName) && launchModel.Details.Format != "safetensors" {
|
||||
if ctxLen := envconfig.ContextLength(); ctxLen > 0 {
|
||||
contextWindow = int(ctxLen)
|
||||
}
|
||||
}
|
||||
|
||||
modalities := []string{"text"}
|
||||
if hasVision {
|
||||
if launchModel.HasCapability(model.CapabilityVision) {
|
||||
modalities = append(modalities, "image")
|
||||
}
|
||||
|
||||
@@ -654,19 +644,6 @@ func buildCodexModelEntry(modelName string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func parseNumCtx(parameters string) int {
|
||||
for _, line := range strings.Split(parameters, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 2 && fields[0] == "num_ctx" {
|
||||
if v, err := strconv.ParseFloat(fields[1], 64); err == nil {
|
||||
return int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func checkCodexVersion() error {
|
||||
if _, err := exec.LookPath("codex"); err != nil {
|
||||
return fmt.Errorf("codex is not installed, install with: npm install -g @openai/codex")
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -68,10 +62,10 @@ func (c *CodexApp) Paths() []string {
|
||||
}
|
||||
|
||||
func (c *CodexApp) Configure(model string) error {
|
||||
return c.ConfigureWithModels(model, []string{model})
|
||||
return c.ConfigureWithModels(model, launchModelsFromNames([]string{model}))
|
||||
}
|
||||
|
||||
func (c *CodexApp) ConfigureWithModels(primary string, models []string) error {
|
||||
func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
primary = strings.TrimSpace(primary)
|
||||
if primary == "" {
|
||||
return fmt.Errorf("codex-app requires a model")
|
||||
@@ -88,7 +82,7 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModelNames(primary, models)); err != nil {
|
||||
if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModels(primary, models)); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
|
||||
@@ -202,7 +196,7 @@ func (c *CodexApp) RestoreSuccessMessage() string {
|
||||
return codexAppRestoreSuccess
|
||||
}
|
||||
|
||||
func (c *CodexApp) Run(_ string, args []string) error {
|
||||
func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
if err := codexAppSupported(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -308,23 +302,15 @@ func codexAppModelCatalogPathForConfig(configPath string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename)
|
||||
}
|
||||
|
||||
func writeCodexAppModelCatalog(path, primary string, models []string) error {
|
||||
func writeCodexAppModelCatalog(path, primary string, models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return fmt.Errorf("codex-app model catalog cannot be empty")
|
||||
}
|
||||
client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
baseInstructions := codexAppBaseInstructions()
|
||||
primaryMetadata := codexAppSelectedModelMetadata(ctx, client, primary)
|
||||
entries := make([]map[string]any, 0, len(models))
|
||||
for i, model := range models {
|
||||
metadata := codexAppDefaultModelMetadata()
|
||||
if model == primary {
|
||||
metadata = primaryMetadata
|
||||
}
|
||||
entries = append(entries, codexAppCatalogEntry(model, metadata, i, baseInstructions))
|
||||
entries = append(entries, codexAppCatalogEntry(model.Name, codexAppModelMetadataFromLaunchModel(model), i, baseInstructions))
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(map[string]any{"models": entries}, "", " ")
|
||||
@@ -337,32 +323,26 @@ func writeCodexAppModelCatalog(path, primary string, models []string) error {
|
||||
return fileutil.WriteWithBackup(path, append(data, '\n'), codexAppIntegrationName)
|
||||
}
|
||||
|
||||
func codexAppCatalogModelNames(primary string, fallback []string) []string {
|
||||
models := codexAppTagModelNames()
|
||||
if len(models) == 0 {
|
||||
models = fallback
|
||||
}
|
||||
return dedupeModelList(append([]string{primary}, models...))
|
||||
}
|
||||
|
||||
func codexAppTagModelNames() []string {
|
||||
client := api.NewClient(envconfig.ConnectableHost(), http.DefaultClient)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := client.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
models := make([]string, 0, len(resp.Models))
|
||||
for _, model := range resp.Models {
|
||||
name := strings.TrimSpace(model.Name)
|
||||
if name != "" {
|
||||
models = append(models, name)
|
||||
func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
|
||||
seen := make(map[string]bool, len(models)+1)
|
||||
out := make([]LaunchModel, 0, len(models)+1)
|
||||
add := func(model LaunchModel) {
|
||||
if model.Name == "" || seen[model.Name] {
|
||||
return
|
||||
}
|
||||
seen[model.Name] = true
|
||||
out = append(out, model)
|
||||
}
|
||||
return models
|
||||
|
||||
if model, ok := findLaunchModel(models, primary); ok {
|
||||
add(model)
|
||||
} else {
|
||||
add(fallbackLaunchModel(primary))
|
||||
}
|
||||
for _, model := range models {
|
||||
add(model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type codexAppModelMetadata struct {
|
||||
@@ -377,16 +357,12 @@ func codexAppDefaultModelMetadata() codexAppModelMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
func codexAppSelectedModelMetadata(ctx context.Context, client *api.Client, model string) codexAppModelMetadata {
|
||||
func codexAppModelMetadataFromLaunchModel(model LaunchModel) codexAppModelMetadata {
|
||||
metadata := codexAppDefaultModelMetadata()
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
|
||||
if err != nil {
|
||||
return metadata
|
||||
if model.ContextLength > 0 {
|
||||
metadata.contextWindow = model.ContextLength
|
||||
}
|
||||
if n, ok := modelInfoContextLength(resp.ModelInfo); ok {
|
||||
metadata.contextWindow = n
|
||||
}
|
||||
if slices.Contains(resp.Capabilities, modelpkg.CapabilityVision) {
|
||||
if model.HasCapability("vision") {
|
||||
metadata.inputModalities = []string{"text", "image"}
|
||||
}
|
||||
return metadata
|
||||
|
||||
@@ -3,8 +3,6 @@ package launch
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -12,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func withCodexAppPlatform(t *testing.T, goos string) {
|
||||
@@ -177,7 +176,7 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
c := &CodexApp{}
|
||||
if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2", "qwen3:8b"}); err != nil {
|
||||
if err := c.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2", "qwen3:8b")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -262,7 +261,7 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -294,7 +293,7 @@ func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testin
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -331,7 +330,7 @@ func TestCodexAppConfigureRejectsMalformedTomlBeforeSideEffects(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"})
|
||||
err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2"))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err)
|
||||
}
|
||||
@@ -374,7 +373,7 @@ func TestCodexAppConfigureRejectsMalformedTomlEvenWithExistingRestoreState(t *te
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"})
|
||||
err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2"))
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("ConfigureWithModels error = %v, want invalid TOML", err)
|
||||
}
|
||||
@@ -532,32 +531,16 @@ func TestCodexAppCurrentModelRequiresHealthyCatalog(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) {
|
||||
func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
showCalls := make(map[string]int)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3:8b"},{"name":"llama3.2"}]}`)
|
||||
case "/api/show":
|
||||
var req struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode show request: %v", err)
|
||||
}
|
||||
showCalls[req.Model]++
|
||||
fmt.Fprintf(w, `{"model_info":{"general.context_length":%d},"capabilities":["vision"]}`, 65536+len(req.Model))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("gemma4", []string{"fallback"}); err != nil {
|
||||
models := []LaunchModel{
|
||||
{Name: "gemma4", ContextLength: 65536 + len("gemma4"), Capabilities: []model.Capability{model.CapabilityVision}},
|
||||
{Name: "qwen3:8b"},
|
||||
{Name: "llama3.2"},
|
||||
}
|
||||
if err := (&CodexApp{}).ConfigureWithModels("gemma4", models); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -577,7 +560,7 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) {
|
||||
}
|
||||
|
||||
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "gemma4,qwen3:8b,llama3.2" {
|
||||
t.Fatalf("catalog slugs = %v, want /api/tags models", got)
|
||||
t.Fatalf("catalog slugs = %v, want enriched models", got)
|
||||
}
|
||||
for _, model := range catalog.Models {
|
||||
slug, _ := model["slug"].(string)
|
||||
@@ -596,11 +579,9 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) {
|
||||
}
|
||||
wantContext := float64(128000)
|
||||
wantModalities := []string{"text"}
|
||||
wantShowCalls := 0
|
||||
if slug == "gemma4" {
|
||||
wantContext = float64(65536 + len(slug))
|
||||
wantModalities = []string{"text", "image"}
|
||||
wantShowCalls = 1
|
||||
}
|
||||
if model["context_window"] != wantContext {
|
||||
t.Fatalf("context_window for %q = %v, want %v", slug, model["context_window"], wantContext)
|
||||
@@ -608,9 +589,6 @@ func TestCodexAppConfigurePopulatesCatalogFromTagsAndShow(t *testing.T) {
|
||||
if got := catalogInputModalities(model); strings.Join(got, ",") != strings.Join(wantModalities, ",") {
|
||||
t.Fatalf("input_modalities for %q = %v, want %v", slug, got, wantModalities)
|
||||
}
|
||||
if showCalls[slug] != wantShowCalls {
|
||||
t.Fatalf("show calls for %q = %d, want %d", slug, showCalls[slug], wantShowCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,7 +616,7 @@ func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -688,7 +666,7 @@ func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
c := &CodexApp{}
|
||||
if err := c.ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
||||
if err := c.ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
if err := c.Restore(); err != nil {
|
||||
@@ -772,7 +750,7 @@ func TestCodexAppConfigureMissingConfigReplacesStaleRestoreState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -815,7 +793,7 @@ func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testi
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", []string{"llama3.2"}); err != nil {
|
||||
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("first ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -829,7 +807,7 @@ func TestCodexAppConfigureRefreshesRestoreStateAfterManualProfileSwitch(t *testi
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", []string{"qwen3:8b"}); err != nil {
|
||||
if err := (&CodexApp{}).ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil {
|
||||
t.Fatalf("second ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
if err := (&CodexApp{}).Restore(); err != nil {
|
||||
@@ -1125,7 +1103,7 @@ func TestCodexAppRunRestartsRunningAppWhenConfirmed(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if quitCalls != 1 || openCalls != 1 {
|
||||
@@ -1163,7 +1141,7 @@ func TestCodexAppRunWaitsForGracefulExitBeforeReopening(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if quitCalls != 1 || openCalls != 1 {
|
||||
@@ -1199,7 +1177,7 @@ func TestCodexAppRunForceStopsMacAfterGracefulTimeout(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
want := []string{"quit", "force", "open"}
|
||||
@@ -1226,7 +1204,7 @@ func TestCodexAppRunReturnsMacForceStopError(t *testing.T) {
|
||||
return fmt.Errorf("operation not permitted")
|
||||
}
|
||||
|
||||
err := (&CodexApp{}).Run("qwen3.5", nil)
|
||||
err := (&CodexApp{}).Run("qwen3.5", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "operation not permitted") {
|
||||
t.Fatalf("Run error = %v, want force stop failure", err)
|
||||
}
|
||||
@@ -1245,7 +1223,7 @@ func TestCodexAppRunOpensOnWindowsWhenNotRunning(t *testing.T) {
|
||||
},
|
||||
)
|
||||
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if openCalls != 1 {
|
||||
@@ -1287,7 +1265,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if quitCalls != 1 {
|
||||
@@ -1333,7 +1311,7 @@ func TestCodexAppRunForceStopsWindowsBackgroundProcessesBeforeReopening(t *testi
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil); err != nil {
|
||||
if err := (&CodexApp{}).Run("qwen3.5", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
want := []string{"quit", "force", "open:OpenAI.Codex_2p2nqsd0c76g0!App"}
|
||||
@@ -1368,7 +1346,7 @@ func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := (&CodexApp{}).Run("qwen3.5", nil)
|
||||
err := (&CodexApp{}).Run("qwen3.5", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "force stop Codex") || !strings.Contains(err.Error(), "access denied") {
|
||||
t.Fatalf("Run error = %v, want force stop failure", err)
|
||||
}
|
||||
@@ -1376,7 +1354,7 @@ func TestCodexAppRunReturnsWindowsForceStopError(t *testing.T) {
|
||||
|
||||
func TestCodexAppRunRejectsExtraArgs(t *testing.T) {
|
||||
withCodexAppPlatform(t, "darwin")
|
||||
err := (&CodexApp{}).Run("qwen3.5", []string{"--foo"})
|
||||
err := (&CodexApp{}).Run("qwen3.5", nil, []string{"--foo"})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not accept extra arguments") {
|
||||
t.Fatalf("Run error = %v, want extra args rejection", err)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ package launch
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestCodexArgs(t *testing.T) {
|
||||
@@ -369,7 +369,7 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
if err := ensureCodexConfig("llama3.2"); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -401,10 +401,10 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
if err := ensureCodexConfig("llama3.2"); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureCodexConfig("llama3.2"); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -439,29 +439,6 @@ func assertBackupContains(t *testing.T, pattern, marker string) {
|
||||
t.Fatalf("backup matching %q with marker %q not found", pattern, marker)
|
||||
}
|
||||
|
||||
func TestParseNumCtx(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parameters string
|
||||
want int
|
||||
}{
|
||||
{"num_ctx set", "num_ctx 8192", 8192},
|
||||
{"num_ctx with other params", "temperature 0.7\nnum_ctx 4096\ntop_p 0.9", 4096},
|
||||
{"no num_ctx", "temperature 0.7\ntop_p 0.9", 0},
|
||||
{"empty string", "", 0},
|
||||
{"malformed value", "num_ctx abc", 0},
|
||||
{"float value", "num_ctx 8192.0", 8192},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseNumCtx(tt.parameters); got != tt.want {
|
||||
t.Errorf("parseNumCtx(%q) = %d, want %d", tt.parameters, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelInfoContextLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -488,124 +465,91 @@ func TestModelInfoContextLength(t *testing.T) {
|
||||
func TestBuildCodexModelEntryContextWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
modelName string
|
||||
showResponse string
|
||||
model LaunchModel
|
||||
envContextLen string
|
||||
wantContext int
|
||||
}{
|
||||
{
|
||||
name: "architectural context length as fallback",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"details": {"format": "gguf"}
|
||||
}`,
|
||||
name: "inventory context length as fallback",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
},
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "OLLAMA_CONTEXT_LENGTH overrides architectural",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"details": {"format": "gguf"}
|
||||
}`,
|
||||
name: "details context length is used when model context is empty",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
Details: api.ModelDetails{Format: "gguf", ContextLength: 131072},
|
||||
},
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "OLLAMA_CONTEXT_LENGTH overrides local gguf inventory context",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: 64000,
|
||||
},
|
||||
{
|
||||
name: "num_ctx overrides OLLAMA_CONTEXT_LENGTH",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"parameters": "num_ctx 8192",
|
||||
"details": {"format": "gguf"}
|
||||
}`,
|
||||
envContextLen: "64000",
|
||||
wantContext: 8192,
|
||||
},
|
||||
{
|
||||
name: "num_ctx overrides architectural",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"parameters": "num_ctx 32768",
|
||||
"details": {"format": "gguf"}
|
||||
}`,
|
||||
wantContext: 32768,
|
||||
},
|
||||
{
|
||||
name: "safetensors uses architectural context only",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"parameters": "num_ctx 8192",
|
||||
"details": {"format": "safetensors"}
|
||||
}`,
|
||||
name: "safetensors uses inventory context only",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "safetensors"},
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "cloud model uses hardcoded limits",
|
||||
modelName: "qwen3.5:cloud",
|
||||
showResponse: `{
|
||||
"model_info": {"qwen3_5_moe.context_length": 131072},
|
||||
"details": {"format": "gguf"}
|
||||
}`,
|
||||
name: "cloud model uses hardcoded limits",
|
||||
model: LaunchModel{
|
||||
Name: "qwen3.5:cloud",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: 262144,
|
||||
},
|
||||
{
|
||||
name: "unknown cloud model uses fallback context",
|
||||
modelName: "deepseek-v4-pro:cloud",
|
||||
showResponse: `{
|
||||
"model_info": {"deepseek.context_length": 131072},
|
||||
"details": {"format": "gguf"}
|
||||
}`,
|
||||
name: "unknown cloud model without metadata uses fallback context",
|
||||
model: LaunchModel{
|
||||
Name: "deepseek-v4-pro:cloud",
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: codexFallbackContextWindow,
|
||||
},
|
||||
{
|
||||
name: "vision capability without reasoning advertisement",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"details": {"format": "gguf"},
|
||||
"capabilities": ["vision", "thinking"]
|
||||
}`,
|
||||
name: "vision capability without reasoning advertisement",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision, modelpkg.CapabilityThinking},
|
||||
},
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "system prompt passed through",
|
||||
modelName: "llama3.2",
|
||||
showResponse: `{
|
||||
"model_info": {"llama.context_length": 131072},
|
||||
"details": {"format": "gguf"},
|
||||
"system": "You are a helpful assistant."
|
||||
}`,
|
||||
wantContext: 131072,
|
||||
name: "missing metadata uses fallback context",
|
||||
model: LaunchModel{Name: "llama3.2"},
|
||||
wantContext: codexFallbackContextWindow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, tt.showResponse)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if tt.envContextLen != "" {
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", tt.envContextLen)
|
||||
} else {
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", "")
|
||||
}
|
||||
|
||||
entry := buildCodexModelEntry(tt.modelName)
|
||||
entry := buildCodexModelEntry(tt.model)
|
||||
|
||||
gotContext, _ := entry["context_window"].(int)
|
||||
if gotContext != tt.wantContext {
|
||||
@@ -626,12 +570,6 @@ func TestBuildCodexModelEntryContextWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if tt.name == "system prompt passed through" {
|
||||
if got, _ := entry["base_instructions"].(string); got != "You are a helpful assistant." {
|
||||
t.Errorf("base_instructions = %q, want %q", got, "You are a helpful assistant.")
|
||||
}
|
||||
}
|
||||
|
||||
if tt.name == "cloud model uses hardcoded limits" {
|
||||
truncationPolicy, _ := entry["truncation_policy"].(map[string]any)
|
||||
if mode, _ := truncationPolicy["mode"].(string); mode != "tokens" {
|
||||
|
||||
@@ -43,7 +43,7 @@ func (c *Copilot) findPath() (string, error) {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (c *Copilot) Run(model string, args []string) error {
|
||||
func (c *Copilot) Run(model string, _ []LaunchModel, args []string) error {
|
||||
copilotPath, err := c.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")
|
||||
|
||||
@@ -40,7 +40,7 @@ type modelEntry struct {
|
||||
|
||||
func (d *Droid) String() string { return "Droid" }
|
||||
|
||||
func (d *Droid) Run(model string, args []string) error {
|
||||
func (d *Droid) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if _, err := exec.LookPath("droid"); err != nil {
|
||||
return fmt.Errorf("droid is not installed, install from https://docs.factory.ai/cli/getting-started/quickstart")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (d *Droid) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Droid) Edit(models []string) error {
|
||||
func (d *Droid) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (d *Droid) Edit(models []string) error {
|
||||
return fileutil.WriteWithBackup(settingsPath, data, "droid")
|
||||
}
|
||||
|
||||
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []string) map[string]any {
|
||||
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []LaunchModel) map[string]any {
|
||||
// Keep only non-Ollama models from the raw map (preserves extra fields)
|
||||
// Rebuild Ollama models
|
||||
var nonOllamaModels []any
|
||||
@@ -119,20 +119,18 @@ func updateDroidSettings(settingsMap map[string]any, settings droidSettings, mod
|
||||
var defaultModelID string
|
||||
for i, model := range models {
|
||||
maxOutput := 64000
|
||||
if isCloudModelName(model) {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
maxOutput = l.Output
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
maxOutput = model.MaxOutputTokens
|
||||
}
|
||||
modelID := fmt.Sprintf("custom:%s-%d", model, i)
|
||||
modelID := fmt.Sprintf("custom:%s-%d", model.Name, i)
|
||||
newModels = append(newModels, modelEntry{
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
Model: model.Name,
|
||||
DisplayName: model.Name,
|
||||
BaseURL: envconfig.Host().String() + "/v1",
|
||||
APIKey: "ollama",
|
||||
Provider: "generic-chat-completion-api",
|
||||
MaxOutputTokens: maxOutput,
|
||||
SupportsImages: false,
|
||||
SupportsImages: model.HasCapability("vision"),
|
||||
ID: modelID,
|
||||
Index: i,
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
|
||||
t.Run("fresh install creates models with sequential indices", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
|
||||
t.Run("sets sessionDefaultSettings.model to first model ID", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -116,10 +116,10 @@ func TestDroidEdit(t *testing.T) {
|
||||
t.Run("re-indexes when models removed", func(t *testing.T) {
|
||||
cleanup()
|
||||
// Add three models
|
||||
d.Edit([]string{"model-a", "model-b", "model-c"})
|
||||
d.Edit(testLaunchModels("model-a", "model-b", "model-c"))
|
||||
|
||||
// Remove middle model
|
||||
d.Edit([]string{"model-a", "model-c"})
|
||||
d.Edit(testLaunchModels("model-a", "model-c"))
|
||||
|
||||
settings := readSettings()
|
||||
models := getCustomModels(settings)
|
||||
@@ -155,7 +155,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
]
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
models := getCustomModels(settings)
|
||||
@@ -184,7 +184,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
"sessionDefaultSettings": {"autonomyMode": "auto-high"}
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
|
||||
@@ -203,7 +203,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
|
||||
t.Run("required fields present", func(t *testing.T) {
|
||||
cleanup()
|
||||
d.Edit([]string{"test-model"})
|
||||
d.Edit(testLaunchModels("test-model"))
|
||||
|
||||
settings := readSettings()
|
||||
models := getCustomModels(settings)
|
||||
@@ -239,7 +239,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
"sessionDefaultSettings": {"reasoningEffort": "off"}
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
||||
@@ -256,7 +256,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
"sessionDefaultSettings": {"reasoningEffort": "high"}
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
||||
@@ -281,7 +281,7 @@ func TestDroidEdit_CorruptedJSON(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(`{corrupted json content`), 0o644)
|
||||
|
||||
// Corrupted JSON should return an error so user knows something is wrong
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupted JSON, got nil")
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func TestDroidEdit_WrongTypeCustomModels(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(`{"customModels": "not an array"}`), 0o644)
|
||||
|
||||
// Should not panic - wrong type should be handled gracefully
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit failed with wrong type customModels: %v", err)
|
||||
}
|
||||
@@ -338,7 +338,7 @@ func TestDroidEdit_EmptyModels(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(originalContent), 0o644)
|
||||
|
||||
// Empty models should be no-op
|
||||
err := d.Edit([]string{})
|
||||
err := d.Edit(testLaunchModels())
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with empty models failed: %v", err)
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func TestDroidEdit_DuplicateModels(t *testing.T) {
|
||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
||||
|
||||
// Add same model twice
|
||||
err := d.Edit([]string{"model-a", "model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a", "model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with duplicates failed: %v", err)
|
||||
}
|
||||
@@ -388,7 +388,7 @@ func TestDroidEdit_MalformedModelEntry(t *testing.T) {
|
||||
// Model entry is a string instead of a map
|
||||
os.WriteFile(settingsPath, []byte(`{"customModels": ["not a map", 123]}`), 0o644)
|
||||
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with malformed entries failed: %v", err)
|
||||
}
|
||||
@@ -415,7 +415,7 @@ func TestDroidEdit_WrongTypeSessionSettings(t *testing.T) {
|
||||
// sessionDefaultSettings is a string instead of map
|
||||
os.WriteFile(settingsPath, []byte(`{"sessionDefaultSettings": "not a map"}`), 0o644)
|
||||
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with wrong type sessionDefaultSettings failed: %v", err)
|
||||
}
|
||||
@@ -490,7 +490,7 @@ func TestDroidEdit_RoundTrip(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
|
||||
|
||||
// Edit with new models
|
||||
if err := d.Edit([]string{"llama3", "mistral"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("llama3", "mistral")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -615,7 +615,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"llama3"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("llama3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -715,10 +715,10 @@ func TestDroidEdit_Idempotent(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
|
||||
|
||||
// Edit twice with same models
|
||||
d.Edit([]string{"llama3", "mistral"})
|
||||
d.Edit(testLaunchModels("llama3", "mistral"))
|
||||
firstData, _ := os.ReadFile(settingsPath)
|
||||
|
||||
d.Edit([]string{"llama3", "mistral"})
|
||||
d.Edit(testLaunchModels("llama3", "mistral"))
|
||||
secondData, _ := os.ReadFile(settingsPath)
|
||||
|
||||
// Results should be identical
|
||||
@@ -744,7 +744,7 @@ func TestDroidEdit_MultipleConsecutiveEdits(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
models = []string{"model-x", "model-y", "model-z"}
|
||||
}
|
||||
if err := d.Edit(models); err != nil {
|
||||
if err := d.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("edit %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -803,7 +803,7 @@ func TestDroidEdit_UnicodeAndSpecialCharacters(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -845,7 +845,7 @@ func TestDroidEdit_LargeNumbers(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -889,7 +889,7 @@ func TestDroidEdit_EmptyAndNullValues(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -943,7 +943,7 @@ func TestDroidEdit_DeeplyNestedStructures(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -988,7 +988,7 @@ func TestDroidEdit_ModelNamesWithSpecialCharacters(t *testing.T) {
|
||||
"model_with_underscores",
|
||||
}
|
||||
|
||||
if err := d.Edit(specialModels); err != nil {
|
||||
if err := d.Edit(launchModelsFromNames(specialModels)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1025,7 +1025,7 @@ func TestDroidEdit_MissingCustomModelsKey(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settings = updateDroidSettings(settings, settingsStruct, []string{"model-a"})
|
||||
settings = updateDroidSettings(settings, settingsStruct, testLaunchModels("model-a"))
|
||||
|
||||
// Original fields preserved
|
||||
if settings["diffMode"] != "github" {
|
||||
@@ -1062,7 +1062,7 @@ func TestDroidEdit_NullCustomModels(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1090,7 +1090,7 @@ func TestDroidEdit_MinifiedJSON(t *testing.T) {
|
||||
original := `{"diffMode":"github","enableHooks":true,"hooks":{"imported":["cmd1","cmd2"]},"customModels":[],"sessionDefaultSettings":{}}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1120,7 +1120,7 @@ func TestDroidEdit_CreatesDirectoryIfMissing(t *testing.T) {
|
||||
t.Fatal("directory should not exist before test")
|
||||
}
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1157,7 +1157,7 @@ func TestDroidEdit_PreservesFileAfterError(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
// Empty models list is a no-op, should not modify file
|
||||
d.Edit([]string{})
|
||||
d.Edit(testLaunchModels())
|
||||
|
||||
data, _ := os.ReadFile(settingsPath)
|
||||
if string(data) != original {
|
||||
@@ -1181,7 +1181,7 @@ func TestDroidEdit_BackupCreated(t *testing.T) {
|
||||
original := fmt.Sprintf(`{"diffMode": "%s", "customModels": [], "sessionDefaultSettings": {}}`, uniqueMarker)
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1231,7 +1231,7 @@ func TestDroidEdit_LargeNumberOfModels(t *testing.T) {
|
||||
models = append(models, fmt.Sprintf("model-%d", i))
|
||||
}
|
||||
|
||||
if err := d.Edit(models); err != nil {
|
||||
if err := d.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1261,7 +1261,7 @@ func TestDroidEdit_LocalModelDefaultMaxOutput(t *testing.T) {
|
||||
settingsDir := filepath.Join(tmpDir, ".factory")
|
||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
||||
|
||||
if err := d.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1312,7 +1312,7 @@ func TestDroidEdit_ArraysWithMixedTypes(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ type Hermes struct{}
|
||||
|
||||
func (h *Hermes) String() string { return "Hermes Agent" }
|
||||
|
||||
func (h *Hermes) Run(_ string, args []string) error {
|
||||
func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
// Hermes reads its primary model from config.yaml. launch configures that
|
||||
// default model ahead of time so we can keep runtime invocation simple and
|
||||
// still let Hermes discover additional models later via its own UX.
|
||||
|
||||
@@ -552,7 +552,7 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", []string{"--continue"}); err != nil {
|
||||
if err := h.Run("", nil, []string{"--continue"}); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -603,7 +603,7 @@ fi
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -655,10 +655,10 @@ func TestHermesRun_SetUpLaterRepromptsOnLaterLaunches(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("first Run returned error: %v", err)
|
||||
}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("second Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -713,7 +713,7 @@ func TestHermesRun_SkipsMessagingPromptWhenConfigured(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -753,7 +753,7 @@ func TestHermesRun_SkipsMessagingPromptWithYesPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -798,7 +798,7 @@ fi
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
err := h.Run("", nil)
|
||||
err := h.Run("", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected messaging setup failure")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type stubEditorRunner struct {
|
||||
editErr error
|
||||
}
|
||||
|
||||
func (s *stubEditorRunner) Run(model string, args []string) error {
|
||||
func (s *stubEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
s.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -34,11 +34,11 @@ func (s *stubEditorRunner) String() string { return "StubEditor" }
|
||||
|
||||
func (s *stubEditorRunner) Paths() []string { return nil }
|
||||
|
||||
func (s *stubEditorRunner) Edit(models []string) error {
|
||||
func (s *stubEditorRunner) Edit(models []LaunchModel) error {
|
||||
if s.editErr != nil {
|
||||
return s.editErr
|
||||
}
|
||||
cloned := append([]string(nil), models...)
|
||||
cloned := launchModelNames(models)
|
||||
s.edited = append(s.edited, cloned)
|
||||
return nil
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func TestAllIntegrations_HaveRequiredMethods(t *testing.T) {
|
||||
if displayName == "" {
|
||||
t.Error("String() should not return empty")
|
||||
}
|
||||
var _ func(string, []string) error = r.Run
|
||||
var _ func(string, []LaunchModel, []string) error = r.Run
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -866,7 +866,7 @@ func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
editor := &stubEditorRunner{editErr: errors.New("boom")}
|
||||
err := prepareEditorIntegration("droid", editor, []string{"new-model"})
|
||||
err := prepareEditorIntegration("droid", editor, testLaunchModels("new-model"))
|
||||
if err == nil || !strings.Contains(err.Error(), "setup failed") {
|
||||
t.Fatalf("expected setup failure, got %v", err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (k *Kimi) args(config string, extra []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func (k *Kimi) Run(model string, args []string) error {
|
||||
func (k *Kimi) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
|
||||
err := k.Run("llama3.2", []string{"--model", "other"})
|
||||
err := k.Run("llama3.2", nil, []string{"--model", "other"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--model") {
|
||||
t.Fatalf("expected conflict error mentioning --model, got %v", err)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ exit 0
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
k := &Kimi{}
|
||||
if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil {
|
||||
if err := k.Run("llama3.2", nil, []string{"--quiet", "--print"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -138,16 +138,17 @@ var isInteractiveSession = func() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// Runner executes a model with an integration.
|
||||
// Runner executes an integration with the selected model and its resolved
|
||||
// launch metadata. models is ordered with the primary model first.
|
||||
type Runner interface {
|
||||
Run(model string, args []string) error
|
||||
Run(model string, models []LaunchModel, args []string) error
|
||||
String() string
|
||||
}
|
||||
|
||||
// Editor can edit config files for integrations that support model configuration.
|
||||
type Editor interface {
|
||||
Paths() []string
|
||||
Edit(models []string) error
|
||||
Edit(models []LaunchModel) error
|
||||
Models() []string
|
||||
}
|
||||
|
||||
@@ -166,7 +167,7 @@ type ManagedSingleModel interface {
|
||||
// ManagedModelListConfigurer lets managed single-model integrations receive
|
||||
// the launcher's model list while still preserving one primary selected model.
|
||||
type ManagedModelListConfigurer interface {
|
||||
ConfigureWithModels(primary string, models []string) error
|
||||
ConfigureWithModels(primary string, models []LaunchModel) error
|
||||
}
|
||||
|
||||
// ManagedAutodiscoveryIntegration is for managed integrations that do not need
|
||||
@@ -240,18 +241,6 @@ type SupportedIntegration interface {
|
||||
Supported() error
|
||||
}
|
||||
|
||||
type modelInfo struct {
|
||||
Name string
|
||||
Remote bool
|
||||
ToolCapable bool
|
||||
Capabilities []modelpkg.Capability
|
||||
Size int64
|
||||
Details api.ModelDetails
|
||||
}
|
||||
|
||||
// ModelInfo re-exports launcher model inventory details for callers.
|
||||
type ModelInfo = modelInfo
|
||||
|
||||
// ModelItem represents model metadata before selector-only UI state is derived.
|
||||
type ModelItem struct {
|
||||
Name string
|
||||
@@ -416,8 +405,7 @@ func launchCommandIsClaudeDesktop(name string) bool {
|
||||
|
||||
type launcherClient struct {
|
||||
apiClient *api.Client
|
||||
modelInventory []ModelInfo
|
||||
inventoryLoaded bool
|
||||
inventory *modelInventory
|
||||
recommendationsLoaded bool
|
||||
recommendationItems []ModelItem
|
||||
accountState *AccountState
|
||||
@@ -434,10 +422,18 @@ func newLauncherClient(policy LaunchPolicy) (*launcherClient, error) {
|
||||
|
||||
return &launcherClient{
|
||||
apiClient: apiClient,
|
||||
inventory: newModelInventory(apiClient),
|
||||
policy: policy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) modelInventory() *modelInventory {
|
||||
if c.inventory == nil {
|
||||
c.inventory = newModelInventory(c.apiClient)
|
||||
}
|
||||
return c.inventory
|
||||
}
|
||||
|
||||
// BuildLauncherState returns the launch-owned root launcher menu snapshot.
|
||||
func BuildLauncherState(ctx context.Context) (*LauncherState, error) {
|
||||
launchClient, err := newLauncherClient(defaultLaunchPolicy(isInteractiveSession(), false))
|
||||
@@ -559,7 +555,7 @@ func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient
|
||||
}
|
||||
|
||||
func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) {
|
||||
_ = c.loadModelInventoryOnce(ctx)
|
||||
_, _ = c.modelInventory().Load(ctx)
|
||||
|
||||
state := &LauncherState{
|
||||
LastSelection: config.LastSelection(),
|
||||
@@ -733,7 +729,7 @@ func (c *launcherClient) launchSingleIntegration(ctx context.Context, name strin
|
||||
}
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, target, req)
|
||||
return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -755,13 +751,17 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
return nil
|
||||
}
|
||||
|
||||
var launchModels []LaunchModel
|
||||
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
|
||||
if err := prepareEditorIntegration(name, editor, models); err != nil {
|
||||
launchModels = c.modelInventory().Resolve(ctx, models)
|
||||
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
launchModels = c.resolveRunModels(ctx, models)
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, models[0], req)
|
||||
return launchAfterConfiguration(name, runner, models[0], launchModels, req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -790,7 +790,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, configureModels); err != nil {
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil {
|
||||
return err
|
||||
}
|
||||
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
||||
@@ -820,7 +820,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, req.ExtraArgs)
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -863,7 +863,7 @@ func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, req.ExtraArgs)
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool {
|
||||
@@ -1115,13 +1115,14 @@ func runMultiSelector(title string, items []SelectionItem, preChecked []string,
|
||||
}
|
||||
|
||||
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
||||
if err := c.loadModelInventoryOnce(ctx); err != nil {
|
||||
inventory, err := c.modelInventory().Load(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
recommendations := c.recommendations(ctx)
|
||||
|
||||
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
|
||||
items, orderedChecked, _, _ := buildModelListWithRecommendations(c.modelInventory, recommendations, preChecked, current)
|
||||
items, orderedChecked, _, _ := buildModelListWithRecommendations(inventory, recommendations, preChecked, current)
|
||||
if cloudDisabled {
|
||||
items = filterCloudItems(items)
|
||||
orderedChecked = c.filterDisabledCloudModels(ctx, orderedChecked)
|
||||
@@ -1311,10 +1312,11 @@ func (c *launcherClient) filterDisabledCloudModels(ctx context.Context, models [
|
||||
}
|
||||
|
||||
func (c *launcherClient) savedModelUsable(ctx context.Context, name string) (bool, error) {
|
||||
if err := c.loadModelInventoryOnce(ctx); err != nil {
|
||||
inventory, err := c.modelInventory().Load(ctx)
|
||||
if err != nil {
|
||||
return c.showBasedModelUsable(ctx, name)
|
||||
}
|
||||
return c.singleModelUsable(ctx, name), nil
|
||||
return c.singleModelUsable(ctx, name, inventory), nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string) (bool, error) {
|
||||
@@ -1340,7 +1342,7 @@ func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) singleModelUsable(ctx context.Context, name string) bool {
|
||||
func (c *launcherClient) singleModelUsable(ctx context.Context, name string, inventory []LaunchModel) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
@@ -1348,11 +1350,11 @@ func (c *launcherClient) singleModelUsable(ctx context.Context, name string) boo
|
||||
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
|
||||
return !cloudDisabled
|
||||
}
|
||||
return c.hasLocalModel(name)
|
||||
return hasLocalModel(inventory, name)
|
||||
}
|
||||
|
||||
func (c *launcherClient) hasLocalModel(name string) bool {
|
||||
for _, model := range c.modelInventory {
|
||||
func hasLocalModel(inventory []LaunchModel, name string) bool {
|
||||
for _, model := range inventory {
|
||||
if model.Remote {
|
||||
continue
|
||||
}
|
||||
@@ -1363,41 +1365,18 @@ func (c *launcherClient) hasLocalModel(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error {
|
||||
if c.inventoryLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := c.apiClient.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.modelInventory = c.modelInventory[:0]
|
||||
for _, model := range resp.Models {
|
||||
c.modelInventory = append(c.modelInventory, ModelInfo{
|
||||
Name: model.Name,
|
||||
Remote: model.RemoteModel != "",
|
||||
ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools),
|
||||
Capabilities: slices.Clone(model.Capabilities),
|
||||
Size: model.Size,
|
||||
Details: model.Details,
|
||||
})
|
||||
}
|
||||
|
||||
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
|
||||
if cloudDisabled {
|
||||
c.modelInventory = filterCloudModels(c.modelInventory)
|
||||
}
|
||||
c.inventoryLoaded = true
|
||||
return nil
|
||||
func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel {
|
||||
return c.modelInventory().Resolve(ctx, models)
|
||||
}
|
||||
|
||||
func runIntegration(runner Runner, modelName string, args []string) error {
|
||||
return runner.Run(modelName, args)
|
||||
func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error {
|
||||
if len(models) == 0 && modelName != "" {
|
||||
models = launchModelsFromNames([]string{modelName})
|
||||
}
|
||||
return runner.Run(modelName, models, args)
|
||||
}
|
||||
|
||||
func launchAfterConfiguration(name string, runner Runner, model string, req IntegrationLaunchRequest) error {
|
||||
func launchAfterConfiguration(name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error {
|
||||
if req.ConfigureOnly {
|
||||
launch, err := ConfirmPrompt(fmt.Sprintf("Launch %s now?", runner))
|
||||
if err != nil {
|
||||
@@ -1410,7 +1389,7 @@ func launchAfterConfiguration(name string, runner Runner, model string, req Inte
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
return runIntegration(runner, model, req.ExtraArgs)
|
||||
return runIntegration(runner, model, models, req.ExtraArgs)
|
||||
}
|
||||
|
||||
func loadStoredIntegrationConfig(name string) (*config.IntegrationConfig, error) {
|
||||
|
||||
@@ -25,7 +25,7 @@ type launcherEditorRunner struct {
|
||||
ranModel string
|
||||
}
|
||||
|
||||
func (r *launcherEditorRunner) Run(model string, args []string) error {
|
||||
func (r *launcherEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -34,8 +34,8 @@ func (r *launcherEditorRunner) String() string { return "LauncherEditor" }
|
||||
|
||||
func (r *launcherEditorRunner) Paths() []string { return r.paths }
|
||||
|
||||
func (r *launcherEditorRunner) Edit(models []string) error {
|
||||
r.edited = append(r.edited, append([]string(nil), models...))
|
||||
func (r *launcherEditorRunner) Edit(models []LaunchModel) error {
|
||||
r.edited = append(r.edited, launchModelNames(models))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ type launcherSingleRunner struct {
|
||||
ranModel string
|
||||
}
|
||||
|
||||
func (r *launcherSingleRunner) Run(model string, args []string) error {
|
||||
func (r *launcherSingleRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -83,7 +83,7 @@ type launcherManagedRunner struct {
|
||||
skipModelReadiness bool
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) Run(model string, args []string) error {
|
||||
func (r *launcherManagedRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -133,8 +133,8 @@ type launcherManagedListRunner struct {
|
||||
configuredModelLists [][]string
|
||||
}
|
||||
|
||||
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []string) error {
|
||||
r.configuredModelLists = append(r.configuredModelLists, append([]string(nil), models...))
|
||||
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
|
||||
return r.Configure(primary)
|
||||
}
|
||||
|
||||
|
||||
201
cmd/launch/model_inventory.go
Normal file
201
cmd/launch/model_inventory.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// LaunchModel is the model metadata Launch passes to integration config
|
||||
// writers after resolving selected model names through the per-run inventory.
|
||||
type LaunchModel struct {
|
||||
Name string
|
||||
Remote bool
|
||||
ToolCapable bool
|
||||
Capabilities []modelpkg.Capability
|
||||
ContextLength int
|
||||
MaxOutputTokens int
|
||||
EmbeddingLength int
|
||||
Size int64
|
||||
Details api.ModelDetails
|
||||
}
|
||||
|
||||
type modelInfo = LaunchModel
|
||||
|
||||
// ModelInfo re-exports launcher model inventory details for callers.
|
||||
type ModelInfo = LaunchModel
|
||||
|
||||
func (m LaunchModel) HasCapability(capability modelpkg.Capability) bool {
|
||||
return slices.Contains(m.Capabilities, capability)
|
||||
}
|
||||
|
||||
func (m LaunchModel) WithCloudLimits() LaunchModel {
|
||||
if limit, ok := lookupCloudModelLimit(m.Name); ok {
|
||||
if m.ContextLength <= 0 {
|
||||
m.ContextLength = limit.Context
|
||||
}
|
||||
if m.MaxOutputTokens <= 0 {
|
||||
m.MaxOutputTokens = limit.Output
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
type modelInventory struct {
|
||||
client *api.Client
|
||||
|
||||
mu sync.Mutex
|
||||
loaded bool
|
||||
models []LaunchModel
|
||||
err error
|
||||
}
|
||||
|
||||
func newModelInventory(client *api.Client) *modelInventory {
|
||||
return &modelInventory{client: client}
|
||||
}
|
||||
|
||||
func (i *modelInventory) Load(ctx context.Context) ([]LaunchModel, error) {
|
||||
return i.load(ctx, false)
|
||||
}
|
||||
|
||||
func (i *modelInventory) Refresh(ctx context.Context) ([]LaunchModel, error) {
|
||||
return i.load(ctx, true)
|
||||
}
|
||||
|
||||
func (i *modelInventory) load(ctx context.Context, force bool) ([]LaunchModel, error) {
|
||||
if i == nil || i.client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.loaded && !force {
|
||||
return cloneLaunchModels(i.models), i.err
|
||||
}
|
||||
|
||||
resp, err := i.client.List(ctx)
|
||||
if err != nil {
|
||||
i.models = nil
|
||||
i.err = err
|
||||
i.loaded = true
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.models = make([]LaunchModel, 0, len(resp.Models))
|
||||
for _, model := range resp.Models {
|
||||
i.models = append(i.models, launchModelFromListResponse(model))
|
||||
}
|
||||
i.err = nil
|
||||
i.loaded = true
|
||||
|
||||
return cloneLaunchModels(i.models), i.err
|
||||
}
|
||||
|
||||
func (i *modelInventory) Resolve(ctx context.Context, names []string) []LaunchModel {
|
||||
names = dedupeModelList(names)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
models, err := i.Load(ctx)
|
||||
if err != nil {
|
||||
models = nil
|
||||
}
|
||||
|
||||
resolved, localMiss := resolveLaunchModels(names, models)
|
||||
if localMiss {
|
||||
if refreshed, err := i.Refresh(ctx); err == nil {
|
||||
resolved, _ = resolveLaunchModels(names, refreshed)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveLaunchModels(names []string, models []LaunchModel) ([]LaunchModel, bool) {
|
||||
resolved := make([]LaunchModel, 0, len(names))
|
||||
localMiss := false
|
||||
for _, name := range names {
|
||||
if model, ok := findLaunchModel(models, name); ok {
|
||||
resolved = append(resolved, model.WithCloudLimits())
|
||||
continue
|
||||
}
|
||||
if !isCloudModelName(name) {
|
||||
localMiss = true
|
||||
}
|
||||
resolved = append(resolved, fallbackLaunchModel(name))
|
||||
}
|
||||
return resolved, localMiss
|
||||
}
|
||||
|
||||
func launchModelFromListResponse(model api.ListModelResponse) LaunchModel {
|
||||
return LaunchModel{
|
||||
Name: model.Name,
|
||||
Remote: model.RemoteModel != "",
|
||||
ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools),
|
||||
Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...),
|
||||
ContextLength: model.Details.ContextLength,
|
||||
EmbeddingLength: model.Details.EmbeddingLength,
|
||||
Size: model.Size,
|
||||
Details: model.Details,
|
||||
}.WithCloudLimits()
|
||||
}
|
||||
|
||||
func fallbackLaunchModel(name string) LaunchModel {
|
||||
return LaunchModel{Name: name, Remote: isCloudModelName(name)}.WithCloudLimits()
|
||||
}
|
||||
|
||||
func findLaunchModel(models []LaunchModel, name string) (LaunchModel, bool) {
|
||||
for _, model := range models {
|
||||
if launchModelMatches(model.Name, name) {
|
||||
return cloneLaunchModel(model), true
|
||||
}
|
||||
}
|
||||
return LaunchModel{}, false
|
||||
}
|
||||
|
||||
func launchModelMatches(candidate, name string) bool {
|
||||
if candidate == name {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSuffix(candidate, ":latest") == name
|
||||
}
|
||||
|
||||
func cloneLaunchModel(model LaunchModel) LaunchModel {
|
||||
model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...)
|
||||
model.Details.Families = append([]string(nil), model.Details.Families...)
|
||||
return model
|
||||
}
|
||||
|
||||
func cloneLaunchModels(models []LaunchModel) []LaunchModel {
|
||||
cloned := make([]LaunchModel, len(models))
|
||||
for i, model := range models {
|
||||
cloned[i] = cloneLaunchModel(model)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func launchModelNames(models []LaunchModel) []string {
|
||||
names := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
if model.Name != "" {
|
||||
names = append(names, model.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func launchModelsFromNames(names []string) []LaunchModel {
|
||||
models := make([]LaunchModel, 0, len(names))
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
models = append(models, fallbackLaunchModel(name))
|
||||
}
|
||||
return models
|
||||
}
|
||||
80
cmd/launch/model_inventory_test.go
Normal file
80
cmd/launch/model_inventory_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestModelInventoryResolveRefreshesLocalMiss(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tags" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
calls++
|
||||
if calls == 1 {
|
||||
fmt.Fprint(w, `{"models":[]}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"models":[{"name":"new-model","size":123,"details":{"context_length":65536,"embedding_length":1024},"capabilities":["vision","tools"]}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
inventory := newModelInventory(api.NewClient(u, srv.Client()))
|
||||
|
||||
got := inventory.Resolve(context.Background(), []string{"new-model"})
|
||||
if calls != 2 {
|
||||
t.Fatalf("List calls = %d, want 2", calls)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Resolve returned %d models, want 1", len(got))
|
||||
}
|
||||
if got[0].Name != "new-model" {
|
||||
t.Fatalf("Name = %q, want new-model", got[0].Name)
|
||||
}
|
||||
if got[0].ContextLength != 65_536 || got[0].EmbeddingLength != 1_024 {
|
||||
t.Fatalf("metadata = context %d embedding %d, want refreshed metadata", got[0].ContextLength, got[0].EmbeddingLength)
|
||||
}
|
||||
if !got[0].HasCapability(modelpkg.CapabilityVision) || !got[0].ToolCapable {
|
||||
t.Fatalf("capabilities = %v toolCapable=%v, want refreshed capabilities", got[0].Capabilities, got[0].ToolCapable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelInventoryResolveDoesNotRefreshCloudMiss(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tags" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
calls++
|
||||
fmt.Fprint(w, `{"models":[]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
inventory := newModelInventory(api.NewClient(u, srv.Client()))
|
||||
|
||||
got := inventory.Resolve(context.Background(), []string{"glm-5.1:cloud"})
|
||||
if calls != 1 {
|
||||
t.Fatalf("List calls = %d, want 1", calls)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Resolve returned %d models, want 1", len(got))
|
||||
}
|
||||
if got[0].Name != "glm-5.1:cloud" || !got[0].Remote {
|
||||
t.Fatalf("resolved model = %#v, want cloud fallback", got[0])
|
||||
}
|
||||
if got[0].ContextLength <= 0 || got[0].MaxOutputTokens <= 0 {
|
||||
t.Fatalf("cloud limits not applied: %#v", got[0])
|
||||
}
|
||||
}
|
||||
@@ -299,18 +299,17 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err
|
||||
}
|
||||
|
||||
// prepareEditorIntegration persists models and applies editor-managed config files.
|
||||
func prepareEditorIntegration(name string, editor Editor, models []string) error {
|
||||
func prepareEditorIntegration(name string, editor Editor, models []LaunchModel) error {
|
||||
if err := editor.Edit(models); err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
if err := config.SaveIntegration(name, models); err != nil {
|
||||
if err := config.SaveIntegration(name, launchModelNames(models)); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []string) error {
|
||||
models = dedupeModelList(append([]string{model}, models...))
|
||||
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []LaunchModel) error {
|
||||
var err error
|
||||
if withModels, ok := managed.(ManagedModelListConfigurer); ok {
|
||||
err = withModels.ConfigureWithModels(model, models)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -10,21 +9,15 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const defaultGatewayPort = 18789
|
||||
|
||||
// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls.
|
||||
var openclawModelShowTimeout = 5 * time.Second
|
||||
|
||||
// openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install
|
||||
var openclawFreshInstall bool
|
||||
|
||||
@@ -34,7 +27,7 @@ type Openclaw struct{}
|
||||
|
||||
func (c *Openclaw) String() string { return "OpenClaw" }
|
||||
|
||||
func (c *Openclaw) Run(model string, args []string) error {
|
||||
func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
|
||||
bin, err := ensureOpenclawInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -656,7 +649,7 @@ func (c *Openclaw) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Openclaw) Edit(models []string) error {
|
||||
func (c *Openclaw) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -710,13 +703,11 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
client, _ := api.ClientFromEnvironment()
|
||||
|
||||
var newModels []any
|
||||
for _, m := range models {
|
||||
entry, _ := openclawModelConfig(context.Background(), client, m)
|
||||
entry, _ := openclawModelConfig(m)
|
||||
// Merge existing fields (user customizations)
|
||||
if existing, ok := existingByID[m]; ok {
|
||||
if existing, ok := existingByID[m.Name]; ok {
|
||||
for k, v := range existing {
|
||||
if _, isNew := entry[k]; !isNew {
|
||||
entry[k] = v
|
||||
@@ -744,7 +735,7 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
if modelConfig == nil {
|
||||
modelConfig = make(map[string]any)
|
||||
}
|
||||
modelConfig["primary"] = "ollama/" + models[0]
|
||||
modelConfig["primary"] = "ollama/" + models[0].Name
|
||||
defaults["model"] = modelConfig
|
||||
agents["defaults"] = defaults
|
||||
config["agents"] = agents
|
||||
@@ -759,7 +750,7 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
|
||||
// Clear any per-session model overrides so the new primary takes effect
|
||||
// immediately rather than being shadowed by a cached modelOverride.
|
||||
clearSessionModelOverride(models[0])
|
||||
clearSessionModelOverride(models[0].Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -936,10 +927,10 @@ func configureOllamaWebSearch() {
|
||||
|
||||
// openclawModelConfig builds an OpenClaw model config entry with capability detection.
|
||||
// The second return value indicates whether the model is a cloud (remote) model.
|
||||
func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) {
|
||||
func openclawModelConfig(model LaunchModel) (map[string]any, bool) {
|
||||
entry := map[string]any{
|
||||
"id": modelID,
|
||||
"name": modelID,
|
||||
"id": model.Name,
|
||||
"name": model.Name,
|
||||
"input": []any{"text"},
|
||||
"cost": map[string]any{
|
||||
"input": 0,
|
||||
@@ -949,53 +940,24 @@ func openclawModelConfig(ctx context.Context, client *api.Client, modelID string
|
||||
},
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return entry, false
|
||||
}
|
||||
|
||||
showCtx := ctx
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
var cancel context.CancelFunc
|
||||
showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID})
|
||||
if err != nil {
|
||||
return entry, false
|
||||
}
|
||||
|
||||
// Set input types based on vision capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||
if model.HasCapability("vision") {
|
||||
entry["input"] = []any{"text", "image"}
|
||||
}
|
||||
|
||||
// Set reasoning based on thinking capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
|
||||
if model.HasCapability("thinking") {
|
||||
entry["reasoning"] = true
|
||||
}
|
||||
|
||||
// Cloud models: use hardcoded limits for context/output tokens.
|
||||
// Capability detection above still applies (vision, thinking).
|
||||
if resp.RemoteModel != "" {
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
entry["contextWindow"] = l.Context
|
||||
entry["maxTokens"] = l.Output
|
||||
}
|
||||
return entry, true
|
||||
if model.ContextLength > 0 {
|
||||
entry["contextWindow"] = model.ContextLength
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
entry["maxTokens"] = model.MaxOutputTokens
|
||||
}
|
||||
|
||||
// Extract context window from ModelInfo (local models only)
|
||||
for key, val := range resp.ModelInfo {
|
||||
if strings.HasSuffix(key, ".context_length") {
|
||||
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
|
||||
entry["contextWindow"] = int(ctxLen)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return entry, false
|
||||
return entry, model.Remote || isCloudModelName(model.Name)
|
||||
}
|
||||
|
||||
func (c *Openclaw) Models() []string {
|
||||
|
||||
@@ -2,12 +2,9 @@ package launch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestOpenclawIntegration(t *testing.T) {
|
||||
@@ -78,7 +75,7 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||
if err := c.Run("llama3.2", nil, []string{"gateway", "--someflag"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -152,7 +149,7 @@ fi
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", nil); err != nil {
|
||||
if err := c.Run("llama3.2", nil, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -224,7 +221,7 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", nil); err != nil {
|
||||
if err := c.Run("llama3.2", nil, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -287,7 +284,7 @@ exit 0
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"status"}); err != nil {
|
||||
if err := c.Run("llama3.2", nil, []string{"status"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -367,7 +364,7 @@ exit 0
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"tui"}); err != nil {
|
||||
if err := c.Run("llama3.2", nil, []string{"tui"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -617,7 +614,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("fresh install", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
@@ -626,7 +623,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("multiple models - first is primary", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
@@ -638,7 +635,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
cleanup()
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644)
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -655,7 +652,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
cleanup()
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644)
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -671,7 +668,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("preserve user customizations on models", func(t *testing.T) {
|
||||
cleanup()
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
// User adds custom field
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -687,7 +684,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
os.WriteFile(configPath, configData, 0o644)
|
||||
|
||||
// Re-run Edit
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
data, _ = os.ReadFile(configPath)
|
||||
json.Unmarshal(data, &cfg)
|
||||
@@ -703,8 +700,8 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("edit replaces models list", func(t *testing.T) {
|
||||
cleanup()
|
||||
c.Edit([]string{"llama3.2", "mistral"})
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2", "mistral"))
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
assertOpenclawModelNotExists(t, configPath, "mistral")
|
||||
@@ -716,7 +713,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
original := `{"existing":"data"}`
|
||||
os.WriteFile(configPath, []byte(original), 0o644)
|
||||
|
||||
c.Edit([]string{})
|
||||
c.Edit(testLaunchModels())
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if string(data) != original {
|
||||
@@ -729,7 +726,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{corrupted`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -745,7 +742,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
@@ -925,7 +922,7 @@ func TestOpenclawEditSchemaFields(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
configPath := filepath.Join(tmpDir, ".openclaw", "openclaw.json")
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -966,7 +963,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
|
||||
|
||||
t.Run("model with colon tag", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"llama3.2:70b"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2:70b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2:70b")
|
||||
@@ -975,7 +972,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
|
||||
|
||||
t.Run("model with slash", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"library/model:tag"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("library/model:tag")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "library/model:tag")
|
||||
@@ -984,7 +981,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
|
||||
|
||||
t.Run("model with hyphen", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"test-model"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("test-model")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "test-model")
|
||||
@@ -1004,7 +1001,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644)
|
||||
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
var cfg map[string]any
|
||||
@@ -1021,7 +1018,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644)
|
||||
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
var cfg map[string]any
|
||||
@@ -1061,7 +1058,7 @@ func TestOpenclawEdit_RoundTrip(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1107,10 +1104,10 @@ func TestOpenclawEdit_Idempotent(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
|
||||
|
||||
c.Edit([]string{"llama3.2", "mistral"})
|
||||
c.Edit(testLaunchModels("llama3.2", "mistral"))
|
||||
firstData, _ := os.ReadFile(configPath)
|
||||
|
||||
c.Edit([]string{"llama3.2", "mistral"})
|
||||
c.Edit(testLaunchModels("llama3.2", "mistral"))
|
||||
secondData, _ := os.ReadFile(configPath)
|
||||
|
||||
if string(firstData) != string(secondData) {
|
||||
@@ -1133,7 +1130,7 @@ func TestOpenclawEdit_MultipleConsecutiveEdits(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
models = []string{"model-x", "model-y", "model-z"}
|
||||
}
|
||||
if err := c.Edit(models); err != nil {
|
||||
if err := c.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("edit %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -1162,7 +1159,7 @@ func TestOpenclawEdit_BackupCreated(t *testing.T) {
|
||||
original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker)
|
||||
os.WriteFile(configPath, []byte(original), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"model-a"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1284,7 +1281,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"theme":"new"}`), 0o644)
|
||||
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"legacy"}`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1303,7 +1300,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
|
||||
os.MkdirAll(legacyDir, 0o755)
|
||||
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"dark"}`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1331,7 +1328,7 @@ func TestOpenclawEdit_CreatesDirectoryIfMissing(t *testing.T) {
|
||||
t.Fatal("directory should not exist before test")
|
||||
}
|
||||
|
||||
if err := c.Edit([]string{"model-a"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2248,8 +2245,8 @@ func TestPrintOpenclawReady(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpenclawModelConfig(t *testing.T) {
|
||||
t.Run("nil client returns base config", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2")
|
||||
t.Run("minimal model returns base config", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(fallbackLaunchModel("llama3.2"))
|
||||
|
||||
if cfg["id"] != "llama3.2" {
|
||||
t.Errorf("id = %v, want llama3.2", cfg["id"])
|
||||
@@ -2260,29 +2257,17 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
if cfg["cost"] == nil {
|
||||
t.Error("cost should be set")
|
||||
}
|
||||
// Should not have capability fields without API
|
||||
// Should not have capability fields without inventory metadata.
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set without API")
|
||||
t.Error("reasoning should not be set without metadata")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set without API")
|
||||
t.Error("contextWindow should not be set without metadata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{"vision"}, ContextLength: 4096})
|
||||
|
||||
input, ok := cfg["input"].([]any)
|
||||
if !ok || len(input) != 2 {
|
||||
@@ -2291,19 +2276,7 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{"completion"}})
|
||||
|
||||
input, ok := cfg["input"].([]any)
|
||||
if !ok || len(input) != 1 {
|
||||
@@ -2315,39 +2288,15 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "qwq")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{"thinking"}})
|
||||
|
||||
if cfg["reasoning"] != true {
|
||||
t.Error("expected reasoning = true for thinking model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extracts context window from model info", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
|
||||
t.Run("sets context window from inventory metadata", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
|
||||
|
||||
if cfg["contextWindow"] != 131072 {
|
||||
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||
@@ -2355,19 +2304,11 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("handles all capabilities together", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{
|
||||
Name: "qwen3-vision",
|
||||
Capabilities: []model.Capability{"vision", "thinking"},
|
||||
ContextLength: 32768,
|
||||
})
|
||||
|
||||
input, ok := cfg["input"].([]any)
|
||||
if !ok || len(input) != 2 {
|
||||
@@ -2381,17 +2322,8 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns base config when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "missing-model")
|
||||
t.Run("returns base config when metadata is unavailable", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(fallbackLaunchModel("missing-model"))
|
||||
|
||||
if cfg["id"] != "missing-model" {
|
||||
t.Errorf("id = %v, want missing-model", cfg["id"])
|
||||
@@ -2401,62 +2333,15 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
t.Error("input should always be set")
|
||||
}
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set when show fails")
|
||||
t.Error("reasoning should not be set when metadata is unavailable")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set when show fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("times out slow show and returns base config", func(t *testing.T) {
|
||||
oldTimeout := openclawModelShowTimeout
|
||||
openclawModelShowTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { openclawModelShowTimeout = oldTimeout })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
start := time.Now()
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "slow-model")
|
||||
elapsed := time.Since(start)
|
||||
if elapsed >= 250*time.Millisecond {
|
||||
t.Fatalf("openclawModelConfig took too long: %v", elapsed)
|
||||
}
|
||||
if cfg["id"] != "slow-model" {
|
||||
t.Errorf("id = %v, want slow-model", cfg["id"])
|
||||
}
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set on timeout")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set on timeout")
|
||||
t.Error("contextWindow should not be set when metadata is unavailable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips zero context length", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "test-model")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "test-model", ContextLength: 0})
|
||||
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set for zero value")
|
||||
@@ -2464,21 +2349,7 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model uses hardcoded limits", func(t *testing.T) {
|
||||
// Use a model name that's in cloudModelLimits and make the server
|
||||
// report it as a remote/cloud model
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.7"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.7:cloud")
|
||||
cfg, isCloud := openclawModelConfig(fallbackLaunchModel("minimax-m2.7:cloud"))
|
||||
|
||||
if !isCloud {
|
||||
t.Error("expected isCloud = true for cloud model")
|
||||
@@ -2492,21 +2363,11 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model with vision capability gets image input", func(t *testing.T) {
|
||||
// Regression test: cloud models must not skip capability detection.
|
||||
// A cloud model that reports vision capability should have input: [text, image].
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud")
|
||||
cfg, isCloud := openclawModelConfig(LaunchModel{
|
||||
Name: "qwen3-vl:235b-cloud",
|
||||
Remote: true,
|
||||
Capabilities: []model.Capability{"vision"},
|
||||
}.WithCloudLimits())
|
||||
|
||||
if !isCloud {
|
||||
t.Error("expected isCloud = true for cloud vision model")
|
||||
@@ -2518,21 +2379,11 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) {
|
||||
// Regression test: cloud models must not skip capability detection.
|
||||
// A cloud model that reports thinking capability should have reasoning: true.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud")
|
||||
cfg, isCloud := openclawModelConfig(LaunchModel{
|
||||
Name: "qwq:cloud",
|
||||
Remote: true,
|
||||
Capabilities: []model.Capability{"thinking"},
|
||||
})
|
||||
|
||||
if !isCloud {
|
||||
t.Error("expected isCloud = true for cloud thinking model")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -9,12 +8,9 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// OpenCode implements Runner and Editor for OpenCode integration.
|
||||
@@ -24,8 +20,6 @@ type OpenCode struct {
|
||||
configContent string // JSON config built by Edit, passed to Run via env var
|
||||
}
|
||||
|
||||
const openCodeModelShowTimeout = 2 * time.Second
|
||||
|
||||
func (o *OpenCode) String() string { return "OpenCode" }
|
||||
|
||||
// findOpenCode returns the opencode binary path, checking PATH first then the
|
||||
@@ -49,7 +43,7 @@ func findOpenCode() (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (o *OpenCode) Run(model string, args []string) error {
|
||||
func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error {
|
||||
opencodePath, ok := findOpenCode()
|
||||
if !ok {
|
||||
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
|
||||
@@ -60,7 +54,7 @@ func (o *OpenCode) Run(model string, args []string) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if content := o.resolveContent(model); content != "" {
|
||||
if content := o.resolveContent(model, models); content != "" {
|
||||
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
|
||||
}
|
||||
return cmd.Run()
|
||||
@@ -69,21 +63,57 @@ func (o *OpenCode) Run(model string, args []string) error {
|
||||
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
|
||||
// Returns content built by Edit if available, otherwise builds from model.json
|
||||
// with the requested model as primary (e.g. re-launch with saved config).
|
||||
func (o *OpenCode) resolveContent(model string) string {
|
||||
func (o *OpenCode) resolveContent(model string, models []LaunchModel) string {
|
||||
if o.configContent != "" {
|
||||
return o.configContent
|
||||
}
|
||||
models := readModelJSONModels()
|
||||
if !slices.Contains(models, model) {
|
||||
models = append([]string{model}, models...)
|
||||
resolvedModels := resolveOpenCodeRunModels(model, models, readModelJSONModels())
|
||||
if len(resolvedModels) == 0 {
|
||||
return ""
|
||||
}
|
||||
content, err := buildInlineConfig(model, models)
|
||||
content, err := buildInlineConfig(resolvedModels[0], resolvedModels)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func resolveOpenCodeRunModels(primary string, models []LaunchModel, stateModels []string) []LaunchModel {
|
||||
if primary == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolved := make([]LaunchModel, 0, 1+len(models)+len(stateModels))
|
||||
appendModel := func(name string) {
|
||||
if name == "" || hasLaunchModel(resolved, name) {
|
||||
return
|
||||
}
|
||||
if model, ok := findLaunchModel(models, name); ok {
|
||||
resolved = append(resolved, model)
|
||||
return
|
||||
}
|
||||
resolved = append(resolved, fallbackLaunchModel(name))
|
||||
}
|
||||
|
||||
appendModel(primary)
|
||||
for _, model := range models {
|
||||
appendModel(model.Name)
|
||||
}
|
||||
for _, model := range stateModels {
|
||||
appendModel(model)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func hasLaunchModel(models []LaunchModel, name string) bool {
|
||||
for _, model := range models {
|
||||
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *OpenCode) Paths() []string {
|
||||
sp, err := openCodeStatePath()
|
||||
if err != nil {
|
||||
@@ -106,12 +136,13 @@ func openCodeStatePath() (string, error) {
|
||||
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
|
||||
}
|
||||
|
||||
func (o *OpenCode) Edit(modelList []string) error {
|
||||
func (o *OpenCode) Edit(models []LaunchModel) error {
|
||||
modelList := launchModelNames(models)
|
||||
if len(modelList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := buildInlineConfig(modelList[0], modelList)
|
||||
content, err := buildInlineConfig(models[0], models)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -178,16 +209,11 @@ func (o *OpenCode) Models() []string {
|
||||
|
||||
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
|
||||
// primary is the model to launch with, models is the full list of available models.
|
||||
func buildInlineConfig(primary string, models []string) (string, error) {
|
||||
if primary == "" || len(models) == 0 {
|
||||
func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error) {
|
||||
if primary.Name == "" || len(models) == 0 {
|
||||
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
client = nil
|
||||
}
|
||||
|
||||
config := map[string]any{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
@@ -197,10 +223,10 @@ func buildInlineConfig(primary string, models []string) (string, error) {
|
||||
"options": map[string]any{
|
||||
"baseURL": envconfig.Host().String() + "/v1",
|
||||
},
|
||||
"models": buildModelEntries(context.Background(), client, models),
|
||||
"models": buildModelEntries(models),
|
||||
},
|
||||
},
|
||||
"model": "ollama/" + primary,
|
||||
"model": "ollama/" + primary.Name,
|
||||
}
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
@@ -240,39 +266,29 @@ func readModelJSONModels() []string {
|
||||
return models
|
||||
}
|
||||
|
||||
func buildModelEntries(ctx context.Context, client *api.Client, modelList []string) map[string]any {
|
||||
if client != nil {
|
||||
var cancel context.CancelFunc
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
ctx, cancel = context.WithTimeout(ctx, openCodeModelShowTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func buildModelEntries(modelList []LaunchModel) map[string]any {
|
||||
models := make(map[string]any)
|
||||
for _, modelID := range modelList {
|
||||
for _, model := range modelList {
|
||||
entry := map[string]any{
|
||||
"name": modelID,
|
||||
"name": model.Name,
|
||||
}
|
||||
if client != nil {
|
||||
if resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID}); err == nil {
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||
entry["modalities"] = map[string]any{
|
||||
"input": []string{"text", "image"},
|
||||
"output": []string{"text"},
|
||||
}
|
||||
}
|
||||
if model.HasCapability("vision") {
|
||||
entry["modalities"] = map[string]any{
|
||||
"input": []string{"text", "image"},
|
||||
"output": []string{"text"},
|
||||
}
|
||||
}
|
||||
if isCloudModelName(modelID) {
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
entry["limit"] = map[string]any{
|
||||
"context": l.Context,
|
||||
"output": l.Output,
|
||||
}
|
||||
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
|
||||
limit := make(map[string]any)
|
||||
if model.ContextLength > 0 {
|
||||
limit["context"] = model.ContextLength
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
limit["output"] = model.MaxOutputTokens
|
||||
}
|
||||
entry["limit"] = limit
|
||||
}
|
||||
models[modelID] = entry
|
||||
models[model.Name] = entry
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestOpenCodeIntegration(t *testing.T) {
|
||||
@@ -40,7 +33,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("builds config content with provider", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -74,7 +67,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("multiple models", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"llama3.2", "qwen3:32b"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("llama3.2", "qwen3:32b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -99,7 +92,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("empty models is no-op", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{}); err != nil {
|
||||
if err := o.Edit(testLaunchModels()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.configContent != "" {
|
||||
@@ -111,7 +104,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
o := &OpenCode{}
|
||||
o.Edit([]string{"llama3.2"})
|
||||
o.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".config", "opencode")
|
||||
|
||||
@@ -126,7 +119,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("cloud model has limits", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -153,7 +146,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("local model has no limits", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
o.Edit([]string{"llama3.2"})
|
||||
o.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(o.configContent), &cfg)
|
||||
@@ -168,37 +161,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("vision model gets image input modalities", func(t *testing.T) {
|
||||
u, err := url.Parse("http://ollama.example")
|
||||
if err != nil {
|
||||
t.Fatalf("parse test URL: %v", err)
|
||||
}
|
||||
client := api.NewClient(u, &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/api/show" {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Body: io.NopCloser(strings.NewReader("not found")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode show request: %v", err)
|
||||
}
|
||||
if body.Model != "gemma4:26b" {
|
||||
t.Fatalf("show request model = %q, want gemma4:26b", body.Model)
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"capabilities":["vision"],"model_info":{}}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})})
|
||||
|
||||
models := buildModelEntries(context.Background(), client, []string{"gemma4:26b"})
|
||||
models := buildModelEntries([]LaunchModel{{Name: "gemma4:26b", Capabilities: []model.Capability{"vision"}}})
|
||||
entry, _ := models["gemma4:26b"].(map[string]any)
|
||||
modalities, _ := entry["modalities"].(map[string]any)
|
||||
input, _ := modalities["input"].([]string)
|
||||
@@ -214,55 +177,23 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildModelEntries(t *testing.T) {
|
||||
t.Run("defaults to model name when capabilities cannot be probed", func(t *testing.T) {
|
||||
models := buildModelEntries(context.Background(), nil, []string{"llama3.2"})
|
||||
t.Run("defaults to model name without capabilities", func(t *testing.T) {
|
||||
models := buildModelEntries(testLaunchModels("llama3.2"))
|
||||
entry, _ := models["llama3.2"].(map[string]any)
|
||||
if entry["name"] != "llama3.2" {
|
||||
t.Fatalf("name = %v, want llama3.2", entry["name"])
|
||||
}
|
||||
if _, ok := entry["modalities"]; ok {
|
||||
t.Fatalf("modalities should not be set without an API client, got %v", entry["modalities"])
|
||||
t.Fatalf("modalities should not be set without capabilities, got %v", entry["modalities"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses one timeout budget across capability probes", func(t *testing.T) {
|
||||
u, err := url.Parse("http://ollama.example")
|
||||
if err != nil {
|
||||
t.Fatalf("parse test URL: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
waited := 0
|
||||
|
||||
client := api.NewClient(u, &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
mu.Lock()
|
||||
if req.Context().Err() == nil {
|
||||
waited++
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
})})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
models := buildModelEntries(ctx, client, []string{"slow-1", "slow-2"})
|
||||
for _, modelID := range []string{"slow-1", "slow-2"} {
|
||||
entry, _ := models[modelID].(map[string]any)
|
||||
if entry["name"] != modelID {
|
||||
t.Fatalf("name for %q = %v, want %q", modelID, entry["name"], modelID)
|
||||
}
|
||||
if _, ok := entry["modalities"]; ok {
|
||||
t.Fatalf("modalities for %q should not be set after probe timeout, got %v", modelID, entry["modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if waited != 1 {
|
||||
t.Fatalf("expected shared timeout to block one probe, waited on %d probes", waited)
|
||||
t.Run("uses context and output limits from metadata", func(t *testing.T) {
|
||||
models := buildModelEntries([]LaunchModel{{Name: "glm-5:cloud", ContextLength: 202_752, MaxOutputTokens: 131_072}})
|
||||
entry, _ := models["glm-5:cloud"].(map[string]any)
|
||||
limit, _ := entry["limit"].(map[string]any)
|
||||
if limit["context"] != 202_752 || limit["output"] != 131_072 {
|
||||
t.Fatalf("limit = %v, want context/output", limit)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -392,7 +323,7 @@ func TestOpenCodeEdit_CloudModelLimitStructure(t *testing.T) {
|
||||
|
||||
expected := cloudModelLimits["glm-4.7"]
|
||||
|
||||
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -422,7 +353,7 @@ func TestOpenCodeEdit_SpecialCharsInModelName(t *testing.T) {
|
||||
|
||||
specialModel := `model-with-"quotes"`
|
||||
|
||||
err := o.Edit([]string{specialModel})
|
||||
err := o.Edit(testLaunchModels(specialModel))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with special chars failed: %v", err)
|
||||
}
|
||||
@@ -515,7 +446,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"gemma4"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("gemma4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
editContent := o.configContent
|
||||
@@ -530,7 +461,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
got := o.resolveContent("gemma4")
|
||||
got := o.resolveContent("gemma4", nil)
|
||||
if got != editContent {
|
||||
t.Errorf("resolveContent returned different content than Edit set\ngot: %s\nwant: %s", got, editContent)
|
||||
}
|
||||
@@ -552,7 +483,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("llama3.2")
|
||||
content := o.resolveContent("llama3.2", nil)
|
||||
if content == "" {
|
||||
t.Fatal("resolveContent returned empty")
|
||||
}
|
||||
@@ -586,7 +517,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("qwen3:32b")
|
||||
content := o.resolveContent("qwen3:32b", nil)
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(content), &cfg)
|
||||
@@ -610,7 +541,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("gemma4")
|
||||
content := o.resolveContent("gemma4", nil)
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(content), &cfg)
|
||||
@@ -630,11 +561,56 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
o := &OpenCode{}
|
||||
if got := o.resolveContent(""); got != "" {
|
||||
if got := o.resolveContent("", nil); got != "" {
|
||||
t.Errorf("resolveContent(\"\") = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses run model metadata when Edit was not called", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
stateDir := filepath.Join(tmpDir, ".local", "state", "opencode")
|
||||
os.MkdirAll(stateDir, 0o755)
|
||||
state := map[string]any{
|
||||
"recent": []any{
|
||||
map[string]any{"providerID": "ollama", "modelID": "llama3.2"},
|
||||
},
|
||||
}
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("gemma4", []LaunchModel{
|
||||
{
|
||||
Name: "gemma4",
|
||||
Capabilities: []model.Capability{model.CapabilityVision},
|
||||
ContextLength: 65_536,
|
||||
MaxOutputTokens: 8_192,
|
||||
},
|
||||
})
|
||||
if content == "" {
|
||||
t.Fatal("resolveContent returned empty")
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(content), &cfg)
|
||||
provider, _ := cfg["provider"].(map[string]any)
|
||||
ollama, _ := provider["ollama"].(map[string]any)
|
||||
cfgModels, _ := ollama["models"].(map[string]any)
|
||||
entry, _ := cfgModels["gemma4"].(map[string]any)
|
||||
limit, _ := entry["limit"].(map[string]any)
|
||||
if limit["context"] != float64(65_536) || limit["output"] != float64(8_192) {
|
||||
t.Fatalf("limit = %v, want context/output from launch metadata", limit)
|
||||
}
|
||||
if _, ok := entry["modalities"].(map[string]any); !ok {
|
||||
t.Fatalf("modalities should be set from launch metadata, got %v", entry["modalities"])
|
||||
}
|
||||
if cfgModels["llama3.2"] == nil {
|
||||
t.Fatalf("state model missing from fallback config: %v", cfgModels)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not mutate configContent on fallback", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -650,7 +626,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
_ = o.resolveContent("llama3.2")
|
||||
_ = o.resolveContent("llama3.2", nil)
|
||||
if o.configContent != "" {
|
||||
t.Errorf("resolveContent should not mutate configContent, got %q", o.configContent)
|
||||
}
|
||||
@@ -659,19 +635,19 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
|
||||
func TestBuildInlineConfig(t *testing.T) {
|
||||
t.Run("returns error for empty primary", func(t *testing.T) {
|
||||
if _, err := buildInlineConfig("", []string{"llama3.2"}); err == nil {
|
||||
if _, err := buildInlineConfig(LaunchModel{}, testLaunchModels("llama3.2")); err == nil {
|
||||
t.Error("expected error for empty primary")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error for empty models", func(t *testing.T) {
|
||||
if _, err := buildInlineConfig("llama3.2", nil); err == nil {
|
||||
if _, err := buildInlineConfig(fallbackLaunchModel("llama3.2"), nil); err == nil {
|
||||
t.Error("expected error for empty models")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("primary differs from first model in list", func(t *testing.T) {
|
||||
content, err := buildInlineConfig("qwen3:32b", []string{"llama3.2", "qwen3:32b"})
|
||||
content, err := buildInlineConfig(fallbackLaunchModel("qwen3:32b"), testLaunchModels("llama3.2", "qwen3:32b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -700,7 +676,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"new-X"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("new-X")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -734,7 +710,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"X", "Y", "Z"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("X", "Y", "Z")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -771,7 +747,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"qwen3:32b"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("qwen3:32b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -808,7 +784,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -850,7 +826,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
|
||||
// Add 5 new models — should cap at 10 total
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"new-0", "new-1", "new-2", "new-3", "new-4"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("new-0", "new-1", "new-2", "new-3", "new-4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -871,7 +847,7 @@ func TestOpenCodeEdit_BaseURL(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
// Default OLLAMA_HOST
|
||||
o.Edit([]string{"llama3.2"})
|
||||
o.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(o.configContent), &cfg)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
||||
@@ -28,7 +26,7 @@ const (
|
||||
|
||||
func (p *Pi) String() string { return "Pi" }
|
||||
|
||||
func (p *Pi) Run(model string, args []string) error {
|
||||
func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := ensureNpmInstalled(); err != nil {
|
||||
return err
|
||||
@@ -183,7 +181,7 @@ func (p *Pi) Paths() []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
func (p *Pi) Edit(models []string) error {
|
||||
func (p *Pi) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -225,7 +223,7 @@ func (p *Pi) Edit(models []string) error {
|
||||
// Build set of selected models to track which need to be added
|
||||
selectedSet := make(map[string]bool, len(models))
|
||||
for _, m := range models {
|
||||
selectedSet[m] = true
|
||||
selectedSet[m.Name] = true
|
||||
}
|
||||
|
||||
// Build new models list:
|
||||
@@ -256,11 +254,9 @@ func (p *Pi) Edit(models []string) error {
|
||||
}
|
||||
|
||||
// Add newly selected models that weren't already in the list
|
||||
client := api.NewClient(envconfig.Host(), http.DefaultClient)
|
||||
ctx := context.Background()
|
||||
for _, model := range models {
|
||||
if selectedSet[model] {
|
||||
newModels = append(newModels, createConfig(ctx, client, model))
|
||||
if selectedSet[model.Name] {
|
||||
newModels = append(newModels, createConfig(model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +280,7 @@ func (p *Pi) Edit(models []string) error {
|
||||
}
|
||||
|
||||
settings["defaultProvider"] = "ollama"
|
||||
settings["defaultModel"] = models[0]
|
||||
settings["defaultModel"] = models[0].Name
|
||||
|
||||
settingsData, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
@@ -342,54 +338,27 @@ func hasContextWindow(cfg map[string]any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// createConfig builds Pi model config with capability detection
|
||||
func createConfig(ctx context.Context, client *api.Client, modelID string) map[string]any {
|
||||
// createConfig builds Pi model config with capability detection.
|
||||
func createConfig(model LaunchModel) map[string]any {
|
||||
cfg := map[string]any{
|
||||
"id": modelID,
|
||||
"id": model.Name,
|
||||
"_launch": true,
|
||||
}
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
cfg["contextWindow"] = l.Context
|
||||
}
|
||||
|
||||
applyCloudContextFallback := func() {
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
cfg["contextWindow"] = l.Context
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID})
|
||||
if err != nil {
|
||||
applyCloudContextFallback()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Set input types based on vision capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||
if model.HasCapability("vision") {
|
||||
cfg["input"] = []string{"text", "image"}
|
||||
} else {
|
||||
cfg["input"] = []string{"text"}
|
||||
}
|
||||
|
||||
// Set reasoning based on thinking capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
|
||||
if model.HasCapability("thinking") {
|
||||
cfg["reasoning"] = true
|
||||
}
|
||||
|
||||
// Extract context window from ModelInfo. For known cloud models, the
|
||||
// pre-filled shared limit remains unless the server provides a positive value.
|
||||
hasContextWindow := false
|
||||
for key, val := range resp.ModelInfo {
|
||||
if strings.HasSuffix(key, ".context_length") {
|
||||
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
|
||||
cfg["contextWindow"] = int(ctxLen)
|
||||
hasContextWindow = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasContextWindow {
|
||||
applyCloudContextFallback()
|
||||
if model.ContextLength > 0 {
|
||||
cfg["contextWindow"] = model.ContextLength
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
@@ -138,7 +135,7 @@ exit 0
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", []string{"--version"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -181,7 +178,7 @@ exit 0
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
err := p.Run("ignored", nil)
|
||||
err := p.Run("ignored", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") {
|
||||
t.Fatalf("expected install cancellation error, got %v", err)
|
||||
}
|
||||
@@ -203,7 +200,7 @@ exit 0
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -238,7 +235,7 @@ exit 0
|
||||
seedNpmNoop(t, tmpDir)
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", []string{"doctor"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"doctor"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -266,7 +263,7 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() should continue after web search update failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -301,7 +298,7 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() should continue after web search install failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -331,7 +328,7 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -360,7 +357,7 @@ exit 0
|
||||
seedPiScript(t, tmpDir)
|
||||
|
||||
p := &Pi{}
|
||||
err := p.Run("ignored", []string{"session"})
|
||||
err := p.Run("ignored", nil, []string{"session"})
|
||||
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") {
|
||||
t.Fatalf("expected missing npm error, got %v", err)
|
||||
}
|
||||
@@ -435,7 +432,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("returns nil for empty models", func(t *testing.T) {
|
||||
if err := pi.Edit([]string{}); err != nil {
|
||||
if err := pi.Edit(testLaunchModels()); err != nil {
|
||||
t.Errorf("Edit([]) error = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
@@ -444,7 +441,7 @@ func TestPiEdit(t *testing.T) {
|
||||
cleanup()
|
||||
|
||||
models := []string{"llama3.2", "qwen3:8b"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -497,7 +494,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"new-model"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -550,7 +547,7 @@ func TestPiEdit(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pi.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
if err := pi.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -595,7 +592,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
newModels := []string{"new-model-1", "new-model-2"}
|
||||
if err := pi.Edit(newModels); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -646,7 +643,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
newModels := []string{"keep-model", "add-model"}
|
||||
if err := pi.Edit(newModels); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -683,7 +680,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"test-model"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() should not fail with corrupt config, got %v", err)
|
||||
}
|
||||
|
||||
@@ -732,7 +729,7 @@ func TestPiEdit(t *testing.T) {
|
||||
|
||||
// Add a new ollama-managed model
|
||||
newModels := []string{"new-ollama-model"}
|
||||
if err := pi.Edit(newModels); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -793,7 +790,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"llama3.2"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -831,7 +828,7 @@ func TestPiEdit(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
|
||||
models := []string{"qwen3:8b"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -865,7 +862,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"test-model"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() should not fail with corrupt settings, got %v", err)
|
||||
}
|
||||
|
||||
@@ -921,7 +918,7 @@ func TestPiEdit_CreatesDistinctBackupsForEachManagedFile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pi.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := pi.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -1087,19 +1084,7 @@ func TestIsPiOllamaModel(t *testing.T) {
|
||||
|
||||
func TestCreateConfig(t *testing.T) {
|
||||
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "llava:7b")
|
||||
cfg := createConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{model.CapabilityVision}})
|
||||
|
||||
if cfg["id"] != "llava:7b" {
|
||||
t.Errorf("id = %v, want llava:7b", cfg["id"])
|
||||
@@ -1114,19 +1099,7 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "llama3.2")
|
||||
cfg := createConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{model.CapabilityCompletion}})
|
||||
|
||||
input, ok := cfg["input"].([]string)
|
||||
if !ok || len(input) != 1 || input[0] != "text" {
|
||||
@@ -1138,39 +1111,15 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "qwq")
|
||||
cfg := createConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{model.CapabilityThinking}})
|
||||
|
||||
if cfg["reasoning"] != true {
|
||||
t.Error("expected reasoning = true for thinking model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extracts context window from model info", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "llama3.2")
|
||||
t.Run("sets context window from metadata", func(t *testing.T) {
|
||||
cfg := createConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
|
||||
|
||||
if cfg["contextWindow"] != 131072 {
|
||||
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||
@@ -1178,19 +1127,11 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("handles all capabilities together", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "qwen3-vision")
|
||||
cfg := createConfig(LaunchModel{
|
||||
Name: "qwen3-vision",
|
||||
Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking},
|
||||
ContextLength: 32768,
|
||||
})
|
||||
|
||||
input := cfg["input"].([]string)
|
||||
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
|
||||
@@ -1204,17 +1145,8 @@ func TestCreateConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns minimal config when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "missing-model")
|
||||
t.Run("returns minimal config when metadata is unavailable", func(t *testing.T) {
|
||||
cfg := createConfig(LaunchModel{Name: "missing-model"})
|
||||
|
||||
if cfg["id"] != "missing-model" {
|
||||
t.Errorf("id = %v, want missing-model", cfg["id"])
|
||||
@@ -1222,49 +1154,29 @@ func TestCreateConfig(t *testing.T) {
|
||||
if cfg["_launch"] != true {
|
||||
t.Error("expected _launch = true")
|
||||
}
|
||||
// Should not have capability fields
|
||||
if _, ok := cfg["input"]; ok {
|
||||
t.Error("input should not be set when show fails")
|
||||
// Input defaults to text even when capabilities are unavailable.
|
||||
input, ok := cfg["input"].([]string)
|
||||
if !ok || len(input) != 1 || input[0] != "text" {
|
||||
t.Errorf("input = %v, want [text]", cfg["input"])
|
||||
}
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set when show fails")
|
||||
t.Error("reasoning should not be set when metadata is unavailable")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set when show fails")
|
||||
t.Error("contextWindow should not be set when metadata is unavailable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloud model falls back to hardcoded context when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "kimi-k2.5:cloud")
|
||||
t.Run("cloud model falls back to hardcoded context", func(t *testing.T) {
|
||||
cfg := createConfig(fallbackLaunchModel("kimi-k2.5:cloud"))
|
||||
|
||||
if cfg["contextWindow"] != 262_144 {
|
||||
t.Errorf("contextWindow = %v, want 262144", cfg["contextWindow"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloud model falls back to hardcoded context when show omits model info", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "glm-5:cloud")
|
||||
t.Run("cloud model uses hardcoded context when tags omit context", func(t *testing.T) {
|
||||
cfg := createConfig(fallbackLaunchModel("glm-5:cloud"))
|
||||
|
||||
if cfg["contextWindow"] != 202_752 {
|
||||
t.Errorf("contextWindow = %v, want 202752", cfg["contextWindow"])
|
||||
@@ -1272,35 +1184,14 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model with dash suffix falls back to hardcoded context", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "gpt-oss:120b-cloud")
|
||||
cfg := createConfig(fallbackLaunchModel("gpt-oss:120b-cloud"))
|
||||
|
||||
if cfg["contextWindow"] != 131_072 {
|
||||
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||
}
|
||||
})
|
||||
t.Run("skips zero context length", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "test-model")
|
||||
cfg := createConfig(LaunchModel{Name: "test-model", ContextLength: 0})
|
||||
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set for zero value")
|
||||
|
||||
@@ -29,7 +29,7 @@ func (p *Poolside) args(model string, extra []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func (p *Poolside) Run(model string, args []string) error {
|
||||
func (p *Poolside) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if poolsideGOOS == "windows" {
|
||||
return poolsideUnsupportedError()
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestPoolsideRunSetsOllamaEnv(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
|
||||
p := &Poolside{}
|
||||
if err := p.Run("qwen3.5", []string{"session"}); err != nil {
|
||||
if err := p.Run("qwen3.5", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestPoolsideRunWindowsUnsupported(t *testing.T) {
|
||||
t.Cleanup(func() { poolsideGOOS = prev })
|
||||
|
||||
p := &Poolside{}
|
||||
err := p.Run("kimi-k2.6:cloud", nil)
|
||||
err := p.Run("kimi-k2.6:cloud", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Windows unsupported error")
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
configPath := tt.checkPath(home)
|
||||
if err := tt.runner.Run("llama3.2", nil); err != nil {
|
||||
if err := tt.runner.Run("llama3.2", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
|
||||
@@ -41,6 +41,10 @@ func setTestHome(t *testing.T, dir string) {
|
||||
setLaunchTestHome(t, dir)
|
||||
}
|
||||
|
||||
func testLaunchModels(names ...string) []LaunchModel {
|
||||
return launchModelsFromNames(names)
|
||||
}
|
||||
|
||||
func SaveIntegration(appName string, models []string) error {
|
||||
return config.SaveIntegration(appName, models)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ const (
|
||||
minVSCodeVersion = "1.113"
|
||||
)
|
||||
|
||||
func (v *VSCode) Run(model string, args []string) error {
|
||||
func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
|
||||
v.checkVSCodeVersion()
|
||||
v.checkCopilotChatVersion()
|
||||
|
||||
@@ -238,7 +238,7 @@ func (v *VSCode) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VSCode) Edit(models []string) error {
|
||||
func (v *VSCode) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestVSCodeEdit(t *testing.T) {
|
||||
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
|
||||
}
|
||||
|
||||
if err := v.Edit(tt.models); err != nil {
|
||||
if err := v.Edit(launchModelsFromNames(tt.models)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
|
||||
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
|
||||
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
|
||||
|
||||
if err := v.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := v.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user