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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions config_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions internal/api/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
24 changes: 20 additions & 4 deletions internal/api/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 20 additions & 10 deletions internal/api/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
15 changes: 11 additions & 4 deletions internal/api/models.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
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
// sentinel.
func ResolveClaudeModel(m string) string {
switch strings.ToLower(m) {
case "sonnet":
return ModelSonnet
return ModelSonnet5
case "opus":
return ModelOpus
case "haiku":
Expand All @@ -23,13 +30,13 @@ 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
}
}

func ValidClaudeModelsHelp() string {
return "auto, sonnet, opus, haiku, " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus47 + ", " + ModelHaiku
return "auto, sonnet, opus, haiku, " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku
}
17 changes: 17 additions & 0 deletions internal/api/models_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
12 changes: 7 additions & 5 deletions internal/tui/tui_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
48 changes: 48 additions & 0 deletions internal/tui/tui_backend_cc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions main_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion resolve_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
Loading