diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca99ab7..98694f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,9 +22,9 @@ jobs: run: go build ./... - name: Lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest + # Build with the project's Go toolchain; the v1 release binary was + # built with Go 1.24 and cannot analyze this module's Go 1.25 target. + run: go run github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8 run - name: Vet run: go vet ./... diff --git a/AGENTS.md b/AGENTS.md index e24e139..61384cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ standalone on a local repository or connected to QualityMax. Through `/orch` it can launch Claude Code, Codex, or OpenCode while keeping qmax tools and terminal UX — or run the built-in loop on Anthropic, Cerebras, or Ollama. -This repository is **Go 1.24+**, not TypeScript. The thing users install is one +This repository is **Go 1.25.13+**, not TypeScript. The thing users install is one compiled binary. ## Positioning diff --git a/CLAUDE.md b/CLAUDE.md index 3a6b92b..2cd8afd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -qmax-code is a **Go** terminal **QA** agent (Go 1.24+). It ships as one +qmax-code is a **Go** terminal **QA** agent (Go 1.25.13+). It ships as one compiled binary: `curl | bash` installs a file, not a Node or Python runtime. Claude Code, Codex, and OpenCode are separate subprocesses that `/orch` can host; they are not this repository's runtime. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6fb6235..22f39fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ Thanks for your interest in improving `qmax-code`. This document covers everythi | Requirement | Notes | |---|---| -| **Go 1.24+** | See `go.mod` for the exact version. `go version` to check. | +| **Go 1.25.13+** | See `go.mod` for the exact version. `go version` to check. | | **Inference backend** | The direct API path needs an Anthropic key. You can instead develop against a logged-in Claude Code/Codex CLI, an enabled OpenCode provider, Cerebras, or Ollama. | | **QualityMax account** | Required only for connected cloud tools (test generation, crawl, repo review). Standalone `--local` development and most unit tests do not need one. | diff --git a/README.md b/README.md index c4cc60b..8f996a3 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,7 @@ go build -o qmax-code . ./qmax-code --version ``` -Go 1.24 or newer is required for source builds. +Go 1.25.13 or newer is required for source builds. ## Why Go @@ -334,7 +334,7 @@ troubleshooting. | Backend | Select with | Authentication | Notes | | --- | --- | --- | --- | | Anthropic API | `/api` or `/orch` | `ANTHROPIC_API_KEY` or OS keychain | Built-in agent loop; tool set follows connected vs. standalone mode. | -| Claude Code | `/cc` or `/orch` | Local Claude Code login | CLI subprocess; qmax tools arrive through MCP. Agent SDK usage may be separately metered by Anthropic. | +| Claude Code | `/cc` or `/orch` | Local Claude Code login | CLI subprocess; qmax tools arrive through MCP. Uses Claude Code authentication and plan/provider billing; see [subscription setup](docs/ORCHESTRATION.md#claude-code-subscription-billing). | | Codex | `/codex` or `/orch` | Local Codex login | CLI subprocess using the user's OpenAI access; qmax tools arrive through MCP. | | Cerebras | `/gemma`, `/orch`, or `--backend cerebras` | `CEREBRAS_API_KEY` or OS keychain | Built-in native function calling. Fast inference (~1000–2000+ tok/s): GPT-OSS 120B, GLM 4.7, Gemma 4 (vision + effort). **Qwen 3.8 coming soon.** | | OpenCode | `/opencode` or `/orch` | Per-provider key in OS keychain | CLI subprocess for opt-in Z.AI, Groq, and OpenRouter providers. | diff --git a/codexrunner/model.go b/codexrunner/model.go index 4517e0e..3d49a0a 100644 --- a/codexrunner/model.go +++ b/codexrunner/model.go @@ -14,6 +14,7 @@ var ( ErrInvalidModel = errors.New("codex runner: invalid model") supportedModels = [...]string{ + "gpt-6-astra", "gpt-5.6-sol", DefaultModel, "gpt-5.6-luna", diff --git a/docs/ORCHESTRATION.md b/docs/ORCHESTRATION.md index e691aa7..cd5e20f 100644 --- a/docs/ORCHESTRATION.md +++ b/docs/ORCHESTRATION.md @@ -56,6 +56,86 @@ QualityMax authentication is separate from backend authentication. Use `--local` to skip QualityMax authentication entirely. QualityMax cloud tools require connected mode and `qmax-code login`. +## Go integration with Claude Code and Codex + +qmax-code calls both native harnesses directly from Go. As of September 5, +2026, their official harness SDKs are available for Python and TypeScript; +neither provider documents an official Go harness SDK. + +| Harness | Go integration used here | Continuation | +| --- | --- | --- | +| Claude Code | `os/exec` with `claude --print --output-format stream-json` | `--resume` with the native session ID | +| Codex | The Go `codexrunner` package with `codex exec --json` | `codex exec resume` with the native thread ID | + +Anthropic explicitly recommends a CLI subprocess for other languages in its +[Agent SDK overview](https://code.claude.com/docs/en/agent-sdk). OpenAI documents +the JSONL stream and saved CLI authentication in its +[non-interactive guide](https://learn.chatgpt.com/docs/non-interactive-mode); +its [SDK guide](https://developers.openai.com/codex/sdk/) covers the Python and +TypeScript libraries. Codex's [app server](https://learn.chatgpt.com/docs/app-server) +also exposes a language-independent JSON-RPC interface for richer custom clients. + +The providers' Go **API client** SDKs are separate from these coding harnesses. +Adding an API client would not provide the native agent loop or subscription +login. The current subprocess integration keeps qmax-code a single Go binary, +with Claude Code and Codex installed and updated separately. `go mod tidy` or +updating Go modules does not update those CLI executables. + +## GPT 6 Astra and Claude Fable 5.1 + +Select **GPT 6 Astra** under Codex, or **Fable 5.1** under Claude Code or +Anthropic API in `/orch`. The exact IDs are `gpt-6-astra` and +`claude-fable-5-1`; `fable` is a shorthand for Fable 5.1. Existing defaults +and the explicit Fable 5 model remain available. + +```bash +qmax-code --local --backend codex --model gpt-6-astra +qmax-code --local --backend cc --model fable +# Direct API billing: +qmax-code --local --backend api --model claude-fable-5-1 +``` + +The `/orch` Codex selection is saved separately from Claude's preference and +survives `/clear`. **Codex default** (or `--backend codex --model auto`) +uses Codex's own model configuration. Approval and sandbox policy continue to +come from Codex configuration. Model access depends on the account used by +the native CLI. See the [Astra model reference](https://developers.openai.com/api/docs/models/gpt-6-astra) +and [Fable 5.1 reference](https://platform.claude.com/docs/en/models/fable-5-1/overview). + +## Claude Code subscription billing + +Yes: qmax-code can use the native Claude Code harness with a Claude subscription +login, without an Anthropic API key. The `cc` backend runs `claude --print` +and connects qmax tools through MCP. It inherits Claude Code authentication; +it does not require `qmax-code cc connect` or a QualityMax login in local mode. + +For subscription authentication, remove the API-key override from the shell +that launches qmax-code, then open Claude Code and use `/login` to sign in with +your Claude account and `/status` to verify authentication: + +```bash +unset ANTHROPIC_API_KEY +claude +# After signing in and exiting Claude Code: +qmax-code --local --backend cc --model fable +``` + +An inherited `ANTHROPIC_API_KEY` takes precedence over subscription login and +causes API billing. See [Anthropic's authentication guidance](https://support.claude.com/en/articles/12304248-manage-api-key-environment-variables-in-claude-code). + +As of September 5, 2026, the separate third-party Agent SDK billing change +announced for June 15 remains paused. Anthropic's +[June 15 update](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan) +confirms that Agent SDK, `claude -p`, and third-party app usage still draw from +subscription limits. Conductor also documents continued subscription use in +its [billing update](https://www.conductor.build/blog/claude-subscription-update). + +Fable 5.1 specifically requires Claude Code **2.1.255 or later**. Max and eligible +premium Team/legacy Enterprise seats include Fable usage up to 50% of the weekly +limit. Pro and standard seats use paid usage credits for Fable 5.1 from the first +request. A subscription login therefore does not guarantee included Fable usage; +check your tier and extra-usage settings in [Anthropic's Fable plan guide](https://support.claude.com/en/articles/15424964-claude-fable-models-on-your-plan). + ## Standalone local-only orchestration Every backend can be selected in standalone mode: diff --git a/go.mod b/go.mod index a3ff4e6..40c7913 100644 --- a/go.mod +++ b/go.mod @@ -1,44 +1,47 @@ module github.com/qualitymax/qmax-code -go 1.24.0 +go 1.25.13 require ( github.com/BourgeoisBear/rasterm v1.1.2 github.com/Quality-Max/qmax-receipt v0.1.0 github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/glamour v0.8.0 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/ansi v0.10.1 + github.com/charmbracelet/glamour v1.0.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.11.8 github.com/chzyer/readline v1.5.1 - github.com/coder/websocket v1.8.14 - github.com/getsentry/sentry-go v0.44.1 - golang.org/x/sys v0.36.0 - golang.org/x/term v0.22.0 + github.com/coder/websocket v1.8.15 + github.com/getsentry/sentry-go v0.49.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 ) require ( - github.com/alecthomas/chroma/v2 v2.14.0 // indirect + github.com/alecthomas/chroma/v2 v2.27.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20260902165432-6f6ad8b37b0a // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2/v2 v2.7.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.29 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.7.4 // indirect - github.com/yuin/goldmark-emoji v1.0.3 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/text v0.16.0 // indirect + github.com/xo/terminfo v1.0.0 // indirect + github.com/yuin/goldmark v1.8.6 // indirect + github.com/yuin/goldmark-emoji v1.0.6 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/text v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index c75efb8..0dece96 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,12 @@ github.com/BourgeoisBear/rasterm v1.1.2 h1:hWHZBZ45N366uNSqxWFYBV0y19q8fXRXADhPk github.com/BourgeoisBear/rasterm v1.1.2/go.mod h1:Ifd+To5s/uyUiYx+B4fxhS8lUNwNLSxDBjskmC5pEyw= github.com/Quality-Max/qmax-receipt v0.1.0 h1:eHFs+2KhmWxldWWyEz1Tu4Oh+FuUOHt1JYG8IXLgUWw= github.com/Quality-Max/qmax-receipt v0.1.0/go.mod h1:1GaT7lO+l9itgDgcVHUKF54pbMxKz5671f74Q1c3rDg= -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs= +github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= @@ -16,53 +16,59 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs= -github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= +github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/exp/slice v0.0.0-20260902165432-6f6ad8b37b0a h1:V2rGRIGqWWv9BuYW2ObrtLy6uE48b+X4cAUKkUgTTBs= +github.com/charmbracelet/x/exp/slice v0.0.0-20260902165432-6f6ad8b37b0a/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4= +github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= -github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.49.0 h1:Ehejknu1l023Ub7QoRBVLAI7g3Jnhqku4oWx4B4Sh5s= +github.com/getsentry/sentry-go v0.49.0/go.mod h1:nuMJAoCfe1u0Bts2ocyNI+TW8HT84vRMqwA5Qq/SKUI= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ= +github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -77,37 +83,35 @@ github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= -github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= -github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= +github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= +github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= +github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 3f6a93b..4083fa0 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -829,6 +829,17 @@ func (a *Agent) callAPI() (*api.APIResponse, error) { return nil, fmt.Errorf("parse response: %w", err) } + // Like the streaming path, retain visible text and tool calls only. The wire + // type does not preserve signed thinking payloads; replaying a partial + // thinking block on the next tool iteration would be rejected by the API. + content := apiResp.Content[:0] + for _, block := range apiResp.Content { + if block.Type != "thinking" && block.Type != "redacted_thinking" { + content = append(content, block) + } + } + apiResp.Content = content + // Track usage a.Usage.InputTokens += apiResp.Usage.InputTokens a.LastContextTokens = apiResp.Usage.InputTokens diff --git a/internal/agent/cc_agent.go b/internal/agent/cc_agent.go index e8c5e5b..34507cd 100644 --- a/internal/agent/cc_agent.go +++ b/internal/agent/cc_agent.go @@ -55,8 +55,8 @@ type PlanLimitReporter interface { // CCAgent orchestrates a Claude Code CLI subprocess for LLM inference. // Inference runs through the user's Claude Code login, so qmax-code does not -// need a QM-held Anthropic API key. Because this agent uses `claude --print`, -// usage moves to the user's monthly Claude Agent SDK credit on 2026-06-15. +// need a QM-held Anthropic API key. `claude --print` uses Claude Code's +// configured authentication and billing, including inherited API credentials. // qmax tools are exposed to CC as an MCP server so CC can call them natively // via its own tool-use mechanism. // diff --git a/internal/agent/codex_agent.go b/internal/agent/codex_agent.go index 3a562e4..66904ed 100644 --- a/internal/agent/codex_agent.go +++ b/internal/agent/codex_agent.go @@ -16,18 +16,19 @@ import ( ) // CodexAgent orchestrates an OpenAI Codex CLI subprocess for LLM inference. -// Inference runs through the user's ChatGPT/OpenAI subscription — no OpenAI -// API tokens consumed by qmax-code. qmax tools are served via the same MCP -// server used for CC mode. +// Inference uses Codex's configured authentication and billing, including +// ChatGPT subscription login or API credentials. qmax tools are served via +// the same MCP server used for CC mode. // // Per-message flow: // 1. qmax-code writes ~/.codex/config.toml with the qmax MCP server entry // 2. qmax-code starts or resumes a Codex thread through codexrunner // 3. Codex picks up the MCP config and spawns qmax-code serve --mcp -// 4. Codex uses qmax tools natively, runs on OpenAI subscription +// 4. Codex uses qmax tools natively with its configured authentication // 5. qmax-code streams Codex's stdout to the terminal type CodexAgent struct { codexBin string + modelID string effort string // "low" | "medium" | "high" outputVerbose bool // false = compact answer style; true = previous detailed style sctx *api.SessionContext @@ -61,15 +62,15 @@ func FindCodex() string { return "" } -// NewCodexAgent creates a Codex subprocess orchestrator. Model, approval, and -// sandbox choices come from Codex's own configuration because the public -// runner intentionally uses a fixed command line. -func NewCodexAgent(bin, effort string, outputVerbose bool, sctx *api.SessionContext) *CodexAgent { +// NewCodexAgent creates a Codex subprocess orchestrator. An empty modelID uses +// Codex configuration. Approval and sandbox choices always come from Codex. +func NewCodexAgent(bin, modelID, effort string, outputVerbose bool, sctx *api.SessionContext) *CodexAgent { if effort == "" { effort = "high" } return &CodexAgent{ codexBin: bin, + modelID: modelID, effort: effort, outputVerbose: outputVerbose, sctx: sctx, @@ -146,6 +147,11 @@ func (a *CodexAgent) Run(userMsg string, term *tui.Terminal) (string, error) { continuity := a.getContinuity() isInitialTurn := continuity.Checkpoint().ThreadID == "" + if isInitialTurn && a.modelID != "" { + if err := continuity.Restore(codexrunner.Checkpoint{Model: a.modelID}); err != nil { + return "", fmt.Errorf("codex model: %w", err) + } + } prompt := a.buildPrompt(userMsg, isInitialTurn) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) @@ -222,6 +228,8 @@ func (a *CodexAgent) buildPrompt(userMsg string, initial bool) string { // ClearHistory resets native Codex continuity (used when the user types /clear). func (a *CodexAgent) ClearHistory() { + a.turnMu.Lock() + defer a.turnMu.Unlock() a.getContinuity().Reset() } diff --git a/internal/agent/codex_agent_continuity_test.go b/internal/agent/codex_agent_continuity_test.go index c1852ee..cdc029a 100644 --- a/internal/agent/codex_agent_continuity_test.go +++ b/internal/agent/codex_agent_continuity_test.go @@ -20,7 +20,7 @@ else fi printf '%s\n' "{\"type\":\"thread.started\",\"thread_id\":\"$thread_id\"}" `) - a := NewCodexAgent(codexBin, "high", false, &api.SessionContext{}) + a := NewCodexAgent(codexBin, "", "high", false, &api.SessionContext{}) if _, err := a.Run(strings.Repeat(t.Name(), 1), nil); err != nil { t.Fatal("initial adapter turn failed") @@ -39,7 +39,7 @@ printf '%s\n' "{\"type\":\"thread.started\",\"thread_id\":\"$thread_id\"}" } func TestCodexAgentAddsQAScaffoldOnlyToInitialTurn(t *testing.T) { - a := NewCodexAgent("codex", "high", false, &api.SessionContext{}) + a := NewCodexAgent("codex", "", "high", false, &api.SessionContext{}) initial := a.buildPrompt(strings.Repeat(t.Name(), 1), true) if !strings.Contains(initial, codexQASystemPrompt) { @@ -53,3 +53,33 @@ func TestCodexAgentAddsQAScaffoldOnlyToInitialTurn(t *testing.T) { } const firstAdapterThreadID = "abcdef12-3456-4abc-8def-1234567890ab" + +func TestCodexAgentAstraSurvivesResumeAndClear(t *testing.T) { + _ = withTempHome(t) + codexBin := writeFakeCLI(t, "codex-astra", `#!/bin/sh +thread_id='abcdef12-3456-4abc-8def-1234567890ab' +if [ "$#" -eq 5 ] && [ "$1" = 'exec' ] && [ "$2" = '--model' ] && [ "$3" = 'gpt-6-astra' ] && [ "$4" = '--json' ] && [ "$5" = '-' ]; then + : +elif [ "$#" -eq 7 ] && [ "$1" = 'exec' ] && [ "$2" = 'resume' ] && [ "$3" = '--model' ] && [ "$4" = 'gpt-6-astra' ] && [ "$5" = '--json' ] && [ "$6" = "$thread_id" ] && [ "$7" = '-' ]; then + : +else + exit 64 +fi +printf '%s\n' "{\"type\":\"thread.started\",\"thread_id\":\"$thread_id\"}" +`) + a := NewCodexAgent(codexBin, "gpt-6-astra", "high", false, &api.SessionContext{}) + for i := 0; i < 3; i++ { + if i == 2 { + a.ClearHistory() + if a.continuity.Checkpoint().ThreadID != "" { + t.Fatal("clear retained the previous thread") + } + } + if _, err := a.Run("Check the repository", nil); err != nil { + t.Fatalf("turn %d: %v", i, err) + } + if got := a.continuity.Checkpoint(); got.Model != "gpt-6-astra" || got.ThreadID != firstAdapterThreadID { + t.Fatalf("turn %d did not preserve the exact model and thread", i) + } + } +} diff --git a/internal/agent/fable_test.go b/internal/agent/fable_test.go new file mode 100644 index 0000000..4b2c9fc --- /dev/null +++ b/internal/agent/fable_test.go @@ -0,0 +1,46 @@ +package agent + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +type fableTestTransport func(*http.Request) (*http.Response, error) + +func (f fableTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestFable51ToolLoopDoesNotReplayIncompleteThinking(t *testing.T) { + a := NewAgent(AgentConfig{Model: api.ModelFable51, Context: &api.SessionContext{LocalOnly: true}}) + requests := 0 + a.client.Transport = fableTestTransport(func(r *http.Request) (*http.Response, error) { + requests++ + var req api.APIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatal(err) + } + if req.Model != api.ModelFable51 { + t.Fatal("request did not use Fable 5.1") + } + body := `{"content":[{"type":"thinking"},{"type":"redacted_thinking"},{"type":"tool_use","id":"test-tool-call","name":"unavailable_test_tool","input":{}}],"stop_reason":"tool_use"}` + if requests == 2 { + if len(req.Messages) != 3 { + t.Fatal("tool continuation lost conversation history") + } + blocks, ok := req.Messages[1].Content.([]interface{}) + if !ok || len(blocks) != 1 || blocks[0].(map[string]interface{})["type"] != "tool_use" { + t.Fatal("tool continuation replayed an incomplete thinking block") + } + body = `{"content":[{"type":"text","text":"Done"}],"stop_reason":"end_turn"}` + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + got, err := a.Run("Check the repository") + if err != nil || got != "Done" || requests != 2 { + t.Fatalf("tool loop: response=%q, requests=%d, error=%v", got, requests, err) + } +} diff --git a/internal/agent/mcp_reconnect_test.go b/internal/agent/mcp_reconnect_test.go index a9edd7e..1ebc83b 100644 --- a/internal/agent/mcp_reconnect_test.go +++ b/internal/agent/mcp_reconnect_test.go @@ -31,7 +31,7 @@ printf '%s\n' '{"type":"thread.started","thread_id":"11111111-1111-4111-8111-111 printf '%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"codex ok"}}' `) - a := NewCodexAgent(codexBin, "high", false, &api.SessionContext{ + a := NewCodexAgent(codexBin, "", "high", false, &api.SessionContext{ ProjectID: 88, LiveFeed: true, LocalOnly: true, diff --git a/internal/api/anthropic.go b/internal/api/anthropic.go index d24ed6d..125b17b 100644 --- a/internal/api/anthropic.go +++ b/internal/api/anthropic.go @@ -4,11 +4,12 @@ package api const ( AnthropicMessagesURL = "https://api.anthropic.com/v1/messages" AnthropicVersion = "2023-06-01" + ModelFable51 = "claude-fable-5-1" 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 + 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 + ModelOpus47 = "claude-opus-4-7" // prior Opus, still selectable by full ID ) diff --git a/internal/api/config.go b/internal/api/config.go index a665048..611845d 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -53,7 +53,7 @@ type Config struct { // Backend selects the LLM inference backend. // "" / "api" → Anthropic API directly (default, requires ANTHROPIC_API_KEY) // "cc" → Claude Code CLI subprocess (no QM API key; `claude --print` - // uses the user's Agent SDK credit starting 2026-06-15) + // uses Claude Code's configured subscription or provider billing) // "codex" → OpenAI Codex CLI subprocess (uses OpenAI subscription, no API key needed) // "cerebras" → Cerebras OpenAI-compatible API (requires CEREBRAS_API_KEY). // Drives the native qmax agent loop with the full tool set via @@ -73,10 +73,13 @@ type Config struct { EnabledProviders []string `json:"enabled_providers,omitempty"` // ModelOverride is the specific model ID selected via the /orch TUI picker. - // Empty means "let the backend pick its default". Codex always uses its own - // configuration; this override remains relevant to CC and opencode. + // Empty means "let the backend pick its default". Used by CC and opencode. ModelOverride string `json:"model_override,omitempty"` + // CodexModel is an optional exact model ID; empty uses Codex configuration. + // Keep this separate so switching backends preserves the Claude preference. + CodexModel string `json:"codex_model,omitempty"` + // Effort controls how thorough the CLI agent should be: "low", "medium", "high". // Injected into the system prompt on every turn. Effort string `json:"effort,omitempty"` diff --git a/internal/api/config_test.go b/internal/api/config_test.go index f56dc93..981cbd5 100644 --- a/internal/api/config_test.go +++ b/internal/api/config_test.go @@ -26,6 +26,7 @@ func TestConfigSaveAndLoad(t *testing.T) { cfg := &Config{ DefaultModel: "opus", + CodexModel: "gpt-6-astra", DefaultProject: 42, Professional: true, AutoSave: false, @@ -37,6 +38,9 @@ func TestConfigSaveAndLoad(t *testing.T) { } loaded := LoadQMaxCodeConfig() + if loaded.CodexModel != "gpt-6-astra" { + t.Fatal("Codex model did not survive save/load") + } if loaded.DefaultModel != "opus" { t.Errorf("DefaultModel: got %s, want opus", loaded.DefaultModel) } diff --git a/internal/api/context_test.go b/internal/api/context_test.go index 20ea3fe..1dbbea4 100644 --- a/internal/api/context_test.go +++ b/internal/api/context_test.go @@ -24,6 +24,7 @@ func TestEstimatedCost(t *testing.T) { want float64 // cost of 1M input + 1M output tokens }{ {"claude-fable-5", introSonnet5, 10.0 + 50.0}, + {"claude-fable-5-1", 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}, diff --git a/internal/api/models.go b/internal/api/models.go index 0360679..79d8841 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -14,6 +14,8 @@ var sonnet5StandardPricingStart = time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) // sentinel. func ResolveClaudeModel(m string) string { switch strings.ToLower(m) { + case "fable": + return ModelFable51 case "sonnet": return ModelSonnet5 case "opus": @@ -30,7 +32,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", ModelFable, ModelSonnet5, ModelSonnet, ModelOpus, ModelOpus1M, ModelOpus47, ModelHaiku: + case "auto", ModelFable51, ModelFable, ModelSonnet5, ModelSonnet, ModelOpus, ModelOpus1M, ModelOpus47, ModelHaiku: return true default: return false @@ -38,7 +40,7 @@ func IsValidClaudeModelName(m string) bool { } func ValidClaudeModelsHelp() string { - return "auto, sonnet, opus, haiku, " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku + return "auto, fable, sonnet, opus, haiku, " + ModelFable51 + ", " + ModelFable + ", " + ModelSonnet5 + ", " + ModelSonnet + ", " + ModelOpus + ", " + ModelOpus1M + ", " + ModelOpus47 + ", " + ModelHaiku } // ContextWindow returns the assumed context-window size in tokens for a model @@ -49,7 +51,7 @@ func ContextWindow(model string) int { switch { case strings.Contains(m, "[1m]"): return 1_000_000 - case m == ModelFable, m == ModelSonnet5: + case m == ModelFable51, m == ModelFable, m == ModelSonnet5: return 1_000_000 default: return 200_000 diff --git a/internal/api/models_test.go b/internal/api/models_test.go index 34bd21a..e48084d 100644 --- a/internal/api/models_test.go +++ b/internal/api/models_test.go @@ -9,7 +9,7 @@ func TestResolveClaudeModelSonnetShorthandUsesSonnet5(t *testing.T) { } func TestIsValidClaudeModelNameIncludesFableAndSonnet5(t *testing.T) { - for _, model := range []string{"sonnet", ModelFable, ModelSonnet5, ModelOpus1M} { + for _, model := range []string{"fable", ModelFable51, "sonnet", ModelFable, ModelSonnet5, ModelOpus1M} { if !IsValidClaudeModelName(model) { t.Errorf("IsValidClaudeModelName(%q) = false, want true", model) } @@ -22,6 +22,7 @@ func TestContextWindow(t *testing.T) { want int }{ {ModelSonnet5, 1_000_000}, + {ModelFable51, 1_000_000}, {"claude-opus-4-6[1m]", 1_000_000}, {"unknown-model", 200_000}, } { @@ -30,3 +31,9 @@ func TestContextWindow(t *testing.T) { } } } + +func TestResolveClaudeModelFableShorthand(t *testing.T) { + if got := ResolveClaudeModel("FABLE"); got != ModelFable51 { + t.Fatalf("fable alias = %q, want %q", got, ModelFable51) + } +} diff --git a/internal/repl/cli_continuity_test.go b/internal/repl/cli_continuity_test.go index 1817c82..c7b7a90 100644 --- a/internal/repl/cli_continuity_test.go +++ b/internal/repl/cli_continuity_test.go @@ -3,10 +3,23 @@ package repl import ( "testing" + "github.com/qualitymax/qmax-code/internal/agent" "github.com/qualitymax/qmax-code/internal/api" "github.com/qualitymax/qmax-code/internal/tui" ) +func TestAPIModelSelectionUpdatesRoutingAndStartupPreference(t *testing.T) { + ag := &agent.Agent{AppConfig: &api.Config{}} + applyAPIModelSelection(ag, api.ModelFable51) + if ag.Cfg.AutoRoute || ag.Cfg.Model != api.ModelFable51 || ag.Cfg.ChatModel != api.ModelFable51 || ag.AppConfig.DefaultModel != api.ModelFable51 { + t.Fatal("Fable picker selection did not reach both API routes and saved preference") + } + applyAPIModelSelection(ag, "auto") + if !ag.Cfg.AutoRoute || ag.Cfg.Model != api.ModelSonnet || ag.Cfg.ChatModel != api.ModelHaiku || ag.AppConfig.DefaultModel != "auto" { + t.Fatal("auto picker selection did not restore smart routing") + } +} + func TestResetCLIConversationUsesBackendContinuityHook(t *testing.T) { spy := &continuityResetSpy{} resetCLIConversation(spy) @@ -18,7 +31,10 @@ func TestResetCLIConversationUsesBackendContinuityHook(t *testing.T) { func TestPersistOrchModelSelectionPreservesPreferenceForCodex(t *testing.T) { cfg := &api.Config{ModelOverride: "saved-preference"} - persistOrchModelSelection(cfg, "codex", "") + persistOrchModelSelection(cfg, "codex", "gpt-6-astra") + if cfg.CodexModel != "gpt-6-astra" { + t.Fatal("Codex model was not persisted") + } if cfg.ModelOverride != "saved-preference" { t.Fatal("selecting Codex erased another backend's model preference") } @@ -27,6 +43,13 @@ func TestPersistOrchModelSelectionPreservesPreferenceForCodex(t *testing.T) { if cfg.ModelOverride != "replacement-preference" { t.Fatal("non-Codex model selection was not persisted") } + if cfg.CodexModel != "gpt-6-astra" { + t.Fatal("Claude selection erased Codex model") + } + persistOrchModelSelection(cfg, "codex", "") + if cfg.CodexModel != "" { + t.Fatal("Codex default did not clear explicit model") + } } type continuityResetSpy struct { diff --git a/internal/repl/repl.go b/internal/repl/repl.go index e176a2a..bfeef31 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -210,7 +210,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin permissionMode = ag.AppConfig.OrchPermissionMode } if backend == "codex" { - model = "Codex config" + model = codexModelLabel(ag.AppConfig.CodexModel) permissionMode = "codex policy" } } @@ -511,6 +511,9 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin currentBackend = "ollama" } currentModelID := cfg.ModelOverride + if currentBackend == "codex" { + currentModelID = cfg.CodexModel + } currentEffort := cfg.Effort if currentBackend == "cerebras" { currentModelID = api.ResolveCerebrasModel(cfg.CerebrasModel) @@ -694,12 +697,12 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin cliAgent = agent.NewCCAgent(agent.FindClaudeCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, ag.Cfg.Context) term.PrintSystem(fmt.Sprintf("Backend: Claude Code model: %s effort: %s", result.ModelID, result.Effort)) case "codex": - ca := agent.NewCodexAgent(agent.FindCodex(), result.Effort, cfg.OutputVerbose, ag.Cfg.Context) + ca := agent.NewCodexAgent(agent.FindCodex(), result.ModelID, result.Effort, cfg.OutputVerbose, ag.Cfg.Context) if err := ca.WriteMCPConfig(); err != nil { term.PrintSystem(fmt.Sprintf("Warning: Codex MCP config: %v", err)) } cliAgent = ca - term.PrintSystem(fmt.Sprintf("Backend: Codex model/policy: Codex config prompt effort: %s", result.Effort)) + term.PrintSystem(fmt.Sprintf("Backend: Codex model: %s policy: Codex config prompt effort: %s", codexModelLabel(result.ModelID), result.Effort)) case "opencode": oc := agent.NewOpenCodeAgent(agent.FindOpenCode(), result.ModelID, result.Effort, cfg.OrchPermissionMode, cfg.OutputVerbose, cfg, ag.Cfg.Context) if _, err := agent.WriteOpenCodeConfig(cfg, ag.Cfg.Context, cfg.OrchPermissionMode); err != nil { @@ -708,6 +711,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin cliAgent = oc term.PrintSystem(fmt.Sprintf("Backend: opencode model: %s effort: %s", result.ModelID, result.Effort)) default: + applyAPIModelSelection(ag, result.ModelID) term.PrintSystem(fmt.Sprintf("Backend: Anthropic API model: %s", result.ModelID)) } @@ -875,7 +879,7 @@ func Run(ag *agent.Agent, cliAgent agent.CLIAgent, quietMode bool, version strin } setup.InstallSkillsReport("codex", term) } - ca := agent.NewCodexAgent(bin, cfg.Effort, cfg.OutputVerbose, ag.Cfg.Context) + ca := agent.NewCodexAgent(bin, cfg.CodexModel, cfg.Effort, cfg.OutputVerbose, ag.Cfg.Context) if err := ca.WriteMCPConfig(); err != nil { term.PrintSystem(fmt.Sprintf("Warning: MCP config: %v", err)) } @@ -1758,11 +1762,27 @@ func resetCLIConversation(cliAgent agent.CLIAgent) { } } +// applyAPIModelSelection updates both routing and the persisted startup model. +func applyAPIModelSelection(ag *agent.Agent, model string) { + ag.Cfg.AutoRoute = model == "auto" + ag.Cfg.Model, ag.Cfg.ChatModel = model, model + if ag.Cfg.AutoRoute { + ag.Cfg.Model, ag.Cfg.ChatModel = api.ModelSonnet, api.ModelHaiku + } + ag.AppConfig.DefaultModel = model +} + +func codexModelLabel(model string) string { + if model == "" { + return "Codex config" + } + return model +} + func persistOrchModelSelection(cfg *api.Config, backend, modelID string) { - // Codex owns its model selection in Codex config. Its terminal-neutral - // picker entry intentionally has no model ID, so do not let choosing it - // erase the saved preference used by the other orchestration backends. - if backend != "codex" { + if backend == "codex" { + cfg.CodexModel = modelID + } else { cfg.ModelOverride = modelID } } diff --git a/internal/tui/tui_backend.go b/internal/tui/tui_backend.go index 14f35e1..e814c95 100644 --- a/internal/tui/tui_backend.go +++ b/internal/tui/tui_backend.go @@ -131,7 +131,8 @@ type pickerEntry struct { } var ccModels = []pickerEntry{ - {backend: "cc", modelID: api.ModelFable, label: "Fable 5", subLabel: "1M ctx · long agents", isNew: true, shortcut: '1'}, + {backend: "cc", modelID: api.ModelFable51, label: "Fable 5.1", subLabel: "1M ctx · long agents", isNew: true, shortcut: '1'}, + {backend: "cc", modelID: api.ModelFable, label: "Fable 5", subLabel: "1M ctx · long agents"}, {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'}, @@ -142,9 +143,11 @@ var ccModels = []pickerEntry{ var codexModels = []pickerEntry{ {backend: "codex", modelID: "", label: "Codex default", subLabel: "uses Codex config", external: true, isFav: true, shortcut: '6'}, + {backend: "codex", modelID: "gpt-6-astra", label: "GPT 6 Astra", external: true, isNew: true}, } var apiModels = []pickerEntry{ + {backend: "", modelID: api.ModelFable51, label: "Fable 5.1", subLabel: "1M ctx", isNew: true}, {backend: "", modelID: "auto", label: "auto", subLabel: "haiku→sonnet routing", isFav: true}, {backend: "", modelID: api.ModelSonnet, label: "Sonnet 4.6"}, {backend: "", modelID: api.ModelOpus, label: "Opus 4.8"}, diff --git a/internal/tui/tui_backend_cc_test.go b/internal/tui/tui_backend_cc_test.go index 194f4a4..4040dea 100644 --- a/internal/tui/tui_backend_cc_test.go +++ b/internal/tui/tui_backend_cc_test.go @@ -16,12 +16,12 @@ func TestPickerIncludesClaudeCodeFableAndSonnet5(t *testing.T) { } } - fable, ok := seen[api.ModelFable] + fable, ok := seen[api.ModelFable51] if !ok { - t.Fatalf("Claude Code picker missing %s", api.ModelFable) + t.Fatalf("Claude Code picker missing %s", api.ModelFable51) } - if fable.label != "Fable 5" { - t.Errorf("Fable label = %q, want Fable 5", fable.label) + if fable.label != "Fable 5.1" { + t.Errorf("Fable label = %q, want Fable 5.1", fable.label) } if fable.subLabel != "1M ctx · long agents" { t.Errorf("Fable subLabel = %q, want 1M ctx · long agents", fable.subLabel) @@ -47,24 +47,24 @@ func TestPickerClaudeCodeDefaultCursorOnSonnet5(t *testing.T) { } } -func TestPickerUsesCodexConfigurationInsteadOfClaimingModelControl(t *testing.T) { - // A legacy saved model must still put the cursor on the terminal-neutral - // Codex entry; the runner no longer puts model IDs on the command line. - m := newModelPickerModel("codex", "legacy-model", "high", "", "", true, true, false, false, nil) - - var entries []pickerEntry - for _, e := range m.allEntries { - if e.backend == "codex" { - entries = append(entries, e) +func TestPickerCodexDefaultAndExplicitModel(t *testing.T) { + for _, model := range []string{"", "legacy-model", "gpt-6-astra"} { + m := newModelPickerModel("codex", model, "high", "", "", true, true, false, false, nil) + want := model + if model == "legacy-model" { + want = "" + } + cur := m.allEntries[m.cursor] + if cur.backend != "codex" || cur.modelID != want { + t.Fatalf("selection %q: cursor on %s/%s, want codex/%s", model, cur.backend, cur.modelID, want) } } - if len(entries) != 1 { - t.Fatalf("Codex picker entries = %d, want 1", len(entries)) - } - if entries[0].modelID != "" || entries[0].subLabel != "uses Codex config" || !entries[0].isFav { - t.Fatal("Codex picker must defer model selection to Codex configuration") - } - if got := m.allEntries[m.cursor].backend; got != "codex" { - t.Fatalf("cursor backend = %q, want codex", got) +} + +func TestPickerIncludesFable51ForDirectAPI(t *testing.T) { + m := newModelPickerModel("", api.ModelFable51, "high", "", "", false, false, false, false, nil) + cur := m.allEntries[m.cursor] + if cur.backend != "" || cur.modelID != api.ModelFable51 { + t.Fatal("direct API Fable 5.1 selection is missing") } } diff --git a/main.go b/main.go index ba1a334..06fdaf9 100644 --- a/main.go +++ b/main.go @@ -31,7 +31,7 @@ func main() { // Flags projectID := flag.Int("project-id", 0, "Default project ID for this session") - model := flag.String("model", "", "Claude model: auto (haiku+sonnet), sonnet, opus, haiku, or full ID") + model := flag.String("model", "", "Model: auto, fable, sonnet, opus, haiku, or full Claude/Codex ID") anthropicAPIKey := flag.String("anthropic-api-key", "", "Anthropic API key (or set ANTHROPIC_API_KEY)") cloudURL := flag.String("cloud-url", "", "QualityMax cloud URL (or use qmax login)") oneShot := flag.String("p", "", "Run a single prompt and exit (non-interactive)") @@ -228,18 +228,10 @@ func main() { } } - // Resolve model: CLI flag > saved config > "auto" - effectiveModel := *model - if effectiveModel == "" { - effectiveModel = appConfig.DefaultModel - } - if effectiveModel == "" { - effectiveModel = "auto" - } - effectiveModel = resolveModel(effectiveModel) - if !isValidModelName(effectiveModel) { - fmt.Fprintf(os.Stderr, "Error: --model %q is not recognized.\n", *model) - fmt.Fprintf(os.Stderr, " Valid: %s.\n", api.ValidClaudeModelsHelp()) + // Resolve the model for the active backend before authentication or setup. + effectiveModel, modelErr := resolveSessionModel(appConfig, *model) + if modelErr != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", modelErr) exitWithReceipt(2) } @@ -324,9 +316,8 @@ func main() { // CLI backend mode: route all LLM inference through a local CLI subprocess. // Neither a QM-held Anthropic API key nor an OpenAI key is required. In cc - // mode, qmax-code uses the user's Claude Code login via `claude --print`; - // starting 2026-06-15, that traffic draws from the user's monthly Claude - // Agent SDK credit before any extra-usage billing. + // mode, `claude --print` uses Claude Code's configured authentication and + // billing (subscription login or API/provider credentials). // qmax tools are served to the CLI via the embedded MCP server. var cliAgent agent.CLIAgent cliBackend := appConfig.Backend // "cc" | "codex" | "" (API) @@ -438,7 +429,7 @@ func main() { fmt.Fprintln(os.Stderr, "\nError: Anthropic API key required.") fmt.Fprintln(os.Stderr, " export ANTHROPIC_API_KEY=sk-ant-...") fmt.Fprintln(os.Stderr, " Or use a CLI backend (no API key needed):") - fmt.Fprintln(os.Stderr, " qmax-code config set backend cc # Claude Code login / Agent SDK credit") + fmt.Fprintln(os.Stderr, " qmax-code config set backend cc # Claude Code login") fmt.Fprintln(os.Stderr, " qmax-code config set backend codex # OpenAI/Codex subscription") if localOnly { fmt.Fprintln(os.Stderr, " qmax-code config set ollama_url http://127.0.0.1:11434") @@ -531,7 +522,7 @@ func main() { if appConfig.OrchGlobalInstall { _, _ = setup.InstallSkills("codex") } - ca := agent.NewCodexAgent(agent.FindCodex(), appConfig.Effort, appConfig.OutputVerbose, ctx) + ca := agent.NewCodexAgent(agent.FindCodex(), appConfig.CodexModel, appConfig.Effort, appConfig.OutputVerbose, ctx) if err := ca.WriteMCPConfig(); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not write Codex MCP config: %v\n", err) } diff --git a/main_models.go b/main_models.go new file mode 100644 index 0000000..48ef74b --- /dev/null +++ b/main_models.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + + "github.com/qualitymax/qmax-code/codexrunner" + "github.com/qualitymax/qmax-code/internal/api" +) + +// resolveSessionModel applies CLI selection to the active harness and returns +// the Claude model for the built-in loop. OpenAI IDs must never reach that loop. +func resolveSessionModel(cfg *api.Config, requested string) (string, error) { + if cfg.Backend == "codex" { + model := cfg.CodexModel + if requested != "" { + model = requested + if model == "auto" { + model = "" + } + } + if model != "" && codexrunner.ValidateModel(model) != nil { + return "", fmt.Errorf("unrecognized Codex model %q; use auto or one of %v", model, codexrunner.SupportedModels()) + } + cfg.CodexModel = model + return "auto", nil + } + + model := requested + if model == "" { + model = cfg.DefaultModel + } + if model == "" { + model = "auto" + } + model = api.ResolveClaudeModel(model) + if !api.IsValidClaudeModelName(model) { + return "", fmt.Errorf("unrecognized Claude model %q; valid: %s", model, api.ValidClaudeModelsHelp()) + } + if cfg.Backend == "cc" && requested != "" { + cfg.ModelOverride = model + if model == "auto" { + cfg.ModelOverride = "" + } + } + return model, nil +} diff --git a/main_models_test.go b/main_models_test.go new file mode 100644 index 0000000..cf1aec5 --- /dev/null +++ b/main_models_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "testing" + + "github.com/qualitymax/qmax-code/internal/api" +) + +func TestResolveSessionModel(t *testing.T) { + for _, tc := range []struct { + name, backend, requested, savedCodex, wantModel, wantCodex, wantClaude string + invalid bool + }{ + {name: "Astra flag", backend: "codex", requested: "gpt-6-astra", wantModel: "auto", wantCodex: "gpt-6-astra", wantClaude: "saved-claude"}, + {name: "saved Astra", backend: "codex", savedCodex: "gpt-6-astra", wantModel: "auto", wantCodex: "gpt-6-astra", wantClaude: "saved-claude"}, + {name: "Codex config", backend: "codex", wantModel: "auto", wantClaude: "saved-claude"}, + {name: "reset Codex", backend: "codex", requested: "auto", savedCodex: "gpt-6-astra", wantModel: "auto", wantClaude: "saved-claude"}, + {name: "Codex typo", backend: "codex", requested: "gpt-6-astro", invalid: true}, + {name: "invalid saved Codex", backend: "codex", savedCodex: "gpt-unknown", invalid: true}, + {name: "wrong provider", backend: "api", requested: "gpt-6-astra", invalid: true}, + {name: "Fable harness", backend: "cc", requested: "fable", wantModel: api.ModelFable51, wantClaude: api.ModelFable51}, + {name: "Fable API", requested: api.ModelFable51, wantModel: api.ModelFable51, wantClaude: "saved-claude"}, + {name: "Claude default", backend: "cc", requested: "auto", wantModel: "auto"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := &api.Config{Backend: tc.backend, CodexModel: tc.savedCodex, ModelOverride: "saved-claude"} + got, err := resolveSessionModel(cfg, tc.requested) + if (err != nil) != tc.invalid { + t.Fatalf("error = %v, invalid = %v", err, tc.invalid) + } + if !tc.invalid && (got != tc.wantModel || cfg.CodexModel != tc.wantCodex || cfg.ModelOverride != tc.wantClaude) { + t.Fatalf("models = (%q, %q, %q), want (%q, %q, %q)", got, cfg.CodexModel, cfg.ModelOverride, tc.wantModel, tc.wantCodex, tc.wantClaude) + } + }) + } +} diff --git a/main_validation_test.go b/main_validation_test.go index 43fa183..58e4737 100644 --- a/main_validation_test.go +++ b/main_validation_test.go @@ -25,6 +25,8 @@ func TestIsValidModelName(t *testing.T) { {"claude-opus-4-7", true}, {"claude-sonnet-5", true}, {"claude-fable-5", true}, + {"claude-fable-5-1", true}, + {"fable", true}, {"claude-sonnet-4-6", true}, {"claude-haiku-4-5-20251001", true}, // Rejected.