package agent import ( "bufio" "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "os" "os/exec" "regexp" "sort" "strings" "sync" "time" ) // Model describes a single LLM model exposed by an agent provider. // The dropdown groups by Provider when the ID uses the // `provider/model` form (e.g. "openai/gpt-4o" from opencode). // Default is a *display* hint: the UI badges the entry the // runtime advertises as its preferred pick (e.g. Claude Code's // shipped default, or hermes' currentModelId). It has no effect // at execution time — when agent.model is empty the daemon passes // "" to the backend so each provider's own CLI resolves its own // default, which is always closer to what the user's account / // environment actually supports than a static guess here. type Model struct { ID string `json:"id"` Label string `json:"label"` Provider string `json:"provider,omitempty"` Default bool `json:"default,omitempty"` ServiceTiers []ModelServiceTier `json:"service_tiers,omitempty"` // Thinking advertises the runtime's reasoning/effort catalog for this // model. nil means the runtime/model has no thinking-level control // (or the daemon couldn't discover one); the UI hides its picker. The // catalog is per-model because Codex's `codex debug models` is itself // per-model and Claude's `--effort` superset has known per-model gaps // (`xhigh` is Opus-only, `max` is session-only). See MUL-2339. Thinking *ModelThinking `json:"thinking,omitempty"` } // ModelServiceTier is one runtime-native execution tier advertised for a // model. ID is sent back to the provider protocol unchanged; Name and // Description are display copy owned by the runtime catalog. type ModelServiceTier struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` } // ModelThinking carries the per-model reasoning/effort catalog // surfaced by an agent runtime. Values are runtime-native — Codex can emit // "none|minimal|low|medium|high|xhigh|max|ultra"; Claude emits // "low|medium|high|xhigh|max". The frontend renders SupportedLevels // as-is so what users see matches each CLI's own UI. type ModelThinking struct { SupportedLevels []ThinkingLevel `json:"supported_levels"` // DefaultLevel is the value the runtime picks when no override is // provided. Empty means "the runtime picks, we don't know" — the // UI shows "Default" as a generic option. DefaultLevel string `json:"default_level,omitempty"` } // ThinkingLevel is one entry in a ModelThinking.SupportedLevels list. // Value is the literal token passed to the CLI (Claude `--effort ` // or Codex `model_reasoning_effort=`); Label is a display string; // Description is optional helper copy lifted from the upstream catalog // when available (Codex's `description` field). type ThinkingLevel struct { Value string `json:"value"` Label string `json:"label"` Description string `json:"description,omitempty"` } // modelCache memoizes dynamic discovery calls so repeated UI loads // don't re-shell the agent CLI. Entries expire after cacheTTL. type modelCacheEntry struct { models []Model expiresAt time.Time } var ( modelCacheMu sync.Mutex modelCache = map[string]modelCacheEntry{} ) const modelCacheTTL = 60 * time.Second // ListModels returns the models supported by the given agent provider. // For providers with a known static catalog it returns the baked-in // list; for providers with a CLI discovery mechanism (codex, opencode, // pi, openclaw) it shells out with caching and falls back where the // provider has a safe static catalog. // // For claude, codex, and opencode, the catalog is augmented with per-model // thinking-level options discovered from the local CLI. Codex discovery // failures fall back to a model + thinking snapshot; providers without a safe // fallback leave Thinking nil, which makes the UI hide the thinking picker. // // executablePath lets the caller point at a non-default binary; pass // "" to use the provider's default name on PATH. func ListModels(ctx context.Context, providerType, executablePath string) ([]Model, error) { switch providerType { case "claude": models := claudeStaticModels() annotateClaudeThinking(ctx, models, executablePath) return models, nil case "codex": return cachedDiscovery(discoveryCacheKey(providerType, executablePath), func() ([]Model, error) { return discoverCodexModels(ctx, executablePath), nil }) case "antigravity": // agy 1.0.6 added a `--model` flag plus an `agy models` catalog // command (MUL-3125). Enumerate it on demand like the other // dynamic-discovery backends. return cachedDiscovery(providerType, func() ([]Model, error) { return discoverAntigravityModels(ctx, executablePath) }) case "traecli": // Official TRAE CLI is ACP-native: it returns its model catalog from // session/new. Enumerate it on demand like the other ACP backends // (requires a logged-in traecli; falls back to manual entry on error). return cachedDiscovery(providerType, func() ([]Model, error) { return discoverTraecliModels(ctx, executablePath) }) case "cursor": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverCursorModels(ctx, executablePath) }) case "copilot": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverCopilotModels(ctx, executablePath) }) case "hermes": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverHermesModels(ctx, executablePath) }) case "kimi": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverKimiModels(ctx, executablePath) }) case "kiro": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverKiroModels(ctx, executablePath) }) case "qoder": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverQoderModels(ctx, executablePath) }) case "opencode": return cachedDiscovery(discoveryCacheKey(providerType, executablePath), func() ([]Model, error) { return discoverOpenCodeModels(ctx, executablePath) }) case "deveco": return cachedDiscovery(discoveryCacheKey(providerType, executablePath), func() ([]Model, error) { return discoverDevecoModels(ctx, executablePath) }) case "pi": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverPiModels(ctx, executablePath) }) case "openclaw": return cachedDiscovery(providerType, func() ([]Model, error) { return discoverOpenclawAgents(ctx, executablePath) }) case "codebuddy": return cachedDiscovery(providerType, func() ([]Model, error) { models, err := discoverCodebuddyModels(ctx, executablePath) if err != nil { return nil, err } annotateCodebuddyThinking(ctx, models, executablePath) return models, nil }) case "qwen": // Qwen Code has no account-independent headless model catalog. An // empty list keeps the runtime default and manual model entry available // without advertising a Token-Plan-specific model to other accounts. return []Model{}, nil case "grok": // xAI Grok Build is ACP-native (`grok agent stdio`); model catalog // comes from session/new. Falls back to a small static list so the // UI picker stays usable offline / unauthenticated. return cachedDiscovery(providerType, func() ([]Model, error) { return discoverGrokModels(ctx, executablePath) }) default: return nil, fmt.Errorf("unknown agent type: %q", providerType) } } // ModelSelectionSupported reports whether setting `agent.model` has // any effect for the given provider. Every built-in provider now honours // `opts.Model` end-to-end — Hermes routes it through the ACP // `session/set_model` RPC before each prompt; Claude / Codex / Cursor / // Gemini / Copilot / Kimi / Kiro / OpenCode / OpenClaw / Pi / Antigravity // pass it via flag or session config (Antigravity gained `--model` in agy // 1.0.6 — MUL-3125). // // The hook is retained — rather than inlining `true` at the call sites — so // a future model-less runtime can opt out in one place, which makes the UI // render a disabled "Managed by runtime" picker instead of an empty // dropdown plus a silently-ignored manual-entry field. func ModelSelectionSupported(providerType string) bool { return true } // ModelKnownIncompatibleWithProvider reports whether a saved model is a known // mismatch for a target runtime provider. For first-party providers with // maintained static catalogs, compatibility is exact: the model must be one of // the IDs that runtime advertises. Unknown/custom model strings still return // false because the UI and CLI allow manual entries and the server should not // erase values it cannot confidently classify. func ModelKnownIncompatibleWithProvider(providerType, model string) bool { model = strings.TrimSpace(model) if model == "" { return false } accepted, ok := acceptedModelIDsForProvider(providerType) if !ok { return false } if accepted[model] { return false } return isRuntimeSpecificModelID(model) } func acceptedModelIDsForProvider(providerType string) (map[string]bool, bool) { switch { case providerType == "claude": return modelIDSet(claudeStaticModels()), true case providerType == "codex": return modelIDSet(codexStaticModels()), true default: return nil, false } } func modelIDSet(models []Model) map[string]bool { out := make(map[string]bool, len(models)) for _, m := range models { out[m.ID] = true } return out } func isRuntimeSpecificModelID(model string) bool { if strings.Contains(model, "/") { return true } return modelHasKnownPrefix(model) || modelIDSet(claudeStaticModels())[model] || modelIDSet(codexStaticModels())[model] } func modelHasKnownPrefix(model string) bool { return strings.HasPrefix(model, "claude-") || strings.HasPrefix(model, "gpt-") || strings.HasPrefix(model, "gemini-") || strings.HasPrefix(model, "auto-gemini-") || isOpenAIReasoningSeriesID(model) } // cachedDiscovery invokes fn and caches the result for modelCacheTTL. // The cache is keyed on providerType only; callers that need to // distinguish discovery by host/user should include that in the key // if we ever introduce such a mode. func cachedDiscovery(key string, fn func() ([]Model, error)) ([]Model, error) { modelCacheMu.Lock() if entry, ok := modelCache[key]; ok && time.Now().Before(entry.expiresAt) { out := entry.models modelCacheMu.Unlock() return out, nil } modelCacheMu.Unlock() models, err := fn() if err != nil { return nil, err } // Don't cache an empty result. Zero models is almost always a transient // failure (discovery CLI timeout, not-logged-in, network blip) rather than // a runtime that genuinely has no models; caching it would keep the picker // blank for the full TTL even after the cause clears. Skipping the cache // lets the next request retry immediately. See #3729. if len(models) == 0 { return models, nil } modelCacheMu.Lock() modelCache[key] = modelCacheEntry{models: models, expiresAt: time.Now().Add(modelCacheTTL)} modelCacheMu.Unlock() return models, nil } func discoveryCacheKey(providerType, executablePath string) string { if executablePath == "" { return providerType } return providerType + ":" + executablePath } // ── Static catalogs ── // claudeStaticModels reflects the Claude Code CLI's accepted --model // values. Keep this list short and current; stale entries here // mislead users more than they help. Default = Sonnet because it's // the everyday workhorse (Opus is reserved for advisor-style flows). func claudeStaticModels() []Model { return []Model{ {ID: "claude-sonnet-5", Label: "Claude Sonnet 5", Provider: "anthropic"}, {ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true}, {ID: "claude-fable-5", Label: "Claude Fable 5", Provider: "anthropic"}, {ID: "claude-opus-4-8", Label: "Claude Opus 4.8", Provider: "anthropic"}, {ID: "claude-opus-4-7", Label: "Claude Opus 4.7", Provider: "anthropic"}, {ID: "claude-haiku-4-5-20251001", Label: "Claude Haiku 4.5", Provider: "anthropic"}, {ID: "claude-opus-4-6", Label: "Claude Opus 4.6", Provider: "anthropic"}, {ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", Provider: "anthropic"}, } } // codexStaticModels is the fallback for Codex versions older than 0.122.0 // and for failed/malformed `codex debug models --bundled` calls. Keep it in // sync with the visible entries in the newest locally verified bundled // catalog, plus still-common models from older Codex releases. Each entry // carries its own reasoning catalog so old/offline CLIs retain the same model // + thinking picker contract as dynamic discovery. Service tiers are // intentionally NOT guessed here: they are runtime/version/account-sensitive, // so a discovery failure hides the speed picker and fails the override closed. func codexStaticModels() []Model { // `Default` here is NOT a user-facing "default model" badge — the picker // stopped rendering that (Multica follows the CLI config when the model is // unset). It only marks the current flagship for the "default must track // the latest release" catalog guard // (TestCodexStaticModelsMatchVerifiedFallbackCatalog, // multica#2009). It is deliberately NOT used to validate effort for an // empty (follow-CLI-config) model: that config can resolve to any model, // so ValidateThinkingLevel fails an empty codex model closed rather than // borrowing this entry's catalog (which alone advertises `ultra`) — see // ValidateThinkingLevel and MUL-4347. Keep exactly one entry flagged. standardThinking := func(defaultLevel string, includeMax, includeUltra bool) *ModelThinking { levels := []ThinkingLevel{ {Value: "low", Label: "Low", Description: "Fast responses with lighter reasoning"}, {Value: "medium", Label: "Medium", Description: "Balances speed and reasoning depth for everyday tasks"}, {Value: "high", Label: "High", Description: "Greater reasoning depth for complex problems"}, {Value: "xhigh", Label: "Extra high", Description: "Extra high reasoning depth for complex problems"}, } if includeMax { levels = append(levels, ThinkingLevel{Value: "max", Label: "Max", Description: "Maximum reasoning depth for the hardest problems"}) } if includeUltra { levels = append(levels, ThinkingLevel{Value: "ultra", Label: "Ultra", Description: "Maximum reasoning with automatic task delegation"}) } return &ModelThinking{DefaultLevel: defaultLevel, SupportedLevels: levels} } gpt52Thinking := func() *ModelThinking { return &ModelThinking{ DefaultLevel: "medium", SupportedLevels: []ThinkingLevel{ {Value: "low", Label: "Low", Description: "Balances speed with some reasoning; useful for straightforward queries and short explanations"}, {Value: "medium", Label: "Medium", Description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks"}, {Value: "high", Label: "High", Description: "Maximizes reasoning depth for complex or ambiguous problems"}, {Value: "xhigh", Label: "Extra high", Description: "Extra high reasoning for complex problems"}, }, } } return []Model{ {ID: "gpt-5.6-sol", Label: "GPT-5.6-Sol", Provider: "openai", Default: true, Thinking: standardThinking("low", true, true)}, {ID: "gpt-5.6-terra", Label: "GPT-5.6-Terra", Provider: "openai", Thinking: standardThinking("medium", true, true)}, {ID: "gpt-5.6-luna", Label: "GPT-5.6-Luna", Provider: "openai", Thinking: standardThinking("medium", true, false)}, {ID: "gpt-5.5", Label: "GPT-5.5", Provider: "openai", Thinking: standardThinking("medium", false, false)}, {ID: "gpt-5.4", Label: "GPT-5.4", Provider: "openai", Thinking: standardThinking("medium", false, false)}, {ID: "gpt-5.4-mini", Label: "GPT-5.4-Mini", Provider: "openai", Thinking: standardThinking("medium", false, false)}, {ID: "gpt-5.3-codex", Label: "GPT-5.3-Codex", Provider: "openai", Thinking: standardThinking("medium", false, false)}, {ID: "gpt-5.2", Label: "GPT-5.2", Provider: "openai", Thinking: gpt52Thinking()}, } } // discoverTraecliModels spins up a throwaway `traecli acp serve --yolo` process // and parses the model catalog traecli returns from session/new (same shape as // Kiro/Qoder). The official TRAE CLI must be logged in for the catalog to be // non-empty; on any failure the caller falls back to the manual-entry field. func discoverTraecliModels(ctx context.Context, executablePath string) ([]Model, error) { return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "traecli", clientName: "multica-model-discovery", tmpdirPrefix: "multica-traecli-discovery-", acpArgs: []string{"acp", "serve", "--yolo"}, }) } // cursorStaticModels is a minimal fallback used when // `cursor-agent --list-models` isn't available (binary missing, // offline, etc). The real catalog is fetched dynamically because // Cursor's model IDs shift (e.g. `composer-2-fast`, // `claude-4.6-sonnet-medium`, `gemini-3.1-pro`) and any static // list we ship goes stale fast. func cursorStaticModels() []Model { return []Model{ {ID: "auto", Label: "Auto", Provider: "cursor", Default: true}, } } // copilotStaticModels — fallback used when GitHub Copilot CLI is // missing on PATH or the user hasn't logged in. Normal operation // goes through discoverCopilotModels(), which speaks ACP to the // CLI and gets the live catalog (including which IDs the user's // account actually has access to). This list is just a safety net // so the UI dropdown still has reasonable options when the live // query fails. // // Source: https://docs.github.com/en/copilot/reference/ai-models/supported-models // IDs use the dotted form `copilot --model ` actually accepts. func copilotStaticModels() []Model { return []Model{ // OpenAI {ID: "gpt-5.5", Label: "GPT-5.5", Provider: "openai"}, {ID: "gpt-5.4", Label: "GPT-5.4", Provider: "openai"}, {ID: "gpt-5.4-mini", Label: "GPT-5.4 mini", Provider: "openai"}, {ID: "gpt-5.3-codex", Label: "GPT-5.3-Codex", Provider: "openai"}, {ID: "gpt-5.2-codex", Label: "GPT-5.2-Codex", Provider: "openai"}, {ID: "gpt-5.2", Label: "GPT-5.2", Provider: "openai"}, {ID: "gpt-5-mini", Label: "GPT-5 mini", Provider: "openai"}, {ID: "gpt-4.1", Label: "GPT-4.1", Provider: "openai"}, // Anthropic {ID: "claude-opus-4.7", Label: "Claude Opus 4.7", Provider: "anthropic"}, {ID: "claude-sonnet-4.6", Label: "Claude Sonnet 4.6", Provider: "anthropic"}, {ID: "claude-sonnet-4.5", Label: "Claude Sonnet 4.5", Provider: "anthropic"}, {ID: "claude-haiku-4.5", Label: "Claude Haiku 4.5", Provider: "anthropic"}, } } // inferCopilotProvider tags Copilot model IDs with a vendor name so // the UI can group them. The Copilot CLI's ACP `availableModels` // payload exposes only `modelId`/`name`; the vendor is implicit in // the prefix. Returning "" leaves the entry ungrouped, which // matches what other ACP discovery paths (hermes/kimi) do for // non-prefixed IDs. // // The OpenAI reasoning series (`o1`, `o3`, `o3-mini`, `o4-mini`, // future `o5`/`o6`/…) is matched by the generic `o…` // pattern so we don't have to chase every new generation. func inferCopilotProvider(modelID string) string { switch { case strings.HasPrefix(modelID, "gpt-") || isOpenAIReasoningSeriesID(modelID): return "openai" case strings.HasPrefix(modelID, "claude-"): return "anthropic" case strings.HasPrefix(modelID, "gemini-"): return "google" case strings.HasPrefix(modelID, "grok-"): return "xai" default: return "" } } // isOpenAIReasoningSeriesID matches IDs in OpenAI's `o`-prefixed // reasoning family: lowercase `o` followed by at least one digit // and then either end-of-string or a `-` separator (e.g. `o3`, // `o3-mini`, `o4-mini-high`). Avoids false positives like // `opus-…` or random IDs that happen to start with `o`. func isOpenAIReasoningSeriesID(id string) bool { if len(id) < 2 || id[0] != 'o' { return false } i := 1 for i < len(id) && id[i] >= '0' && id[i] <= '9' { i++ } if i == 1 { return false } return i == len(id) || id[i] == '-' } // ── Dynamic discovery ── // discoverOpenCodeModels runs `opencode models --verbose` and parses its // output. The CLI prints `provider/model` rows, followed by JSON metadata // when verbose mode is enabled; we emit IDs verbatim so what the user sees // matches what `--model` accepts, and project any model `variants` into the // thinking-level picker because OpenCode's `run --variant` flag is its // provider-specific reasoning-effort surface. // On any failure (CLI missing, parse error, timeout) we fall back to // an empty list so the creatable UI still works. func discoverOpenCodeModels(ctx context.Context, executablePath string) ([]Model, error) { if executablePath == "" { executablePath = "opencode" } if _, err := exec.LookPath(executablePath); err != nil { return []Model{}, nil } // Newer opencode (1.15+) syncs its hosted free-model catalog over the // network on `opencode models`, which can take ~6s; the previous 5s cap // timed out and returned an empty list, so the runtime showed online but // the model picker was empty. See multica-ai/multica#3627. runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() cmd := exec.CommandContext(runCtx, executablePath, "models", "--verbose") hideAgentWindow(cmd) // Parse whatever the verbose command printed, even on a non-zero exit — a // stale config entry can make `opencode models` exit non-zero while still // listing the resolvable catalog (mirrors the pi path; see #3729/#3627). out, _ := cmd.Output() models := parseOpenCodeModels(string(out)) if len(models) == 0 { // Verbose yielded nothing usable (unsupported flag, error text, or an // empty list). Retry the plain command, which omits the per-model JSON // but still prints the IDs. cmd = exec.CommandContext(runCtx, executablePath, "models") hideAgentWindow(cmd) out, _ = cmd.Output() models = parseOpenCodeModels(string(out)) } if len(models) == 0 { return []Model{}, nil } return models, nil } // parseOpenCodeModels accepts the `opencode models` text output and // extracts IDs. Non-verbose output is one `provider/model` row per line. // Verbose output appends a pretty-printed JSON object after each ID; when // that object contains `variants`, each enabled variant becomes a thinking // level that the backend later passes through `opencode run --variant`. func parseOpenCodeModels(output string) []Model { lines := strings.Split(output, "\n") var models []Model indexByID := map[string]int{} for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) if line == "" { continue } id := parseOpenCodeModelIDLine(line) if id == "" { continue } idx, seen := indexByID[id] if !seen { provider := "" if slash := strings.Index(id, "/"); slash > 0 { provider = id[:slash] } idx = len(models) indexByID[id] = idx models = append(models, Model{ID: id, Label: id, Provider: provider}) } next := i + 1 for next < len(lines) && strings.TrimSpace(lines[next]) == "" { next++ } if next >= len(lines) || !strings.HasPrefix(strings.TrimSpace(lines[next]), "{") { continue } raw, resumeAt := collectOpenCodeModelJSON(lines, next) if json.Valid(raw) { annotateOpenCodeModelMetadata(&models[idx], raw) } i = resumeAt - 1 } return models } func parseOpenCodeModelIDLine(line string) string { fields := strings.Fields(line) if len(fields) == 0 { return "" } id := fields[0] if strings.HasPrefix(id, `"`) || strings.HasPrefix(id, "{") || strings.HasPrefix(id, "[") { return "" } if !strings.Contains(id, "/") { return "" } // Skip header rows such as PROVIDER/MODEL. if id == strings.ToUpper(id) { return "" } return id } func collectOpenCodeModelJSON(lines []string, start int) ([]byte, int) { var b strings.Builder for i := start; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) if i > start && parseOpenCodeModelIDLine(line) != "" { return []byte(b.String()), i } if b.Len() > 0 { b.WriteByte('\n') } b.WriteString(lines[i]) if json.Valid([]byte(b.String())) { return []byte(b.String()), i + 1 } } return []byte(b.String()), len(lines) } type opencodeModelMetadata struct { Reasoning bool `json:"reasoning"` Variants map[string]opencodeModelVariant `json:"variants"` } type opencodeModelVariant struct { Disabled bool `json:"disabled"` ReasoningEffort string `json:"reasoningEffort"` Thinking json.RawMessage `json:"thinking"` } var opencodeVariantLabel = map[string]string{ "none": "None", "minimal": "Minimal", "low": "Low", "medium": "Medium", "high": "High", "xhigh": "Extra high", "max": "Max", } var opencodeVariantOrder = map[string]int{ "none": 0, "minimal": 1, "low": 2, "medium": 3, "high": 4, "xhigh": 5, "max": 6, } func annotateOpenCodeModelMetadata(model *Model, raw []byte) { var meta opencodeModelMetadata if err := json.Unmarshal(raw, &meta); err != nil { return } if !meta.Reasoning && !openCodeVariantsLookReasoning(meta.Variants) { return } levels := openCodeThinkingLevelsFromVariants(meta.Variants) if len(levels) == 0 { return } model.Thinking = &ModelThinking{SupportedLevels: levels} } func openCodeVariantsLookReasoning(variants map[string]opencodeModelVariant) bool { for name, variant := range variants { if _, known := opencodeVariantOrder[name]; known { return true } if variant.ReasoningEffort != "" || len(variant.Thinking) > 0 { return true } } return false } func openCodeThinkingLevelsFromVariants(variants map[string]opencodeModelVariant) []ThinkingLevel { if len(variants) == 0 { return nil } values := make([]string, 0, len(variants)) for value, variant := range variants { if value == "" || variant.Disabled { continue } values = append(values, value) } sort.Slice(values, func(i, j int) bool { left, leftKnown := opencodeVariantOrder[values[i]] right, rightKnown := opencodeVariantOrder[values[j]] if leftKnown && rightKnown { return left < right } if leftKnown != rightKnown { return leftKnown } return values[i] < values[j] }) levels := make([]ThinkingLevel, 0, len(values)) for _, value := range values { label, ok := opencodeVariantLabel[value] if !ok { label = strings.Title(strings.ReplaceAll(value, "-", " ")) //nolint:staticcheck } levels = append(levels, ThinkingLevel{Value: value, Label: label}) } return levels } // discoverPiModels runs `pi --list-models` and parses its output. // Older pi versions print the list to stderr; newer versions use // stdout. We capture both and parse whichever is non-empty. func discoverPiModels(ctx context.Context, executablePath string) ([]Model, error) { if executablePath == "" { executablePath = "pi" } if _, err := exec.LookPath(executablePath); err != nil { return []Model{}, nil } // Newer pi fetches its catalog from each configured provider over the // network, so discovery time scales with provider count — a multi-provider // setup measured ~4.6-4.8s, right at the old 5s cap. When jitter pushed it // over, the daemon killed the command before it printed anything and the // model picker came back empty while the runtime stayed online. 15s matches // the opencode discovery cap (see #3729, same class as #3627). runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() cmd := exec.CommandContext(runCtx, executablePath, "--list-models") hideAgentWindow(cmd) var stderr strings.Builder cmd.Stderr = &stderr stdout, err := cmd.Output() if err != nil && len(stdout) == 0 && stderr.Len() == 0 { return []Model{}, nil } text := string(stdout) if strings.TrimSpace(text) == "" { text = stderr.String() } return parsePiModels(text), nil } // parsePiModels accepts the `pi --list-models` output. Pi historically // emitted `provider:model` per line and now emits a multi-column table // (`provider model context …`); both shapes are normalized to // `provider/model` to match opencode/UI conventions. The case-insensitive // `provider` token in column 0 is treated as the table header and skipped. func parsePiModels(output string) []Model { scanner := bufio.NewScanner(strings.NewReader(output)) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) var models []Model seen := map[string]bool{} for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // pi interleaves human-readable diagnostics with the catalog when an // agent config references stale patterns — e.g. // Warning: No models match pattern "opencode-go/mimo-v2-omni" // Skip them before field-splitting; otherwise prose tokens are coined // into bogus models like `No/models` or `Warning/`. See #3729. if isPiDiscoveryNoise(line) { continue } fields := strings.Fields(line) if len(fields) == 0 { continue } first := fields[0] if strings.EqualFold(first, "provider") { continue } var id string if strings.ContainsAny(first, ":/") { // Legacy `provider:model` format — normalize colon to slash. // Restricted to this branch so a model name with a `:` in // the table format's column 1 is not silently rewritten. id = strings.Replace(first, ":", "/", 1) } else if len(fields) >= 2 { id = first + "/" + fields[1] } else { continue } // A real id has a non-empty provider and model on both sides of the // slash. Drop anything that doesn't (e.g. a stray `something:` token), // a cheap structural backstop on top of the diagnostic filter above. if slash := strings.Index(id, "/"); slash <= 0 || slash == len(id)-1 { continue } if seen[id] { continue } seen[id] = true provider := "" if i := strings.Index(id, "/"); i > 0 { provider = id[:i] } models = append(models, Model{ID: id, Label: id, Provider: provider}) } return models } // isPiDiscoveryNoise reports whether a `pi --list-models` line is a diagnostic // message rather than a catalog row. pi prints these alongside the table when // an agent config references stale provider/model patterns, e.g. // // Warning: No models match pattern "opencode-go/mimo-v2-omni" // // The `Warning:` prefix is not guaranteed across versions, so the unmatched- // pattern message is also matched on its own. These are prose, not // `provider model` rows; without skipping them the field splitter coins bogus // models like `No/models`. See #3729. func isPiDiscoveryNoise(line string) bool { lower := strings.ToLower(line) if strings.Contains(lower, "no models match pattern") { return true } return strings.HasPrefix(lower, "warning:") || strings.HasPrefix(lower, "error:") || strings.HasPrefix(lower, "info:") } // discoverHermesModels spins up a throwaway `hermes acp` process, // drives just enough of the protocol to receive the model list // advertised in the `session/new` response, and shuts it down. The // list and the `current` flag both come from hermes' own // `_build_model_state` so whatever ~/.hermes/config.yaml resolves // to at runtime is exactly what the UI shows. // // Failure modes (hermes missing, no credentials, config resolution // error) all return an empty list so the UI falls back to the // creatable manual-entry input instead of blocking the form. func discoverHermesModels(ctx context.Context, executablePath string) ([]Model, error) { return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "hermes", clientName: "multica-model-discovery", extraEnv: []string{"HERMES_YOLO_MODE=1"}, tmpdirPrefix: "multica-hermes-discovery-", }) } // discoverKimiModels spins up a throwaway `kimi acp` process and // drives the same minimal ACP handshake as Hermes to surface the // model catalog advertised by Kimi's `session/new` response. Kimi // ≤0.28 returns a `models` block (`availableModels`/`currentModelId`); // 0.29 moved the same catalog into `configOptions` (MUL-5239). The // shared parser accepts both, so the discovery path stays identical. // // Failure modes (kimi missing, not logged in, config error) all // return an empty list so the UI falls back to manual entry. func discoverKimiModels(ctx context.Context, executablePath string) ([]Model, error) { return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "kimi", clientName: "multica-model-discovery", tmpdirPrefix: "multica-kimi-discovery-", }) } // discoverKiroModels spins up a throwaway `kiro-cli acp` process and parses // the models block Kiro returns from session/new. func discoverKiroModels(ctx context.Context, executablePath string) ([]Model, error) { return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "kiro-cli", clientName: "multica-model-discovery", tmpdirPrefix: "multica-kiro-discovery-", }) } // discoverCopilotModels spins up `copilot --acp` and reads the // `availableModels` block from session/new. The catalog is keyed // off the user's GitHub account, so this is the only way to know // which IDs they actually have access to (Pro vs Pro+ vs // Enterprise vs evaluation models). // // Falls back to copilotStaticModels() when the binary is missing // or when the ACP handshake fails (auth missing, network down, // etc.) so the UI dropdown always has something to show. // // We also tag each entry with a vendor in the Provider field — // the Copilot ACP payload doesn't include one, but the UI groups // by Provider, so deriving it from the ID prefix keeps OpenAI / // Anthropic / Gemini sections distinct. // // No extra env or permission flags are needed: discovery only // drives `initialize` + `session/new`, neither of which triggers // a tool-permission prompt — the model catalog is part of the // session/new response itself. func discoverCopilotModels(ctx context.Context, executablePath string) ([]Model, error) { models, err := discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "copilot", clientName: "multica-model-discovery", tmpdirPrefix: "multica-copilot-discovery-", acpArgs: []string{"--acp"}, }) if err != nil || len(models) == 0 { return copilotStaticModels(), nil } for i := range models { if models[i].Provider == "" { models[i].Provider = inferCopilotProvider(models[i].ID) } } return models, nil } // discoverQoderModels spins up `qodercli --yolo --acp` and parses models from session/new. func discoverQoderModels(ctx context.Context, executablePath string) ([]Model, error) { return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "qodercli", clientName: "multica-model-discovery", acpArgs: []string{"--yolo", "--acp"}, tmpdirPrefix: "multica-qoder-discovery-", }) } // acpDiscoveryProvider configures how discoverACPModels launches an // ACP-speaking agent CLI. The shared helper drives every CLI in // the same way (initialize → optional authenticate → session/new → parse // models block) — the // per-provider differences are which binary to spawn, which env // vars suppress interactive prompts during init, what argv puts // the binary into ACP server mode (most use `acp`, Copilot uses // `--acp`), and what to label temporary work directories so they're // easy to identify in logs. type acpDiscoveryProvider struct { defaultBin string clientName string extraEnv []string tmpdirPrefix string // acpArgs is the argv passed to the binary to start it in ACP // server mode. Defaults to []string{"acp"} when nil/empty. acpArgs []string // selectAuthMethod inspects the initialize response and child environment // after initialize succeeds. A non-nil selector must return one advertised // method id; its error aborts discovery before any session operation. selectAuthMethod func(json.RawMessage, []string) (string, error) // strictErrors surfaces stage-specific handshake errors to the caller. // Legacy discovery providers keep their empty-list behavior; Grok enables // this so it can log the actual fallback reason. strictErrors bool } // discoverACPModels runs the ACP handshake for any agent CLI that // implements the standard `initialize` + `session/new` flow and // advertises its model catalog in the response under // `models.availableModels` / `models.currentModelId`, or under the // newer `configOptions` list. Provider-specific `launchArgs` select // ACP mode (e.g. `acp` vs `--acp`). func discoverACPModels(ctx context.Context, executablePath string, p acpDiscoveryProvider) ([]Model, error) { fail := func(stage string, err error) ([]Model, error) { if p.strictErrors { return nil, fmt.Errorf("ACP model discovery %s failed: %w", stage, err) } return []Model{}, nil } if executablePath == "" { executablePath = p.defaultBin } if _, err := exec.LookPath(executablePath); err != nil { return fail("executable lookup", err) } runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() cmdArgs := p.acpArgs if len(cmdArgs) == 0 { cmdArgs = []string{"acp"} } cmd := exec.CommandContext(runCtx, executablePath, cmdArgs...) hideAgentWindow(cmd) childEnv := append(os.Environ(), p.extraEnv...) cmd.Env = childEnv stdin, err := cmd.StdinPipe() if err != nil { return fail("stdin setup", err) } stdout, err := cmd.StdoutPipe() if err != nil { stdin.Close() return fail("stdout setup", err) } // Discard stderr; noisy logs here don't help us and we don't // want them bleeding into the daemon log every 60s. cmd.Stderr = io.Discard if err := cmd.Start(); err != nil { return fail("process start", err) } // Ensure the child process is always reaped. defer func() { _ = stdin.Close() _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() }() scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 0, 1024*1024), 4*1024*1024) nextID := 1 requestACP := func(method string, params map[string]any) (json.RawMessage, error) { id := nextID nextID++ msg := map[string]any{ "jsonrpc": "2.0", "id": id, "method": method, "params": params, } data, err := json.Marshal(msg) if err != nil { return nil, err } data = append(data, '\n') if _, err = stdin.Write(data); err != nil { return nil, err } for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } var env struct { ID json.RawMessage `json:"id"` Result json.RawMessage `json:"result"` Error json.RawMessage `json:"error"` } if err := json.Unmarshal([]byte(line), &env); err != nil || string(env.ID) != fmt.Sprint(id) { continue } if len(env.Error) > 0 && string(env.Error) != "null" { var rpcErr struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } _ = json.Unmarshal(env.Error, &rpcErr) detail := "" if len(rpcErr.Data) > 0 && string(rpcErr.Data) != "null" { if err := json.Unmarshal(rpcErr.Data, &detail); err != nil { detail = strings.TrimSpace(string(rpcErr.Data)) } } return nil, &acpRPCError{Method: method, Code: rpcErr.Code, Message: rpcErr.Message, Data: detail} } if len(env.Result) == 0 { return nil, fmt.Errorf("response contained neither result nor error") } return env.Result, nil } if err := scanner.Err(); err != nil { return nil, err } if err := runCtx.Err(); err != nil { return nil, err } return nil, io.ErrUnexpectedEOF } // Each stage is response-driven. In particular, do not guess Grok's auth // method or enqueue session/new before initialize/authenticate succeeds. initResult, err := requestACP("initialize", map[string]any{ "protocolVersion": 1, "clientInfo": map[string]any{"name": p.clientName, "version": "0.1.0"}, "clientCapabilities": map[string]any{}, }) if err != nil { return fail("initialize", err) } // session/new requires a valid cwd — use a temp directory we // clean up afterwards, not the daemon's workdir (which might // be in the middle of another task's worktree). tmp, err := os.MkdirTemp("", p.tmpdirPrefix) if err != nil { return fail("temporary cwd", err) } defer os.RemoveAll(tmp) if p.selectAuthMethod != nil { methodID, err := p.selectAuthMethod(initResult, childEnv) if err != nil { return fail("auth method selection", err) } if _, err := requestACP("authenticate", map[string]any{ "methodId": methodID, "_meta": map[string]any{"headless": true}, }); err != nil { return fail(fmt.Sprintf("authenticate (%s)", methodID), err) } } sessionResult, err := requestACP("session/new", map[string]any{ "cwd": tmp, "mcpServers": []any{}, }) if err != nil { return fail("session/new", err) } models := parseACPSessionNewModels(sessionResult) if len(models) == 0 { // session/new succeeded but carried no catalog we recognise. This // is what upstream schema drift looks like from here (MUL-5239: // kimi 0.29 moved the catalog from `models` to `configOptions`), // and without this line it is indistinguishable from "the CLI // really has no models". Log the top-level keys only — never the // response body, which carries session ids and account-shaped data. slog.Debug("ACP model discovery found no models in session/new response", "binary", executablePath, "result_keys", strings.Join(acpResultTopLevelKeys(sessionResult), ","), ) } if models == nil { return fail("session/new model parsing", fmt.Errorf("response contained no model catalog")) } if err := runCtx.Err(); err != nil { return fail("completion", err) } return models, nil } // parseACPSessionNewModels extracts the model catalog from an ACP // `session/new` response. Hermes and older Kimi (and any other ACP // agent that follows that schema) emit: // // { // "sessionId": "...", // "models": { // "availableModels": [ // {"modelId": "...", "name": "...", "description": "..."} // ], // "currentModelId": "..." // } // } // // Newer agents advertise the same catalog through the ACP // `configOptions` list instead — see parseACPConfigOptionModels. Both // shapes are accepted: the `models` block wins when present, and // `configOptions` is consulted only when it yields nothing, so no // existing provider changes behaviour. // // Returns nil (not an empty slice) when the payload is missing so // the caller can distinguish "parsed with no models" (valid but // empty catalog) from "couldn't find the structure at all". func parseACPSessionNewModels(raw json.RawMessage) []Model { type acpModelInfo struct { ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` Name string `json:"name"` Description string `json:"description"` } var resp struct { Models struct { AvailableModels []acpModelInfo `json:"availableModels"` AvailableModelsSnake []acpModelInfo `json:"available_models"` CurrentModelID string `json:"currentModelId"` CurrentModelIDSnake string `json:"current_model_id"` } `json:"models"` } if err := json.Unmarshal(raw, &resp); err != nil { return nil } availableModels := resp.Models.AvailableModels if len(availableModels) == 0 && resp.Models.AvailableModelsSnake != nil { availableModels = resp.Models.AvailableModelsSnake } currentModelID := strings.TrimSpace(resp.Models.CurrentModelID) if currentModelID == "" { currentModelID = strings.TrimSpace(resp.Models.CurrentModelIDSnake) } models := make([]Model, 0, len(availableModels)) seen := map[string]bool{} for _, m := range availableModels { modelID := strings.TrimSpace(m.ModelID) if modelID == "" { modelID = strings.TrimSpace(m.ModelIDSnake) } if modelID == "" || seen[modelID] { continue } seen[modelID] = true models = append(models, acpModelEntry(modelID, m.Name, currentModelID)) } if len(models) > 0 { return models } if fromConfig := parseACPConfigOptionModels(raw); len(fromConfig) > 0 { return fromConfig } return models } // parseACPConfigOptionModels extracts the model catalog from the ACP // `configOptions` list returned by `session/new`. Kimi Code 0.29 dropped // the top-level `models` block in favour of this shape (MUL-5239): // // { // "sessionId": "...", // "configOptions": [ // { // "type": "select", "id": "model", "name": "Model", "category": "model", // "currentValue": "kimi-code/k3", // "options": [ // {"value": "kimi-code/k3", "name": "K3"}, // {"value": "kimi-code/kimi-for-coding", "name": "K2.7 Coding"} // ] // }, // {"id": "thinking", "category": "thought_level", ...} // ] // } // // A config option counts as the model picker when its `id` or `category` // is "model" (case-insensitive). Every other option — thinking level in // particular — is deliberately ignored: those are separate product // surfaces, and mapping them into the model dropdown would offer values // `session/set_model` cannot honour. `currentValue` marks the default. // // Returns nil when no model option is present, so the caller can keep // whatever the `models` block produced. func parseACPConfigOptionModels(raw json.RawMessage) []Model { type acpConfigChoice struct { Value string `json:"value"` Name string `json:"name"` } type acpConfigOption struct { ID string `json:"id"` Category string `json:"category"` CurrentValue string `json:"currentValue"` CurrentValueSnake string `json:"current_value"` Options []acpConfigChoice `json:"options"` } var resp struct { ConfigOptions []acpConfigOption `json:"configOptions"` ConfigOptionsSnake []acpConfigOption `json:"config_options"` } if err := json.Unmarshal(raw, &resp); err != nil { return nil } configOptions := resp.ConfigOptions if len(configOptions) == 0 { configOptions = resp.ConfigOptionsSnake } for _, opt := range configOptions { if !strings.EqualFold(strings.TrimSpace(opt.ID), "model") && !strings.EqualFold(strings.TrimSpace(opt.Category), "model") { continue } currentValue := strings.TrimSpace(opt.CurrentValue) if currentValue == "" { currentValue = strings.TrimSpace(opt.CurrentValueSnake) } models := make([]Model, 0, len(opt.Options)) seen := map[string]bool{} for _, choice := range opt.Options { modelID := strings.TrimSpace(choice.Value) if modelID == "" || seen[modelID] { continue } seen[modelID] = true models = append(models, acpModelEntry(modelID, choice.Name, currentValue)) } if len(models) > 0 { return models } } return nil } // acpModelEntry builds one dropdown entry from an ACP-advertised model id // and its display name. Provider is derived from the `provider:model` form // only — ids like kimi's `kimi-code/k3` carry no colon and stay ungrouped, // which matches how the UI renders a flat catalog. func acpModelEntry(modelID, name, currentModelID string) Model { provider := "" if idx := strings.Index(modelID, ":"); idx > 0 { provider = modelID[:idx] } return Model{ ID: modelID, Label: acpModelLabel(name, modelID), Provider: provider, Default: modelID == currentModelID, } } // acpResultTopLevelKeys returns the sorted top-level object keys of an ACP // result. Keys only, never values: enough to tell `configOptions` drift from // a genuinely empty catalog in daemon.log without logging session ids or any // other content from the upstream response. func acpResultTopLevelKeys(raw json.RawMessage) []string { var obj map[string]json.RawMessage if err := json.Unmarshal(raw, &obj); err != nil { return nil } keys := make([]string, 0, len(obj)) for k := range obj { keys = append(keys, k) } sort.Strings(keys) return keys } func acpModelLabel(name, modelID string) string { label := strings.TrimSpace(name) if label == "" || strings.EqualFold(label, "unknown") { return modelID } return label } // discoverAntigravityModels runs `agy models` and returns the catalog the // installed Antigravity CLI advertises (one display name per line). // // Unlike cursor / pi / opencode there is deliberately NO static fallback. // agy's `--model` takes the exact human display string (e.g. // "Claude Opus 4.6 (Thinking)") and silently no-ops on any value it doesn't // recognise — empty output, exit 0 — so a guessed static list would risk // offering a model the installed CLI can't honour, turning a typo into a // "successful" empty run. On any discovery failure we return an empty // catalog instead; agent.model stays unset and agy resolves its own // default. cachedDiscovery never caches empty results, so this retries on // the next request once the cause clears. func discoverAntigravityModels(ctx context.Context, executablePath string) ([]Model, error) { if executablePath == "" { executablePath = "agy" } if _, err := exec.LookPath(executablePath); err != nil { return nil, nil } // `agy models` is a local enumeration (no network round-trip), so a // short cap is plenty; keep it generous enough to absorb cold starts. runCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() cmd := exec.CommandContext(runCtx, executablePath, "models") hideAgentWindow(cmd) out, err := cmd.Output() if err != nil && len(out) == 0 { return nil, nil } return parseAntigravityModels(string(out)), nil } // parseAntigravityModels turns `agy models` output — one model display name // per line — into Model entries. The display string IS the value `--model` // expects, so ID and Label are identical and the daemon ships opts.Model // verbatim. Blank and duplicate lines are skipped. func parseAntigravityModels(output string) []Model { scanner := bufio.NewScanner(strings.NewReader(output)) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) var models []Model seen := map[string]bool{} for scanner.Scan() { name := strings.TrimSpace(scanner.Text()) if name == "" || seen[name] { continue } seen[name] = true models = append(models, Model{ ID: name, Label: name, Provider: "antigravity", }) } return models } // discoverGrokModels spins up `grok agent --always-approve stdio` and parses // the model catalog from session/new (same shape as Kiro/Qoder/Trae). Requires // an authenticated Grok CLI; on any failure falls back to grokStaticModels. func discoverGrokModels(ctx context.Context, executablePath string) ([]Model, error) { // Match the daemon's runtime launch: `--no-auto-update` (global) so a // background update check can't stall discovery. Auth is selected only // after initialize returns the methods this installed CLI actually offers. models, err := discoverACPModels(ctx, executablePath, acpDiscoveryProvider{ defaultBin: "grok", clientName: "multica-model-discovery", tmpdirPrefix: "multica-grok-discovery-", acpArgs: []string{"--no-auto-update", "agent", "--always-approve", "stdio"}, selectAuthMethod: func(initResult json.RawMessage, childEnv []string) (string, error) { return selectGrokAuthMethod(extractACPAuthMethods(initResult), envHasNonEmpty(childEnv, "XAI_API_KEY")) }, strictErrors: true, }) if err != nil || len(models) == 0 { if err != nil { slog.Debug("grok model discovery fell back to static catalog", "error", err) } return grokStaticModels(), nil } for i := range models { if models[i].Provider == "" { models[i].Provider = "xai" } } annotateGrokThinking(models) return models, nil } // grokStaticModels is the offline fallback catalog for the Grok Build CLI. // IDs match a typical signed-in `session/new` / `grok models` listing. func grokStaticModels() []Model { models := []Model{ {ID: "grok-4.5", Label: "Grok 4.5", Provider: "xai", Default: true}, {ID: "grok-composer-2.5-fast", Label: "Grok Composer 2.5 Fast", Provider: "xai"}, } annotateGrokThinking(models) return models } // annotateGrokThinking attaches only capabilities confirmed by xAI's // per-model reasoning documentation. session/new does not advertise effort // catalogs, so unknown and composer models deliberately keep Thinking nil // instead of exposing values that may fail at runtime. func annotateGrokThinking(models []Model) { for i := range models { if models[i].ID != "grok-4.5" { continue } models[i].Thinking = &ModelThinking{SupportedLevels: []ThinkingLevel{ {Value: "low", Label: "Low"}, {Value: "medium", Label: "Medium"}, {Value: "high", Label: "High"}, }} } } // discoverCursorModels runs `cursor-agent --list-models` and parses // the `id - Label` rows. Cursor's catalog changes often and ships // many variants of the same base model (thinking / fast / max // suffixes) — static baking would be obsolete within weeks. On any // failure we fall back to the minimal static catalog so the UI // stays usable when cursor-agent isn't installed on the daemon host. func discoverCursorModels(ctx context.Context, executablePath string) ([]Model, error) { if executablePath == "" { executablePath = "cursor-agent" } if _, err := exec.LookPath(executablePath); err != nil { return cursorStaticModels(), nil } // 15s to match the other network-backed discovery paths (pi/opencode/ACP); // cursor-agent fetches its frequently-changing catalog, so a tight cap can // time out and fall back to the minimal static list. See #3729. runCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() cmd := exec.CommandContext(runCtx, executablePath, "--list-models") hideAgentWindow(cmd) out, err := cmd.Output() if err != nil && len(out) == 0 { return cursorStaticModels(), nil } models := parseCursorModels(string(out)) if len(models) == 0 { return cursorStaticModels(), nil } return models, nil } // parseCursorModels extracts model IDs from `cursor-agent --list-models`. // Output format (as of cursor-agent 2026.04): // // Available models // // auto - Auto // composer-2-fast - Composer 2 Fast (current, default) // composer-2 - Composer 2 // … // // The model tagged `(default)` is surfaced as Default=true so the // UI badge points at cursor's own recommendation rather than a // hard-coded guess from our catalog. func parseCursorModels(output string) []Model { scanner := bufio.NewScanner(strings.NewReader(output)) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) var models []Model seen := map[string]bool{} for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // Row format: " -