From 7a2306087bcbea05b498370fc58f335ea15bc103 Mon Sep 17 00:00:00 2001 From: Eva Ho Date: Thu, 26 Mar 2026 19:55:13 -0400 Subject: [PATCH] wip --- cmd/launch/vscode.go | 82 ++++++++++++++++++++++----- cmd/launch/vscode_test.go | 113 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 19 deletions(-) diff --git a/cmd/launch/vscode.go b/cmd/launch/vscode.go index 33de0f68b..40f724859 100644 --- a/cmd/launch/vscode.go +++ b/cmd/launch/vscode.go @@ -402,22 +402,25 @@ func (v *VSCode) ShowInModelPicker(models []string) error { // Build name→ID map from VS Code's cached model list. // VS Code uses numeric IDs like "ollama/Ollama/4", not "ollama/Ollama/kimi-k2.5:cloud". nameToID := make(map[string]string) + var cached []map[string]any var cacheJSON string if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&cacheJSON); err == nil { - var cached []map[string]any - if json.Unmarshal([]byte(cacheJSON), &cached) == nil { - for _, entry := range cached { - meta, _ := entry["metadata"].(map[string]any) - if meta == nil { - continue - } - if vendor, _ := meta["vendor"].(string); vendor == "ollama" { - name, _ := meta["name"].(string) - id, _ := entry["identifier"].(string) - if name != "" && id != "" { - nameToID[name] = id - } - } + _ = json.Unmarshal([]byte(cacheJSON), &cached) + } + cachedNames := make(map[string]bool) + for _, entry := range cached { + meta, _ := entry["metadata"].(map[string]any) + if meta == nil { + continue + } + if vendor, _ := meta["vendor"].(string); vendor == "ollama" { + name, _ := meta["name"].(string) + id, _ := entry["identifier"].(string) + if name != "" && id != "" { + nameToID[name] = id + } + if name != "" { + cachedNames[name] = true } } } @@ -454,10 +457,56 @@ func (v *VSCode) ShowInModelPicker(models []string) error { } } + // Ensure configured models exist in the cached model list so VS Code can + // restore the selection immediately on startup, before extensions load. + // Without this, a model that was never previously used won't be in the + // cache, and VS Code falls back to "auto" until the Ollama BYOK provider + // discovers it via the API (which is slow). + cacheChanged := false + for _, m := range models { + if cachedNames[m] { + continue + } + if !strings.Contains(m, ":") && cachedNames[m+":latest"] { + continue + } + cacheID := m + if !strings.Contains(m, ":") { + cacheID = m + ":latest" + } + cached = append(cached, map[string]any{ + "identifier": "ollama/Ollama/" + cacheID, + "metadata": map[string]any{ + "extension": map[string]any{"value": "github.copilot-chat"}, + "name": m, + "id": m, + "vendor": "ollama", + "version": "1.0.0", + "family": m, + "detail": "Ollama", + "maxInputTokens": 4096, + "maxOutputTokens": 4096, + "isDefaultForLocation": map[string]any{}, + "isUserSelectable": true, + "capabilities": map[string]any{"toolCalling": true}, + }, + }) + cacheChanged = true + } + if cacheChanged { + cacheData, _ := json.Marshal(cached) + if _, err := db.Exec("INSERT OR REPLACE INTO ItemTable (key, value) VALUES ('chat.cachedLanguageModels.v2', ?)", string(cacheData)); err != nil { + return err + } + } + return nil } // modelVSCodeIDs returns all possible VS Code picker IDs for a model name. +// The primary (first) ID should match the live identifier that VS Code assigns +// at runtime via toModelIdentifier(vendor, group, m.id), where m.id comes from +// /api/tags and always includes the tag (e.g. "llama3.2:latest"). func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []string { var ids []string if id, ok := nameToID[model]; ok { @@ -467,10 +516,13 @@ func (v *VSCode) modelVSCodeIDs(model string, nameToID map[string]string) []stri ids = append(ids, id) } } - ids = append(ids, "ollama/Ollama/"+model) + // For untagged models, the live identifier includes :latest + // (e.g. ollama/Ollama/llama3.2:latest), so prefer that format + // to avoid a mismatch that causes VS Code to reset to "auto". if !strings.Contains(model, ":") { ids = append(ids, "ollama/Ollama/"+model+":latest") } + ids = append(ids, "ollama/Ollama/"+model) return ids } diff --git a/cmd/launch/vscode_test.go b/cmd/launch/vscode_test.go index 2561d7ca6..d3e6bf50c 100644 --- a/cmd/launch/vscode_test.go +++ b/cmd/launch/vscode_test.go @@ -416,12 +416,12 @@ func TestShowInModelPicker(t *testing.T) { dbPath := testVSCodePath(t, tmpDir, filepath.Join("globalStorage", "state.vscdb")) panelModel := readValue(t, dbPath, "chat.currentLanguageModel.panel") - if panelModel != "ollama/Ollama/llama3.2" { - t.Errorf("expected panel model ollama/Ollama/llama3.2, got %q", panelModel) + if panelModel != "ollama/Ollama/llama3.2:latest" { + t.Errorf("expected panel model ollama/Ollama/llama3.2:latest, got %q", panelModel) } editorModel := readValue(t, dbPath, "chat.currentLanguageModel.editor") - if editorModel != "ollama/Ollama/llama3.2" { - t.Errorf("expected editor model ollama/Ollama/llama3.2, got %q", editorModel) + if editorModel != "ollama/Ollama/llama3.2:latest" { + t.Errorf("expected editor model ollama/Ollama/llama3.2:latest, got %q", editorModel) } panelDefault := readValue(t, dbPath, "chat.currentLanguageModel.panel.isDefault") if panelDefault != "false" { @@ -473,6 +473,111 @@ func TestShowInModelPicker(t *testing.T) { t.Error("expected llama3.2 to be re-shown") } }) + + // helper to read and parse the cached models from the state DB + readCache := func(t *testing.T, dbPath string) []map[string]any { + t.Helper() + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var raw string + if err := db.QueryRow("SELECT value FROM ItemTable WHERE key = 'chat.cachedLanguageModels.v2'").Scan(&raw); err != nil { + return nil + } + var result []map[string]any + _ = json.Unmarshal([]byte(raw), &result) + return result + } + + t.Run("adds uncached model to cache for instant startup display", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + // No seed cache — model has never been used in VS Code before + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, nil) + + err := v.ShowInModelPicker([]string{"qwen3:8b"}) + if err != nil { + t.Fatal(err) + } + + cache := readCache(t, dbPath) + if len(cache) != 1 { + t.Fatalf("expected 1 cached entry, got %d", len(cache)) + } + entry := cache[0] + if id, _ := entry["identifier"].(string); id != "ollama/Ollama/qwen3:8b" { + t.Errorf("expected identifier ollama/Ollama/qwen3:8b, got %q", id) + } + meta, _ := entry["metadata"].(map[string]any) + if meta == nil { + t.Fatal("expected metadata in cache entry") + } + if v, _ := meta["vendor"].(string); v != "ollama" { + t.Errorf("expected vendor ollama, got %q", v) + } + if sel, ok := meta["isUserSelectable"].(bool); !ok || !sel { + t.Error("expected isUserSelectable to be true") + } + }) + + t.Run("does not duplicate already-cached model", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + cache := []map[string]any{ + { + "identifier": "ollama/Ollama/4", + "metadata": map[string]any{"vendor": "ollama", "name": "llama3.2"}, + }, + { + "identifier": "copilot/copilot/auto", + "metadata": map[string]any{"vendor": "copilot", "name": "Auto"}, + }, + } + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache) + + err := v.ShowInModelPicker([]string{"llama3.2"}) + if err != nil { + t.Fatal(err) + } + + // Cache should still have exactly 2 entries (no duplicate added) + result := readCache(t, dbPath) + if len(result) != 2 { + t.Errorf("expected 2 cached entries (no duplicate), got %d", len(result)) + } + }) + + t.Run("adds only missing models to existing cache", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("XDG_CONFIG_HOME", "") + cache := []map[string]any{ + { + "identifier": "ollama/Ollama/4", + "metadata": map[string]any{"vendor": "ollama", "name": "llama3.2"}, + }, + } + dbPath := setupDB(t, testVSCodePath(t, tmpDir, ""), nil, cache) + + // llama3.2 is cached, qwen3:8b is not + err := v.ShowInModelPicker([]string{"llama3.2", "qwen3:8b"}) + if err != nil { + t.Fatal(err) + } + + result := readCache(t, dbPath) + if len(result) != 2 { + t.Fatalf("expected 2 cached entries, got %d", len(result)) + } + // Second entry should be the newly added qwen3:8b + if id, _ := result[1]["identifier"].(string); id != "ollama/Ollama/qwen3:8b" { + t.Errorf("expected new entry ollama/Ollama/qwen3:8b, got %q", id) + } + }) } func TestParseCopilotChatVersion(t *testing.T) {