Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions cmd/task/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1833,3 +1833,95 @@ func TestShouldSubmitInput(t *testing.T) {
})
}
}

// TestCreateModelBackendEscapeHatch covers the escape hatch that keeps
// `ty create --model` validation from breaking proxy-routed setups: when a
// project's Claude is pointed at ollama (or any other non-Anthropic backend),
// the model names are the proxy's and must not be checked against Anthropic's.
func TestCreateModelBackendEscapeHatch(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")

database, err := db.Open(dbPath)
if err != nil {
t.Fatalf("failed to open database: %v", err)
}
defer database.Close()

if err := database.CreateProject(&db.Project{Name: "stock", Path: filepath.Join(tmpDir, "stock")}); err != nil {
t.Fatalf("create stock project: %v", err)
}
if err := database.CreateProject(&db.Project{
Name: "ollama",
Path: filepath.Join(tmpDir, "ollama"),
ClaudeConfigDir: "~/.claude-ollama",
}); err != nil {
t.Fatalf("create ollama project: %v", err)
}

t.Setenv("ANTHROPIC_BASE_URL", "")

// This is the shape `ty create` builds to validate the --model flag.
probe := func(project, model string) error {
return database.ValidateTaskModel(&db.Task{Project: project, Model: model})
}

if err := probe("stock", "glm-5.2:cloud"); err == nil {
t.Error("a proxy-only model on a stock-backend project should be rejected")
}
if err := probe("ollama", "glm-5.2:cloud"); err != nil {
t.Errorf("a project with a CLAUDE_CONFIG_DIR override should skip validation: %v", err)
}
if err := probe("does-not-exist", "opuss"); err == nil {
t.Error("an unknown project must not be treated as a custom backend")
}
if err := probe("", "opuss"); err == nil {
t.Error("no project means no override, so validation still applies")
}

// An ambient ANTHROPIC_BASE_URL routes every project at a proxy.
t.Setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:11434")
if err := probe("stock", "glm-5.2:cloud"); err != nil {
t.Errorf("ANTHROPIC_BASE_URL in the environment should skip validation: %v", err)
}
}

// TestCreateModelValidation pins the rules `ty create --model` enforces: reject
// what the agent's CLI would reject, accept model IDs newer than this code, and
// never reject anything the UI picker offers.
func TestCreateModelValidation(t *testing.T) {
rejected := []struct {
executor string
model string
}{
{db.ExecutorClaude, "opuss"}, // typo
{db.ExecutorClaude, "claude"}, // the executor slug, not a model
{db.ExecutorClaude, "gpt-5"}, // another vendor
{db.ExecutorGrok, "opus"}, // claude alias on grok
{db.ExecutorCodex, "gpt-5-codex"}, // executor has no --model flag
{"", "sonnet-4"}, // default executor, bad alias
}
for _, tt := range rejected {
if err := db.ValidateModel(tt.executor, tt.model); err == nil {
t.Errorf("--model %q with executor %q should be rejected", tt.model, tt.executor)
}
}

accepted := []struct {
executor string
model string
}{
{db.ExecutorClaude, ""}, // no override
{"", ""},
{db.ExecutorClaude, "opus"},
{db.ExecutorClaude, "claude-opus-5"},
{db.ExecutorClaude, "claude-opus-9"}, // released after this code
{db.ExecutorGrok, "grok-4"},
{db.ExecutorCodex, ""}, // modelless executor, no override
}
for _, tt := range accepted {
if err := db.ValidateModel(tt.executor, tt.model); err != nil {
t.Errorf("--model %q with executor %q should be accepted: %v", tt.model, tt.executor, err)
}
}
}
24 changes: 20 additions & 4 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -775,8 +775,7 @@ Examples:
os.Exit(1)
}

// Normalize model override (empty = use Claude's global default). Any
// non-empty value is accepted; the Claude CLI validates the model name.
// Normalize model override (empty = use the agent's own default).
modelOverride = strings.TrimSpace(modelOverride)

// If project not specified, try to detect from cwd
Expand All @@ -788,6 +787,20 @@ Examples:
}
}

// Reject a model the executor's CLI would not accept. A bad override is
// otherwise invisible: the task launches, the agent rejects the flag
// inside tmux, and the card sits there looking busy. ValidateTaskModel
// skips the check when this task would be routed at a proxy (ollama and
// friends), where the model names belong to the proxy, not Anthropic.
if err := database.ValidateTaskModel(&db.Task{
Project: project,
Executor: taskExecutor,
Model: modelOverride,
}); err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}

// Generate title from body if title is empty
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) != "" {
var apiKey string
Expand Down Expand Up @@ -889,7 +902,7 @@ Examples:
createCmd.Flags().StringP("project", "p", "", "Project name (auto-detected from cwd if not specified)")
createCmd.Flags().StringP("executor", "e", "", "Task executor: claude, codex, gemini, grok, pi, opencode, openclaw (default: claude)")
createCmd.Flags().String("effort", "", "Per-task Claude effort override: low, medium, high, xhigh, max (default: Claude's global default)")
createCmd.Flags().String("model", "", "Per-task Claude model override: opus, sonnet, haiku, or a full model name (default: Claude's global default)")
createCmd.Flags().String("model", "", "Per-task model override: opus, sonnet, haiku, fable, or a full model ID like claude-opus-5 (default: the agent's own default). Claude and grok only")
createCmd.Flags().BoolP("execute", "x", false, "Queue task for immediate execution")
createCmd.Flags().Bool("dangerous", false, "Execute in dangerous mode (alias for --permission-mode dangerous)")
createCmd.Flags().String("permission-mode", "", "Permission mode: default (prompt), accept-edits (auto-accept file edits), auto (Claude Code auto mode: auto-approve safe actions, block risky ones), dangerous (skip all). Defaults to the project's setting")
Expand All @@ -905,7 +918,10 @@ Examples:
return db.EffortLevels(), cobra.ShellCompDirectiveNoFileComp
})
createCmd.RegisterFlagCompletionFunc("model", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return db.ModelOptions(), cobra.ShellCompDirectiveNoFileComp
// Complete against the models the chosen executor actually accepts, so
// `-e grok --model <tab>` doesn't offer Claude aliases.
executor, _ := cmd.Flags().GetString("executor")
return db.ModelsForExecutor(executor), cobra.ShellCompDirectiveNoFileComp
})
rootCmd.AddCommand(createCmd)

Expand Down
198 changes: 198 additions & 0 deletions internal/db/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package db

import (
"fmt"
"os"
"regexp"
"strings"
)

// Per-task model overrides pick which model an executor's CLI runs (its
// `--model` flag). An empty value means "no override": the task uses the
// agent's own default, leaving the user's global setting untouched.
//
// Overrides are validated before they are stored because a bad one is
// invisible: the task launches, the agent CLI rejects the flag inside tmux,
// and the card just sits there looking busy. An early default baked the
// executor slug "claude" into every row (see the clear-model repair in
// sqlite.go) — exactly that failure.
const (
ModelFable = "fable"
ModelOpus = "opus"
ModelSonnet = "sonnet"
ModelHaiku = "haiku"
// ModelOpusPlan is Claude Code's planning alias (Opus to plan, Sonnet to
// execute). Accepted as an override but kept out of the UI picker.
ModelOpusPlan = "opusplan"
)

// executorModelPrefix maps an executor to the ID prefix its models carry. Only
// executors listed here pass a --model flag to their CLI at all; for the rest
// (codex, gemini, pi, opencode, openclaw) a model override is dead config —
// the launch command never mentions it.
var executorModelPrefix = map[string]string{
ExecutorClaude: "claude-",
ExecutorGrok: "grok-",
}

// executorModelAliases lists the short names an executor's CLI accepts in place
// of a full model ID. Full IDs are matched by prefix + shape instead (see
// ValidateModel), so a newly released model needs no code change here; a new
// *alias* does — that's how "fable" arrived alongside opus/sonnet/haiku.
var executorModelAliases = map[string][]string{
ExecutorClaude: {ModelOpus, ModelSonnet, ModelHaiku, ModelFable, ModelOpusPlan},
// The grok CLI takes full IDs only; these are examples for help text and
// shell completion, not a closed set (any grok-* ID is accepted).
ExecutorGrok: {"grok-4", "grok-4-fast", "grok-code-fast-1"},
}

// modelIDShape matches the shape of a vendor model ID: dash-separated
// lowercase alphanumeric segments, with an optional bracketed variant suffix
// (Claude Code's 1M-context form, e.g. "claude-opus-5[1m]"). It deliberately
// says nothing about which models exist — the vendor prefix does that — so
// "claude-opus-9" passes and "claude opus" or "claude;rm -rf" does not.
var modelIDShape = regexp.MustCompile(`^[a-z0-9]+(\.[a-z0-9]+)*(-[a-z0-9]+(\.[a-z0-9]+)*)*(\[[a-z0-9]+\])?$`)

// ModelOptions returns the per-task model override aliases offered in the UI
// picker. It is a curated shortlist, not the set ValidateModel accepts: full
// model IDs and opusplan are valid overrides but not picker entries.
func ModelOptions() []string {
return []string{ModelOpus, ModelSonnet, ModelHaiku, ModelFable}
}

// ExecutorSupportsModel reports whether the executor's CLI takes a --model
// flag. An empty executor means the default (claude).
func ExecutorSupportsModel(executor string) bool {
_, ok := executorModelPrefix[resolveExecutor(executor)]
return ok
}

// ModelCapableExecutors returns the executors that accept a model override, in
// KnownExecutors display order.
func ModelCapableExecutors() []string {
var out []string
for _, e := range KnownExecutors() {
if ExecutorSupportsModel(e) {
out = append(out, e)
}
}
return out
}

// ModelsForExecutor returns the model names ty knows about for an executor, in
// display order — used for `ty create --model` shell completion and error
// messages. It is not exhaustive: any ID carrying the executor's vendor prefix
// is also accepted. Nil when the executor has no --model flag.
func ModelsForExecutor(executor string) []string {
aliases := executorModelAliases[resolveExecutor(executor)]
return append([]string(nil), aliases...)
}

// ValidateModel reports whether model is a model the executor's CLI would
// actually accept. The empty string is always valid and means "no override".
//
// A model is accepted when it is one of the executor's aliases (optionally with
// a "[1m]" variant suffix) or a well-formed ID carrying the executor's vendor
// prefix — "claude-opus-5" and "grok-4-fast" pass without ty having to track
// every release. Everything else is rejected, which catches the two mistakes
// that actually happen: a typo ("opuss") and a model belonging to some other
// vendor ("gpt-5", or the "claude" executor slug).
//
// Callers must skip this check when the agent is routed at a non-stock backend
// — a CLAUDE_CONFIG_DIR override or ANTHROPIC_BASE_URL pointing at a proxy like
// ollama — because the model names are then the proxy's (e.g. "glm-5.2:cloud")
// and ty has no way to know them. See ModelBackendIsCustom.
func ValidateModel(executor, model string) error {
model = strings.TrimSpace(model)
if model == "" {
return nil
}
executor = resolveExecutor(executor)
if !ExecutorSupportsModel(executor) {
return fmt.Errorf("the %s executor has no --model flag, so %q would be silently ignored (model overrides work with: %s)",
executor, model, strings.Join(ModelCapableExecutors(), ", "))
}
lower := strings.ToLower(model)
for _, alias := range executorModelAliases[executor] {
if lower == alias || strings.HasPrefix(lower, alias+"[") && modelIDShape.MatchString(lower) {
return nil
}
}
prefix := executorModelPrefix[executor]
if strings.HasPrefix(lower, prefix) && modelIDShape.MatchString(lower) {
return nil
}
return fmt.Errorf("unknown %s model %q — known models: %s (or any %s* model ID)",
executor, model, strings.Join(ModelsForExecutor(executor), ", "), prefix)
}

// ValidateTaskModel checks a task's model override against the executor that
// will run it, skipping the check when the task is routed at a non-stock
// backend. This is the entry point every write path should use — it resolves
// the escape hatch (per-task config dir or env, the project's config dir, an
// ambient ANTHROPIC_BASE_URL) that a bare ValidateModel call cannot see.
func (db *DB) ValidateTaskModel(t *Task) error {
if t == nil || strings.TrimSpace(t.Model) == "" {
return nil
}
if db.taskModelBackendIsCustom(t) {
return nil
}
return ValidateModel(t.Executor, t.Model)
}

// taskModelBackendIsCustom reports whether anything in a task's resolved
// configuration points Claude away from Anthropic's API: the task's own config
// dir or env, the project's config dir, or ANTHROPIC_BASE_URL in the
// environment ty itself is running in.
func (db *DB) taskModelBackendIsCustom(t *Task) bool {
if ModelBackendIsCustom(t.ClaudeConfigDir, t.EnvMap()) {
return true
}
if strings.TrimSpace(os.Getenv("ANTHROPIC_BASE_URL")) != "" {
return true
}
if t.Project == "" {
return false
}
p, err := db.GetProjectByName(t.Project)
if err != nil || p == nil {
return false
}
return ModelBackendIsCustom(p.ClaudeConfigDir, nil)
}

// ModelBackendIsCustom reports whether a Claude run is pointed at something
// other than Anthropic's API — a CLAUDE_CONFIG_DIR override or an
// ANTHROPIC_BASE_URL env override (the two ways ty routes a task through a
// proxy such as ollama). Model names are the proxy's there, so overrides must
// not be validated against Anthropic's.
func ModelBackendIsCustom(configDir string, env map[string]string) bool {
if strings.TrimSpace(configDir) != "" {
return true
}
return strings.TrimSpace(env["ANTHROPIC_BASE_URL"]) != ""
}

// IsValidModel reports whether s is an acceptable per-task model override for
// *some* executor. The empty string is valid and means "use the agent default".
// Prefer ValidateModel when the executor is known; this looser form exists for
// callers holding a remembered value with no executor in hand (the new-task
// form's per-project default).
func IsValidModel(s string) bool {
for _, e := range KnownExecutors() {
if ValidateModel(e, s) == nil {
return true
}
}
return false
}

// resolveExecutor normalizes an executor slug, mapping "" to the default.
func resolveExecutor(executor string) string {
executor = strings.ToLower(strings.TrimSpace(executor))
if executor == "" {
return DefaultExecutor()
}
return executor
}
Loading