diff --git a/CHANGELOG.md b/CHANGELOG.md index 167a271..26a76a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,19 @@ reconcile. Closes #279. ### Fixed (CLI) +- **Cursor cost shown for every model, not just Auto.** Cursor emits model + names in a `claude--` shape (`claude-4.6-sonnet`, + `claude-4.5-opus`, `claude-4.5-opus-high-thinking`, etc.) plus its own + `composer-1` house model, none of which match the canonical LiteLLM + pricing keys (`claude-sonnet-4-6`, `claude-opus-4-5`). The alias map in + `src/models.ts` filled some of these in v0.9.4 but missed the plain + no-suffix forms (`claude-4.5-opus`, `claude-4.5-sonnet`, + `claude-4.6-opus`), the haiku tier, the forward-looking 4.7 variant, + and `composer-1`. The dashboard rendered $0 for sessions that used any + unaliased model. Visible to users in #159 even after the v0.9.4 fix. + Every Cursor variant in `src/providers/cursor.ts:modelDisplayNames` + now has an alias and a regression test asserting non-zero pricing + resolution. Closes #159. - **Activity classifier no longer mislabels feature work as debugging.** Messages like "add error handling", "create an issue tracker", or "implement the 404 page" used to land in the Debugging bucket because diff --git a/src/models.ts b/src/models.ts index 6f2d0ed..e4441e0 100644 --- a/src/models.ts +++ b/src/models.ts @@ -170,12 +170,49 @@ const BUILTIN_ALIASES: Record = { 'cline-auto': 'claude-sonnet-4-5', 'openclaw-auto': 'claude-sonnet-4-5', 'qwen-auto': 'claude-sonnet-4-5', - // Cursor emits dot-version tier-last names - 'claude-4.6-sonnet': 'claude-sonnet-4-6', - 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', + // Cursor emits dot-version tier-last names plus tier/reasoning suffixes + // that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`, + // `-high-thinking`, `-fast-mode`). Missing aliases here surface as $0 in + // the dashboard for users on non-Auto models (issue #159). Sources: the + // display map at `src/providers/cursor.ts:modelDisplayNames`, Cursor's + // public model docs at https://cursor.com/docs/models, and forum bug + // reports that quote literal slugs (e.g. forum.cursor.com/t/154933). + 'claude-4-sonnet': 'claude-sonnet-4', + 'claude-4-sonnet-1m': 'claude-sonnet-4', 'claude-4-sonnet-thinking': 'claude-sonnet-4-5', - 'claude-4-opus': 'claude-opus-4-5', + 'claude-4.5-sonnet': 'claude-sonnet-4-5', + 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', + 'claude-4.6-sonnet': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-high': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-low': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-thinking': 'claude-sonnet-4-6', + 'claude-4.6-sonnet-high-thinking':'claude-sonnet-4-6', + 'claude-4-opus': 'claude-opus-4', + 'claude-4.5-opus': 'claude-opus-4-5', + 'claude-4.5-opus-high': 'claude-opus-4-5', + 'claude-4.5-opus-low': 'claude-opus-4-5', + 'claude-4.5-opus-medium': 'claude-opus-4-5', 'claude-4.5-opus-high-thinking': 'claude-opus-4-5', + 'claude-4.6-opus': 'claude-opus-4-6', + 'claude-4.6-opus-fast-mode': 'claude-opus-4-6', + 'claude-4.6-opus-high': 'claude-opus-4-6', + 'claude-4.6-opus-low': 'claude-opus-4-6', + 'claude-4.6-opus-medium': 'claude-opus-4-6', + 'claude-4.6-opus-high-thinking': 'claude-opus-4-6', + 'claude-4.7-opus': 'claude-opus-4-7', + // Dash form (NOT dot) seen in forum.cursor.com/t/158597. + 'claude-opus-4-7-thinking-high': 'claude-opus-4-7', + 'claude-4.5-haiku': 'claude-haiku-4-5', + 'claude-4.6-haiku': 'claude-haiku-4-5', + // Cursor's house models have no LiteLLM pricing entry. composer-1 is + // sonnet-4.5-class per Cursor docs; composer-2 is built on Sonnet 4.6 + // per cursor.com/blog/composer-2. + 'composer-1': 'claude-sonnet-4-5', + 'composer-1.5': 'claude-sonnet-4-5', + 'composer-2': 'claude-sonnet-4-6', + // Cursor's "fast" routing variant of GPT-5 is the same model behind a + // lower-latency endpoint; price as base GPT-5 until LiteLLM tracks it. + 'gpt-5-fast': 'gpt-5', 'gpt-4.1': 'gpt-4.1', 'gpt-5.2-low': 'gpt-5', 'gpt-5.1-codex-high': 'gpt-5.3-codex', diff --git a/tests/models.test.ts b/tests/models.test.ts index e30e5c6..9fdf87b 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -179,3 +179,72 @@ describe('existing model names still resolve', () => { expect(getModelCosts('anthropic/claude-opus-4-6')).not.toBeNull() }) }) + +// Issue #159: every model name Cursor emits in its SQLite database must +// resolve to a non-zero pricing entry, otherwise the dashboard shows $0 for +// that model. Each case asserts the resolved pricing identity matches the +// pricing of the expected canonical key, so an accidental alias swap (e.g. +// `claude-4.6-opus` aliased to a haiku entry) fails the test even though +// haiku also has positive pricing. +describe('Cursor model variants resolve to pricing', () => { + const cases: Array<[string, string]> = [ + // Sonnet family + ['claude-4-sonnet', 'claude-sonnet-4'], + ['claude-4-sonnet-1m', 'claude-sonnet-4'], + ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4.5-sonnet', 'claude-sonnet-4-5'], + ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4.6-sonnet', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-high', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-low', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-thinking', 'claude-sonnet-4-6'], + ['claude-4.6-sonnet-high-thinking', 'claude-sonnet-4-6'], + // Opus family + ['claude-4-opus', 'claude-opus-4'], + ['claude-4.5-opus', 'claude-opus-4-5'], + ['claude-4.5-opus-high', 'claude-opus-4-5'], + ['claude-4.5-opus-low', 'claude-opus-4-5'], + ['claude-4.5-opus-medium', 'claude-opus-4-5'], + ['claude-4.5-opus-high-thinking', 'claude-opus-4-5'], + ['claude-4.6-opus', 'claude-opus-4-6'], + ['claude-4.6-opus-fast-mode', 'claude-opus-4-6'], + ['claude-4.6-opus-high', 'claude-opus-4-6'], + ['claude-4.6-opus-low', 'claude-opus-4-6'], + ['claude-4.6-opus-medium', 'claude-opus-4-6'], + ['claude-4.6-opus-high-thinking', 'claude-opus-4-6'], + ['claude-4.7-opus', 'claude-opus-4-7'], + ['claude-opus-4-7-thinking-high', 'claude-opus-4-7'], + // Haiku family + ['claude-4.5-haiku', 'claude-haiku-4-5'], + ['claude-4.6-haiku', 'claude-haiku-4-5'], + // Cursor house models + ['composer-1', 'claude-sonnet-4-5'], + ['composer-1.5', 'claude-sonnet-4-5'], + ['composer-2', 'claude-sonnet-4-6'], + ['cursor-auto', 'claude-sonnet-4-5'], + // OpenAI variants Cursor emits + ['gpt-5', 'gpt-5'], + ['gpt-5-fast', 'gpt-5'], + ['gpt-5.2', 'gpt-5.2'], + ['gpt-5.2-low', 'gpt-5'], + // Direct LiteLLM hits where no alias is required + ['grok-code-fast-1', 'grok-code-fast-1'], + ['gemini-3-pro', 'gemini-3-pro-preview'], + ] + + for (const [input, expectedAlias] of cases) { + it(`${input} resolves to ${expectedAlias} pricing`, () => { + const costs = getModelCosts(input) + expect(costs, `${input} should resolve to pricing (and not produce $0 in the dashboard)`).not.toBeNull() + expect(costs!.inputCostPerToken).toBeGreaterThan(0) + expect(costs!.outputCostPerToken).toBeGreaterThan(0) + const expected = getModelCosts(expectedAlias) + expect(expected, `expected target ${expectedAlias} should itself resolve`).not.toBeNull() + // Identity check: the alias must produce the SAME pricing object as + // the canonical key, not just any non-zero pricing. Catches drift + // where a future edit re-points an alias at a wrong-but-positive entry. + expect(costs!.inputCostPerToken).toBe(expected!.inputCostPerToken) + expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) + }) + } +})