Cursor model aliases: cover every variant so non-Auto sessions price (#159) (#290)

Cursor emits model names in a `claude-<dot-version>-<tier>` 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 these 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:

- plain no-suffix forms: `claude-4.5-opus`, `claude-4.5-sonnet`,
  `claude-4.6-opus`
- haiku tier: `claude-4.5-haiku`, `claude-4.6-haiku`
- forward-looking: `claude-4.7-opus`
- Cursor's house model: `composer-1`

The dashboard rendered $0 for sessions that used any unaliased
model — visible in the screenshots posted in #159 even after the
v0.9.4 fix that added the `-thinking` variants.

This PR fills the gaps and adds 16 regression tests under
`Cursor model variants resolve to pricing` that assert every
model name in `src/providers/cursor.ts:modelDisplayNames` plus
the additional plain forms resolves to a non-null pricing entry
with `inputCostPerToken > 0` and `outputCostPerToken > 0`. So a
future LiteLLM snapshot bump or a typo in the alias map will fail
the test before users see $0.

Direct hits in the snapshot (no alias needed): `gpt-5`, `gpt-5.2`,
`grok-code-fast-1`, `gemini-3-pro` (already aliased). These are
covered in the test suite as well so a snapshot that drops them
would also be caught.

Tests: 45 files, 617 passing locally (16 new). Closes #159.
This commit is contained in:
Resham Joshi 2026-05-10 03:27:44 -07:00 committed by GitHub
parent 7a878f4d19
commit cdf7169a89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 4 deletions

View file

@ -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-<dot-version>-<tier>` 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

View file

@ -170,12 +170,49 @@ const BUILTIN_ALIASES: Record<string, string> = {
'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',

View file

@ -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)
})
}
})