Files
multica/server/pkg/agent/models.go
DylanLi 78342a39ce MUL-3305: feat(agent): add qoder CLI as a choice of agent provider. (#2461)
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage

- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)

Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): harden Qoder ACP drain and wire project skills path

- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(qoder): add provider logo and wire MCP config into ACP sessions

- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
  the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
  config (object map) into ACP array format for session/new and
  session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
  empty/nil, and multi-server cases

Co-authored-by: Orca <help@stably.ai>

* fix(test): capture both return values from InjectRuntimeConfig in Qoder test

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): preserve remote MCP headers and promote provider errors

Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.

1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
   only forwarded url/type, so an authenticated remote MCP server looked
   configured in Multica but failed inside the Qoder session. Replace it
   with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
   use), which preserves headers as [{name, value}], sorts for
   deterministic output, and handles remote transport aliases. Fail
   closed on malformed mcp_config instead of silently dropping servers.

2. Provider failures could report as completed tasks. stderr was wired
   via io.MultiWriter and the result was only promoted to failed when
   output was empty, so a terminal upstream error (HTTP 429 / expired
   token) racing a stopReason=end_turn with text still became
   "completed". Switch to StderrPipe + an explicit copier, drain it
   (bounded by the existing grace window, since qodercli can leave a
   child holding the inherited fds) before the decision, and run the
   shared promoteACPResultOnProviderError.

Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): align ACP session handling

Co-authored-by: Orca <help@stably.ai>

* fix(agent): guard qoder late output after drain

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:55:45 +08:00

1502 lines
51 KiB
Go

package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"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"`
// 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"`
}
// ModelThinking carries the per-model reasoning/effort catalog
// surfaced by an agent runtime. Values are runtime-native — Codex
// emits "none|minimal|low|medium|high|xhigh"; 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 <value>`
// or Codex `model_reasoning_effort=<value>`); 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 (opencode, pi,
// openclaw) it shells out with caching and falls back to the static
// list on failure.
//
// For claude, codex, and opencode, the catalog is augmented with per-model
// thinking-level options discovered from the local CLI. Discovery failures
// silently leave Thinking == nil on each entry, which the UI treats as
// "no picker for this model" rather than blocking model selection.
//
// 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":
models := codexStaticModels()
annotateCodexThinking(ctx, models, executablePath)
return models, nil
case "gemini":
return geminiStaticModels(), 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 "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 "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
})
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
case providerType == "gemini":
return modelIDSet(geminiStaticModels()), 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] ||
modelIDSet(geminiStaticModels())[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-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"},
}
}
func codexStaticModels() []Model {
return []Model{
{ID: "gpt-5.5", Label: "GPT-5.5", Provider: "openai", Default: true},
{ID: "gpt-5.5-mini", Label: "GPT-5.5 mini", 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", Label: "GPT-5", Provider: "openai"},
{ID: "o3", Label: "o3", Provider: "openai"},
{ID: "o3-mini", Label: "o3-mini", Provider: "openai"},
}
}
// geminiStaticModels lists the values we pass via `gemini -m`. Gemini
// CLI has no `models list` subcommand, so dynamic discovery isn't
// possible; the next best thing is to expose the CLI's own aliases
// (auto / pro / flash / flash-lite and the `auto-gemini-*` family)
// alongside a few explicit version pins. Aliases track whatever the
// installed CLI considers current (see `resolveModel` in the CLI's
// packages/core/src/config/models.ts), so new Gemini releases light
// up without a Multica redeploy. Default is `auto` to match Google's
// recommendation — the CLI picks Pro vs Flash per task and falls back
// when quota is exhausted.
func geminiStaticModels() []Model {
return []Model{
{ID: "auto", Label: "Auto (Gemini 3)", Provider: "google", Default: true},
{ID: "auto-gemini-2.5", Label: "Auto (Gemini 2.5)", Provider: "google"},
{ID: "pro", Label: "Pro", Provider: "google"},
{ID: "flash", Label: "Flash", Provider: "google"},
{ID: "flash-lite", Label: "Flash Lite", Provider: "google"},
{ID: "gemini-3-pro-preview", Label: "Gemini 3 Pro (preview)", Provider: "google"},
{ID: "gemini-3-flash-preview", Label: "Gemini 3 Flash (preview)", Provider: "google"},
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google"},
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google"},
{ID: "gemini-2.5-flash-lite", Label: "Gemini 2.5 Flash Lite", Provider: "google"},
}
}
// 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 <id>` 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<digit>…`
// 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's
// ACPServer.new_session returns a `models` block of the same shape
// (`availableModels`/`currentModelId`) so the parsing path is shared.
//
// 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 → 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
}
// 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`. Provider-specific
// `launchArgs` select ACP mode (e.g. `acp` vs `--acp`).
func discoverACPModels(ctx context.Context, executablePath string, p acpDiscoveryProvider) ([]Model, error) {
if executablePath == "" {
executablePath = p.defaultBin
}
if _, err := exec.LookPath(executablePath); err != nil {
return []Model{}, nil
}
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)
if len(p.extraEnv) > 0 {
cmd.Env = append(os.Environ(), p.extraEnv...)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return []Model{}, nil
}
stdout, err := cmd.StdoutPipe()
if err != nil {
stdin.Close()
return []Model{}, nil
}
// 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 []Model{}, nil
}
// Ensure the child process is always reaped.
defer func() {
_ = stdin.Close()
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}()
writeACP := func(id int, method string, params map[string]any) error {
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
data = append(data, '\n')
_, err = stdin.Write(data)
return err
}
// Send initialize + session/new.
if err := writeACP(1, "initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{"name": p.clientName, "version": "0.1.0"},
"clientCapabilities": map[string]any{},
}); err != nil {
return []Model{}, nil
}
// 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 []Model{}, nil
}
defer os.RemoveAll(tmp)
if err := writeACP(2, "session/new", map[string]any{
"cwd": tmp,
"mcpServers": []any{},
}); err != nil {
return []Model{}, nil
}
// Read responses until we see the one for id=2 (session/new).
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 4*1024*1024)
deadline := time.After(12 * time.Second)
done := make(chan []Model, 1)
go func() {
defer close(done)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var env struct {
ID json.Number `json:"id"`
Result json.RawMessage `json:"result"`
}
if err := json.Unmarshal([]byte(line), &env); err != nil {
continue
}
if env.ID.String() != "2" || len(env.Result) == 0 {
continue
}
done <- parseACPSessionNewModels(env.Result)
return
}
}()
select {
case models := <-done:
if models == nil {
return []Model{}, nil
}
return models, nil
case <-deadline:
return []Model{}, nil
case <-runCtx.Done():
return []Model{}, nil
}
}
// parseACPSessionNewModels extracts the model catalog from an ACP
// `session/new` response. Both Hermes and Kimi (and any other ACP
// agent that follows the standard schema) emit:
//
// {
// "sessionId": "...",
// "models": {
// "availableModels": [
// {"modelId": "...", "name": "...", "description": "..."}
// ],
// "currentModelId": "..."
// }
// }
//
// 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
label := acpModelLabel(m.Name, modelID)
provider := ""
if idx := strings.Index(modelID, ":"); idx > 0 {
provider = modelID[:idx]
}
models = append(models, Model{
ID: modelID,
Label: label,
Provider: provider,
Default: modelID == currentModelID,
})
}
return models
}
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
}
// 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
// <blank>
// 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: "<id> - <label>". Skip the "Available models" header.
idx := strings.Index(line, " - ")
if idx <= 0 {
continue
}
id := strings.TrimSpace(line[:idx])
label := strings.TrimSpace(line[idx+3:])
if !isOpenclawIdentifier(id) {
// Reuse the identifier guard — cursor IDs are in the
// same character set (alnum + `-./_`), so anything
// that fails it is either malformed or a header line.
continue
}
if seen[id] {
continue
}
seen[id] = true
isDefault := strings.Contains(label, "default")
// Strip the "(current, default)" suffix from the display
// label since we surface that through the Default flag.
if paren := strings.Index(label, "("); paren > 0 {
label = strings.TrimSpace(label[:paren])
}
if label == "" {
label = id
}
models = append(models, Model{
ID: id,
Label: label,
Provider: "cursor",
Default: isDefault,
})
}
return models
}
// discoverOpenclawAgents enumerates the pre-registered OpenClaw
// agents (which is where model selection actually lives in the
// OpenClaw world — each agent is bound to a model at `agents add`
// time). It tries structured JSON output first, falling back to a
// conservative text parser that rejects TUI decoration and section
// headers. On any ambiguity we return an empty list and let the
// creatable dropdown handle manual entry — a silently-wrong
// enumeration would be worse than none.
func discoverOpenclawAgents(ctx context.Context, executablePath string) ([]Model, error) {
if executablePath == "" {
executablePath = "openclaw"
}
if _, err := exec.LookPath(executablePath); err != nil {
return []Model{}, nil
}
runCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Try JSON modes first. Different openclaw builds expose the
// flag under different names; trying a couple is cheap.
for _, jsonArgs := range [][]string{
{"agents", "list", "--json"},
{"agents", "list", "--output", "json"},
{"agents", "list", "-o", "json"},
} {
cmd := exec.CommandContext(runCtx, executablePath, jsonArgs...)
hideAgentWindow(cmd)
out, err := cmd.Output()
if err != nil && len(out) == 0 {
continue
}
if models, ok := parseOpenclawAgentsJSON(out); ok {
return models, nil
}
}
// Text fallback. Be strict — the default output is a decorated
// banner with box-drawing and section headers, and picking up
// the wrong tokens produces nonsense entries like "Identity:".
cmd := exec.CommandContext(runCtx, executablePath, "agents", "list")
hideAgentWindow(cmd)
out, err := cmd.Output()
if err != nil && len(out) == 0 {
return []Model{}, nil
}
return parseOpenclawAgents(string(out)), nil
}
// openclawAgentEntry is the shape parseOpenclawAgentsJSON expects
// from `openclaw agents list --json`. `id` is the routing key
// passed to `openclaw agent --agent <id>`; `name` is the human
// display label set via `openclaw agents set-identity --name` and
// is only used to enrich the dropdown label. The two are not
// interchangeable — see openclawEntriesToModels for the mapping.
// Older openclaw versions may emit only `name`; in that case we
// fall back to using it as the id for backward compatibility.
// `model` is optional and only used to enrich the dropdown label.
type openclawAgentEntry struct {
Name string `json:"name"`
ID string `json:"id"`
Model string `json:"model"`
}
// parseOpenclawAgentsJSON accepts `openclaw agents list --json`-style
// output. It handles two common shapes: a top-level array, or an
// object with an `agents` key whose value is an array. Returns
// ok=false if the input isn't valid JSON in either shape.
func parseOpenclawAgentsJSON(raw []byte) ([]Model, bool) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil, false
}
var flat []openclawAgentEntry
if err := json.Unmarshal(raw, &flat); err == nil {
return openclawEntriesToModels(flat), true
}
var wrapped struct {
Agents []openclawAgentEntry `json:"agents"`
}
if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Agents != nil {
return openclawEntriesToModels(wrapped.Agents), true
}
return nil, false
}
func openclawEntriesToModels(entries []openclawAgentEntry) []Model {
models := make([]Model, 0, len(entries))
seen := map[string]bool{}
for _, e := range entries {
// Use ID as the model identifier because openclaw resolves
// --agent by id, not by display name. Names may contain spaces
// (e.g. "Sub2API OPS") which openclaw's normalizeAgentId would
// mangle into a different string ("sub2api-ops"), causing a
// lookup miss and "no parseable output" errors.
id := e.ID
if id == "" {
id = e.Name
}
if id == "" || seen[id] {
continue
}
seen[id] = true
displayName := e.Name
if displayName == "" {
displayName = id
}
label := displayName
if e.Model != "" {
label = displayName + " (" + e.Model + ")"
}
models = append(models, Model{ID: id, Label: label, Provider: "openclaw"})
}
return models
}
// parseOpenclawAgents extracts agent names from the text output of
// `openclaw agents list`. The default CLI output is a decorated
// banner — section headers ending in `:`, box-drawing characters,
// and single-character icons — so we only accept lines that look
// like a proper `<name> <model>` row: at least two whitespace-
// separated tokens, both made of safe identifier characters, and
// neither ending in `:`. Anything else is discarded to avoid
// surfacing "Identity:" or `◇` as selectable models.
func parseOpenclawAgents(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
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
name, model := fields[0], fields[1]
if !isOpenclawIdentifier(name) || !isOpenclawIdentifier(model) {
continue
}
if seen[name] {
continue
}
seen[name] = true
models = append(models, Model{
ID: name,
Label: name + " (" + model + ")",
Provider: "openclaw",
})
}
return models
}
// isOpenclawIdentifier reports whether s looks like a valid
// agent-name or model-id token: starts with a letter, contains only
// identifier-safe characters, and isn't a section header
// (trailing colon). Rejects TUI decoration like `│`, `╭`, `◇`, `|`.
func isOpenclawIdentifier(s string) bool {
if s == "" || strings.HasSuffix(s, ":") {
return false
}
first := s[0]
if !((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z')) {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '-' || r == '_' || r == '.' || r == '/':
default:
return false
}
}
return true
}
// ── CodeBuddy model discovery ──
// codebuddyModelRe matches the `--model <model> ... Currently supported: (m1, m2, ...)`
// line in `codebuddy --help` output.
var codebuddyModelRe = regexp.MustCompile(`--model\s*<[^>]+>\s*.*?Currently supported:\s*\(([^)]+)\)`)
// discoverCodebuddyModels runs `codebuddy --help` and extracts the
// supported model list from its output. Falls back to a static list
// when the binary is missing or the output cannot be parsed.
func discoverCodebuddyModels(ctx context.Context, executablePath string) ([]Model, error) {
if executablePath == "" {
executablePath = "codebuddy"
}
if _, err := exec.LookPath(executablePath); err != nil {
return codebuddyStaticModels(), nil
}
helpOut := codebuddyHelpOutput(ctx, executablePath)
if helpOut == "" {
return codebuddyStaticModels(), nil
}
models := parseCodebuddyModels(helpOut)
if len(models) == 0 {
return codebuddyStaticModels(), nil
}
return models, nil
}
// parseCodebuddyModels extracts model IDs from codebuddy --help output.
// The help text contains a line like:
//
// --model <model> ... Currently supported: (model1, model2, ...)
//
// The first model in the list is marked as default.
func parseCodebuddyModels(helpOutput string) []Model {
match := codebuddyModelRe.FindStringSubmatch(helpOutput)
if len(match) < 2 {
return nil
}
raw := strings.Split(match[1], ",")
var models []Model
for _, s := range raw {
id := strings.TrimSpace(s)
if id == "" {
continue
}
models = append(models, Model{
ID: id,
Label: codebuddyModelLabel(id),
Provider: codebuddyModelProvider(id),
Default: len(models) == 0,
})
}
return models
}
// codebuddyModelProvider infers a provider name from a model ID prefix.
func codebuddyModelProvider(id string) string {
switch {
case strings.HasPrefix(id, "claude-"):
return "anthropic"
case strings.HasPrefix(id, "gemini-"):
return "google"
case strings.HasPrefix(id, "gpt-"):
return "openai"
case strings.HasPrefix(id, "glm-"):
return "zhipu"
case strings.HasPrefix(id, "minimax-"):
return "minimax"
case strings.HasPrefix(id, "kimi-"):
return "kimi"
case len(id) >= 3 && id[0] == 'h' && id[1] == 'y' && id[2] >= '0' && id[2] <= '9':
return "hunyuan"
case strings.HasPrefix(id, "deepseek-"):
return "deepseek"
default:
return ""
}
}
// codebuddyModelLabel generates a human-readable label from a model ID.
// Capitalizes each dash-separated part; special-cases GPT/GLM to uppercase
// and rewrites the "-ioa" suffix as "IOA".
func codebuddyModelLabel(id string) string {
parts := strings.Split(id, "-")
for i, p := range parts {
if strings.EqualFold(p, "gpt") || strings.EqualFold(p, "glm") {
parts[i] = strings.ToUpper(p)
} else if strings.EqualFold(p, "ioa") {
parts[i] = "IOA"
} else if len(p) > 0 {
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
}
return strings.Join(parts, " ")
}
// codebuddyStaticModels is the fallback catalog when dynamic discovery
// fails (binary missing, parse error, timeout).
func codebuddyStaticModels() []Model {
return []Model{
{ID: "claude-sonnet-4.6", Label: "Claude Sonnet 4.6", Provider: "anthropic", Default: true},
{ID: "claude-opus-4.7", Label: "Claude Opus 4.7", Provider: "anthropic"},
{ID: "gemini-3.1-pro", Label: "Gemini 3.1 Pro", Provider: "google"},
{ID: "gpt-5.5", Label: "GPT 5.5", Provider: "openai"},
{ID: "deepseek-v3-2-volc-ioa", Label: "Deepseek V3 2 Volc IOA", Provider: "deepseek"},
}
}