diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d98910..a776b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to qmax-code. Versions follow [Semantic Versioning](https:// ## [Unreleased] +### Added +- Added Claude Fable 5 and Claude Sonnet 5 to the Claude Code `/orch` model + picker, with Sonnet 5 as the default Claude Code row. Direct API picker + unchanged; CC number shortcuts 1–5 now map to Fable 5, Sonnet 5, and Opus + variants (Sonnet 4.6 and Haiku 4.5 remain selectable without shortcuts). + +### Changed +- `sonnet` shorthand and `-model claude-sonnet-5` / `claude-fable-5` now + resolve through the shared model registry; Sonnet 5 intro pricing ($2/$10 + MTok through Aug 31, 2026) is reflected in `/cost` and `/status`. + ## [1.20.4] - 2026-06-30 ### Fixed diff --git a/config_command_test.go b/config_command_test.go index 5c3b7c4..9b21212 100644 --- a/config_command_test.go +++ b/config_command_test.go @@ -144,8 +144,8 @@ func TestSetConfigField_DefaultModelValidation(t *testing.T) { t.Fatalf("expected shorthand model to be accepted: %v", err) } loaded := api.LoadQMaxCodeConfig() - if loaded.DefaultModel != api.ModelSonnet { - t.Errorf("DefaultModel: got %q, want %q", loaded.DefaultModel, api.ModelSonnet) + if loaded.DefaultModel != api.ModelSonnet5 { + t.Errorf("DefaultModel: got %q, want %q", loaded.DefaultModel, api.ModelSonnet5) } if err := setConfigField("default_model", "claude-future-model-9-0"); err == nil { diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index 2f6b6f8..d24ed6d 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -4,8 +4,11 @@ package api const ( AnthropicMessagesURL = "https://api.anthropic.com/v1/messages" AnthropicVersion = "2023-06-01" + ModelFable = "claude-fable-5" ModelHaiku = "claude-haiku-4-5-20251001" + ModelSonnet5 = "claude-sonnet-5" ModelSonnet = "claude-sonnet-4-6" ModelOpus = "claude-opus-4-8" // latest Opus; the "opus" shorthand resolves here + ModelOpus1M = ModelOpus + "[1m]" // Claude Code 1M-context selector ModelOpus47 = "claude-opus-4-7" // prior Opus, still selectable by full ID ) diff --git a/internal/api/context.go b/internal/api/context.go index b0afc86..fff0961 100644 --- a/internal/api/context.go +++ b/internal/api/context.go @@ -74,22 +74,38 @@ func (u *TokenUsage) TotalTokens() int { // EstimatedCost returns estimated cost in USD, based on standard per-MTok API // rates (https://platform.claude.com/docs/en/about-claude/pricing). The 1M -// context window bills at these same standard rates — there is no >200K -// premium tier for Opus 4.6+/Sonnet 4.6 — so a flat rate per model is correct. -// Pricing: Opus 4.6/4.7/4.8 input=$5/MTok output=$25/MTok, Sonnet 4.6 input=$3/MTok output=$15/MTok, Haiku 4.5 input=$1/MTok output=$5/MTok +// context window bills at these same standard rates, so a flat rate per model +// is correct. Pricing: Fable 5 input=$10/MTok output=$50/MTok, +// Opus 4.6/4.7/4.8 input=$5/MTok output=$25/MTok, Sonnet 4.6 input=$3/MTok +// output=$15/MTok, Sonnet 5 intro $2/$10 through Aug 31 2026 then $3/$15, +// Haiku 4.5 input=$1/MTok output=$5/MTok. func (u *TokenUsage) EstimatedCost(model string) float64 { + return u.estimatedCostAt(model, time.Now()) +} + +func (u *TokenUsage) estimatedCostAt(model string, now time.Time) float64 { var inputRate, outputRate float64 switch { + case strings.Contains(model, "fable"): + inputRate, outputRate = 10.0, 50.0 case strings.Contains(model, "opus"): inputRate, outputRate = 5.0, 25.0 case strings.Contains(model, "haiku"): inputRate, outputRate = 1.0, 5.0 default: // sonnet - inputRate, outputRate = 3.0, 15.0 + inputRate, outputRate = sonnetCostRates(model, now) } return (float64(u.InputTokens)/1_000_000)*inputRate + (float64(u.OutputTokens)/1_000_000)*outputRate } +func sonnetCostRates(model string, now time.Time) (inputRate, outputRate float64) { + inputRate, outputRate = 3.0, 15.0 + if strings.Contains(model, "sonnet-5") && now.Before(sonnet5StandardPricingStart) { + inputRate, outputRate = 2.0, 10.0 + } + return inputRate, outputRate +} + // LoadQMaxConfig reads the qmax CLI config file. // Returns an empty config if the file doesn't exist — the user can log in via the qmax CLI. func LoadQMaxConfig() QMaxConfig { diff --git a/internal/api/context_test.go b/internal/api/context_test.go index fdf198d..20ea3fe 100644 --- a/internal/api/context_test.go +++ b/internal/api/context_test.go @@ -3,30 +3,40 @@ package api import ( "math" "testing" + "time" ) // TestEstimatedCost locks in the standard per-MTok rates so the /cost and // /status displays stay accurate. Rates per // https://platform.claude.com/docs/en/about-claude/pricing — Opus 4.6+ is // $5/$25 (not the retired Opus 4.1 $15/$75), Haiku 4.5 is $1/$5 (not the -// retired Haiku 3 $0.25/$1.25), Sonnet 4.6 is $3/$15. The 1M context window -// bills at these same rates, so model substring alone determines the rate. +// retired Haiku 3 $0.25/$1.25), Fable 5 is $10/$50, Sonnet 4.6 is $3/$15, +// and Sonnet 5 is $2/$10 intro through Aug 31 2026 then $3/$15. The 1M +// context window bills at these same rates, so model substring alone +// determines the rate. func TestEstimatedCost(t *testing.T) { + introSonnet5 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + standardSonnet5 := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) + cases := []struct { model string + now time.Time want float64 // cost of 1M input + 1M output tokens }{ - {"claude-opus-4-8", 5.0 + 25.0}, - {"claude-opus-4-8[1m]", 5.0 + 25.0}, // 1M variant bills at standard rate - {"claude-opus-4-7", 5.0 + 25.0}, - {"claude-sonnet-4-6", 3.0 + 15.0}, - {"claude-haiku-4-5-20251001", 1.0 + 5.0}, - {"auto", 3.0 + 15.0}, // unknown → sonnet default + {"claude-fable-5", introSonnet5, 10.0 + 50.0}, + {"claude-opus-4-8", introSonnet5, 5.0 + 25.0}, + {"claude-opus-4-8[1m]", introSonnet5, 5.0 + 25.0}, // 1M variant bills at standard rate + {"claude-opus-4-7", introSonnet5, 5.0 + 25.0}, + {"claude-sonnet-5", introSonnet5, 2.0 + 10.0}, + {"claude-sonnet-5", standardSonnet5, 3.0 + 15.0}, + {"claude-sonnet-4-6", introSonnet5, 3.0 + 15.0}, + {"claude-haiku-4-5-20251001", introSonnet5, 1.0 + 5.0}, + {"auto", introSonnet5, 3.0 + 15.0}, // unknown → sonnet default } for _, c := range cases { u := TokenUsage{InputTokens: 1_000_000, OutputTokens: 1_000_000} - if got := u.EstimatedCost(c.model); math.Abs(got-c.want) > 1e-9 { - t.Errorf("EstimatedCost(%q) = %v, want %v", c.model, got, c.want) + if got := u.estimatedCostAt(c.model, c.now); math.Abs(got-c.want) > 1e-9 { + t.Errorf("estimatedCostAt(%q, %v) = %v, want %v", c.model, c.now, got, c.want) } } } diff --git a/internal/api/models.go b/internal/api/models.go index f9e3b93..96d3349 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -1,6 +1,13 @@ package api -import "strings" +import ( + "strings" + "time" +) + +// sonnet5StandardPricingStart is when Claude Sonnet 5 intro pricing ($2/$10 +// MTok) ends and standard pricing ($3/$15) begins. +var sonnet5StandardPricingStart = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) // ResolveClaudeModel expands user-facing shorthand names to concrete // Anthropic model IDs. "auto" is preserved as qmax-code's smart-routing @@ -8,7 +15,7 @@ import "strings" func ResolveClaudeModel(m string) string { switch strings.ToLower(m) { case "sonnet": - return ModelSonnet + return ModelSonnet5 case "opus": return ModelOpus case "haiku": @@ -23,7 +30,7 @@ func ResolveClaudeModel(m string) string { // instead of being forwarded to the Anthropic API or Claude Code. func IsValidClaudeModelName(m string) bool { switch ResolveClaudeModel(m) { - case "auto", ModelSonnet, ModelOpus, ModelOpus47, ModelHaiku: + case "auto", ModelFable, ModelSonnet5, ModelSonnet, ModelOpus, ModelOpus1M, ModelOpus47, ModelHaiku: return true default: return false @@ -31,5 +38,5 @@ func IsValidClaudeModelName(m string) bool { } func ValidClaudeModelsHelp() string { - return "auto, sonnet, opus, haiku, " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus47 + ", " + ModelHaiku + return "auto, sonnet, opus, haiku, " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku } diff --git a/internal/api/models_test.go b/internal/api/models_test.go new file mode 100644 index 0000000..72dd327 --- /dev/null +++ b/internal/api/models_test.go @@ -0,0 +1,17 @@ +package api + +import "testing" + +func TestResolveClaudeModelSonnetShorthandUsesSonnet5(t *testing.T) { + if got := ResolveClaudeModel("sonnet"); got != ModelSonnet5 { + t.Errorf("ResolveClaudeModel(sonnet) = %q, want %q", got, ModelSonnet5) + } +} + +func TestIsValidClaudeModelNameIncludesFableAndSonnet5(t *testing.T) { + for _, model := range []string{"sonnet", ModelFable, ModelSonnet5, ModelOpus1M} { + if !IsValidClaudeModelName(model) { + t.Errorf("IsValidClaudeModelName(%q) = false, want true", model) + } + } +} diff --git a/internal/tui/tui_backend.go b/internal/tui/tui_backend.go index 2f728ea..4d5410c 100644 --- a/internal/tui/tui_backend.go +++ b/internal/tui/tui_backend.go @@ -128,11 +128,13 @@ type pickerEntry struct { } var ccModels = []pickerEntry{ - {backend: "cc", modelID: "claude-opus-4-8[1m]", label: "Opus 4.8", subLabel: "1M ctx", isNew: true, shortcut: '1'}, - {backend: "cc", modelID: "claude-opus-4-8", label: "Opus 4.8", isNew: true, shortcut: '2'}, - {backend: "cc", modelID: "claude-opus-4-7", label: "Opus 4.7", subLabel: "1M ctx", shortcut: '3'}, - {backend: "cc", modelID: "claude-sonnet-4-6", label: "Sonnet 4.6", isFav: true, shortcut: '4'}, - {backend: "cc", modelID: "claude-haiku-4-5-20251001", label: "Haiku 4.5", shortcut: '5'}, + {backend: "cc", modelID: api.ModelFable, label: "Fable 5", subLabel: "1M ctx · long agents", isNew: true, shortcut: '1'}, + {backend: "cc", modelID: api.ModelSonnet5, label: "Sonnet 5", subLabel: "1M ctx", isNew: true, isFav: true, shortcut: '2'}, + {backend: "cc", modelID: api.ModelOpus1M, label: "Opus 4.8", subLabel: "1M ctx", shortcut: '3'}, + {backend: "cc", modelID: api.ModelOpus, label: "Opus 4.8", shortcut: '4'}, + {backend: "cc", modelID: api.ModelOpus47, label: "Opus 4.7", subLabel: "1M ctx", shortcut: '5'}, + {backend: "cc", modelID: api.ModelSonnet, label: "Sonnet 4.6"}, + {backend: "cc", modelID: api.ModelHaiku, label: "Haiku 4.5"}, } var codexModels = []pickerEntry{ diff --git a/internal/tui/tui_backend_cc_test.go b/internal/tui/tui_backend_cc_test.go new file mode 100644 index 0000000..898764c --- /dev/null +++ b/internal/tui/tui_backend_cc_test.go @@ -0,0 +1,48 @@ +package tui + +import ( + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +func TestPickerIncludesClaudeCodeFableAndSonnet5(t *testing.T) { + m := newModelPickerModel("cc", "", "high", "", "", true, true, false) + + seen := map[string]pickerEntry{} + for _, e := range m.allEntries { + if e.backend == "cc" { + seen[e.modelID] = e + } + } + + fable, ok := seen[api.ModelFable] + if !ok { + t.Fatalf("Claude Code picker missing %s", api.ModelFable) + } + if fable.label != "Fable 5" { + t.Errorf("Fable label = %q, want Fable 5", fable.label) + } + if fable.subLabel != "1M ctx · long agents" { + t.Errorf("Fable subLabel = %q, want 1M ctx · long agents", fable.subLabel) + } + + sonnet, ok := seen[api.ModelSonnet5] + if !ok { + t.Fatalf("Claude Code picker missing %s", api.ModelSonnet5) + } + if sonnet.label != "Sonnet 5" { + t.Errorf("Sonnet label = %q, want Sonnet 5", sonnet.label) + } + if !sonnet.isFav { + t.Error("Sonnet 5 should be the default Claude Code picker row") + } +} + +func TestPickerClaudeCodeDefaultCursorOnSonnet5(t *testing.T) { + m := newModelPickerModel("cc", "", "high", "", "", true, true, false) + cur := m.allEntries[m.cursor] + if cur.backend != "cc" || cur.modelID != api.ModelSonnet5 { + t.Errorf("cursor on %s/%s, want cc/%s", cur.backend, cur.modelID, api.ModelSonnet5) + } +} diff --git a/main_validation_test.go b/main_validation_test.go index d420388..da64eb0 100644 --- a/main_validation_test.go +++ b/main_validation_test.go @@ -19,6 +19,8 @@ func TestIsValidModelName(t *testing.T) { // Real model IDs from api.Model* constants. {"claude-opus-4-8", true}, {"claude-opus-4-7", true}, + {"claude-sonnet-5", true}, + {"claude-fable-5", true}, {"claude-sonnet-4-6", true}, {"claude-haiku-4-5-20251001", true}, // Rejected. diff --git a/resolve_model_test.go b/resolve_model_test.go index c5a9b83..e90c182 100644 --- a/resolve_model_test.go +++ b/resolve_model_test.go @@ -9,7 +9,7 @@ import ( func TestResolveModelUsesCentralModelConstants(t *testing.T) { cases := map[string]string{ "haiku": api.ModelHaiku, - "sonnet": api.ModelSonnet, + "sonnet": api.ModelSonnet5, "opus": api.ModelOpus, "custom": "custom", }