fix(models): name dot-form Claude minors and future GPT ids consistently (#1531)

* fix(models): name dot-form Claude minors and future GPT ids consistently (#1530)

One model id resolved to different display names per provider, and new
models showed truncated names until a hand-maintained entry landed:

- deriveClaudeShortName now accepts the dot-form minor some doors write
  on disk (GitHub Copilot's session store: claude-opus-4.8), so the name
  keeps its minor instead of truncating to "Opus 4".
- Unknown dotted-version GPT ids derive their display name (gpt-5.7-terra
  -> "GPT-5.7 Terra") the way unreleased Claude versions already do, so a
  new release needs no SHORT_NAMES entry. Legacy bare-major /
  date-packaged ids (gpt-9, gpt-4-1106-preview) stay raw, and curated
  labels keep winning.
- Pi/OMP/Copilot local display tables now match keys on segment
  boundaries via modelKeyMatches, so a bare `gpt-5` key can no longer
  capture `gpt-5.5` / `gpt-5.6-luna`; unmatched ids fall through to the
  global resolver, making one id show one name on every provider.
- The JetBrains token scan rejects a trailing dot, so `gpt-5` /
  `claude-opus-4` cannot match inside `gpt-5.6-luna` / `claude-opus-4.8`.

Fixes #1530

* fix(copilot): list the current JetBrains model ids in the token scan (#1530)

Review of #1531: the tighter #1530 boundary is right, but with no token
entries the newer ids fell through to copilot-anthropic-auto and priced
as Sonnet 4.5. Add the current dotted ids, longest first per the list's
order, so "model":"gpt-5.5", gpt-5.6-luna and claude-opus-4.8 resolve to
their own priced, correctly named rows; unknown future versions (a dot
followed by a digit) still return no token.
This commit is contained in:
ozymandiashh 2026-09-28 19:48:21 +03:00 • committed by GitHub
parent b6a977dd7c
commit 0259392a20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 225 additions and 33 deletions

View file

@ -1423,6 +1423,10 @@ const SORTED_SHORT_NAMES: [string, string][] = Object.entries(SHORT_NAMES)
// Anthropic's id scheme is `claude-<family>-<major>[-<minor>]`, so every new
// version is derivable — no hand-maintained entry per release. (Legacy 3.x ids
// put the family last, e.g. `claude-3-5-sonnet`, and stay in SHORT_NAMES.)
// Some doors write the minor with a dot instead of a dash (GitHub Copilot's
// session store: `claude-opus-4.8`), so both spellings derive the same name
// (#1530); the dotted aliases in BUILTIN_ALIASES below predate this and stay
// only because pricing also resolves through them.
const CLAUDE_FAMILY: Record<string, string> = {
opus: 'Opus',
sonnet: 'Sonnet',
@ -1431,19 +1435,50 @@ const CLAUDE_FAMILY: Record<string, string> = {
mythos: 'Mythos',
}
function deriveClaudeShortName(canonical: string): string | undefined {
const m = canonical.match(/^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d+))?/)
const m = canonical.match(/^claude-(opus|sonnet|haiku|fable|mythos)-(\d+)(?:[-.](\d+))?/)
if (!m) return undefined
const [, family, major, minor] = m
return `${CLAUDE_FAMILY[family]} ${major}${minor ? `.${minor}` : ''}`
}
// OpenAI's id scheme is `gpt-<version>[-<variant>]` with a numeric version and
// lowercase variant segments, so it derives the same way Anthropic's does — a
// new release needs no hand-maintained entry (#1530). Consulted only after
// SHORT_NAMES misses, so curated labels (GPT-5 Pro, GPT-5.1 Codex Mini, the
// individually listed GPT-5.6 variants) keep winning. Guarded to the modern
// dotted-version scheme (`gpt-5.7-terra`): bare-major ids are the legacy
// date-packaged shape (`gpt-4-1106-preview`) and stay raw, and purely numeric
// segments (1106, 0125, 20261105) are packaging, not the model's name.
const GPT_DERIVED_ID = /^gpt-(\d+\.\d+(?:\.\d+)*)(?:-([a-z0-9][a-z0-9-]*))?$/
function deriveGptShortName(id: string): string | undefined {
const m = id.match(GPT_DERIVED_ID)
if (!m) return undefined
const [, version, variant] = m
const segments = (variant ?? '')
.split('-')
.filter(seg => seg !== '' && !/^\d+$/.test(seg))
.map(seg => seg.charAt(0).toUpperCase() + seg.slice(1))
return ['GPT-' + version, ...segments].join(' ')
}
function lookupShortName(id: string): string | undefined {
const claude = deriveClaudeShortName(id)
if (claude) return claude
for (const [key, name] of SORTED_SHORT_NAMES) {
if (id === key || id.startsWith(key + '-')) return name
}
return undefined
return deriveGptShortName(id)
}
/// Segment-boundary key match for provider display tables: `key` must be the
/// whole id or a whole dash-segment of it, never mid-number or mid-version —
/// so `gpt-5` cannot capture `gpt-5.5` or `gpt-5.6-luna`, exactly as the
/// global table's `id === key || id.startsWith(key + '-')` cannot (#1530).
/// Providers match their local tables with this so one model id resolves to
/// one display name on every provider.
export function modelKeyMatches(model: string, key: string): boolean {
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`(?<![\\w.-])${escaped}(?=$|-)`).test(model)
}
// Public API stays unary so Array.map/forEach cannot feed index as cycle state.

View file

@ -64,7 +64,7 @@ import { join, basename, dirname, posix, win32 } from 'path'
import { existsSync } from 'fs'
import { createHash } from 'crypto'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { calculateCost, modelKeyMatches } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { estimateTokens } from '../context-tree.js'
import type {
@ -1861,11 +1861,21 @@ function createChatSessionParser(
// Known JetBrains Copilot model tokens, longest-first so we match the most
// specific name (e.g. "gpt-4.1-mini" before "gpt-4.1").
const JETBRAINS_MODEL_TOKENS = [
'claude-opus-4.8',
'claude-opus-4.6',
'claude-opus-4.5',
'claude-opus-4.1',
'claude-opus-4',
'claude-sonnet-4.6',
'claude-sonnet-4.5',
'claude-sonnet-4',
'claude-haiku-4.5',
'gpt-5.6-luna',
'gpt-5.6-terra',
'gpt-5.6',
'gpt-5.5',
'gpt-5.4-mini',
'gpt-5.4',
'gpt-5.3-codex',
'gpt-5.3',
'gpt-5.2',
@ -1897,11 +1907,15 @@ function normalizeJetBrainsModelName(raw: string): string {
}
/** Match a known model token at an alnum boundary anywhere in a string. */
function findJetBrainsModelToken(s: string): string {
export function findJetBrainsModelToken(s: string): string {
for (const token of JETBRAINS_MODEL_TOKENS) {
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// "o3" etc. must not match inside words like "iso3166".
if (new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9])`).test(s)) {
// "o3" etc. must not match inside words like "iso3166". A dot followed by
// a digit is a version continuation and is rejected too: `gpt-5` must not
// capture `gpt-5.6-luna`, nor `claude-opus-4` capture `claude-opus-4.8` —
// that is a different version (#1530). A bare trailing dot (end of
// sentence, file extension) is not a version and stays a boundary.
if (new RegExp(`(?<![A-Za-z0-9])${escaped}(?![A-Za-z0-9]|\\.\\d)`).test(s)) {
return normalizeJetBrainsModelName(token)
}
}
@ -3689,9 +3703,13 @@ export function createCopilotProvider(
return [...roots.values()]
},
// Segment-boundary match (#1530): `includes` let a bare `gpt-5` key
// capture `gpt-5.5` and `gpt-5.6-luna`. Unknown ids fall through to the
// raw id, and the report layer's fallbackRawModelDisplayName then resolves
// them with the same global getShortModelName every other provider uses.
modelDisplayName(model: string): string {
for (const [key, display] of modelDisplayEntries) {
if (model.includes(key)) return display
if (modelKeyMatches(model, key)) return display
}
return model
},

View file

@ -9,15 +9,6 @@ import { extractBashCommands } from '../bash-utils.js'
import { normalizeContentBlocks } from '../content-utils.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
const modelDisplayNames: Record<string, string> = {
'gpt-5.4': 'GPT-5.4',
'gpt-5.4-mini': 'GPT-5.4 Mini',
'gpt-5.5': 'GPT-5.5',
'gpt-5': 'GPT-5',
'gpt-4o': 'GPT-4o',
'gpt-4o-mini': 'GPT-4o Mini',
}
const toolNameMap: Record<string, string> = {
bash: 'Bash',
read: 'Read',
@ -33,9 +24,6 @@ const toolNameMap: Record<string, string> = {
patch: 'Patch',
}
// Pre-sorted by key length descending so longer/more-specific keys match first
const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
// Pi/OMP have no dedicated skill tool the way Claude Code does. A native skill
// load is emitted as an ordinary `read` tool call whose path points at the
// skill's `SKILL.md` (Pi resolves skills from many roots: ~/.pi/agent/skills,
@ -440,10 +428,13 @@ export function createPiProvider(sessionsDir?: string): Provider {
},
displayName: 'Pi',
// No local display table (#1530): every entry one used to carry exists in
// the global SHORT_NAMES with the same label, and a local list that lags
// behind it re-creates the #1530 inconsistency (a bare `gpt-5` key
// swallowing `gpt-5-mini` into "GPT-5"). Echoing the raw id lets the
// report layer's fallbackRawModelDisplayName resolve every id with the
// one global getShortModelName all providers share.
modelDisplayName(model: string): string {
for (const [key, name] of modelDisplayEntries) {
if (model.startsWith(key)) return name
}
return model
},
@ -475,9 +466,6 @@ export function createOmpProvider(sessionsDir?: string): Provider {
displayName: 'OMP',
modelDisplayName(model: string): string {
for (const [key, name] of modelDisplayEntries) {
if (model.startsWith(key)) return name
}
return model
},

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { fallbackRawModelDisplayName } from '../src/models.js'
import { pi, createOmpProvider } from '../src/providers/pi.js'
import { copilot } from '../src/providers/copilot.js'
// The exact composition models-report/audit-report use: the provider's local
// table first, then the global resolver when the provider echoed the raw id.
// One model id must resolve to one display name on every provider (#1530).
function displayNameOf(provider: { modelDisplayName?: (m: string) => string }, model: string): string {
return fallbackRawModelDisplayName(provider.modelDisplayName!(model), model)
}
describe('model display name consistency across providers (#1530)', () => {
const omp = createOmpProvider()
const providers = [
{ name: 'pi', modelDisplayName: pi.modelDisplayName },
{ name: 'omp', modelDisplayName: omp.modelDisplayName },
{ name: 'copilot', modelDisplayName: copilot.modelDisplayName },
]
// The rows from the issue: copilot and pi showed "GPT-5" / "Opus 4" because
// a bare `gpt-5` key prefix-matched `gpt-5.5` / `gpt-5.6-luna`, and the
// dot-form Claude minor was dropped by the dash-only derivation.
it('shows GPT-5.5 and the GPT-5.6 variants on every provider', () => {
for (const p of providers) {
expect(displayNameOf(p, 'gpt-5.5')).toBe('GPT-5.5')
expect(displayNameOf(p, 'gpt-5.6-terra')).toBe('GPT-5.6 Terra')
expect(displayNameOf(p, 'gpt-5.6-luna')).toBe('GPT-5.6 Luna')
expect(displayNameOf(p, 'gpt-5.5')).not.toBe('GPT-5')
}
})
it('shows the Claude minor for dot-form ids on every provider', () => {
for (const p of providers) {
expect(displayNameOf(p, 'claude-opus-4.8')).toBe('Opus 4.8')
expect(displayNameOf(p, 'claude-sonnet-4.5')).toBe('Sonnet 4.5')
}
})
it('resolves one id to the same name on every provider', () => {
const ids = ['gpt-5.5', 'gpt-5.6-terra', 'gpt-5.6-luna', 'claude-opus-4.8', 'gpt-5.4-mini', 'gpt-5-mini', 'gpt-5', 'gpt-4o']
for (const id of ids) {
const names = new Set(providers.map(p => displayNameOf(p, id)))
expect([...names], id).toHaveLength(1)
}
})
it('keeps the provider-specific auto-model overrides', () => {
expect(displayNameOf(copilot, 'copilot-openai-auto')).toBe('Copilot (OpenAI auto)')
expect(displayNameOf(copilot, 'copilot-anthropic-auto')).toBe('Copilot (Anthropic auto)')
})
})

View file

@ -24,6 +24,7 @@ import {
getFlatRateModelsConfigHash,
parseLiteLLMEntry,
unpricedModelHint,
modelKeyMatches,
} from '../src/models.js'
import { getDailyCacheConfigHash } from '../src/usage-aggregator.js'
import snapshotData from '../src/data/litellm-snapshot.json' with { type: 'json' }
@ -354,9 +355,42 @@ describe('getShortModelName', () => {
expect(getShortModelName('gpt-5.6-sol')).toBe('GPT-5.6 Sol')
expect(getShortModelName('gpt-5.6-terra')).toBe('GPT-5.6 Terra')
expect(getShortModelName('gpt-5.6-luna')).toBe('GPT-5.6 Luna')
// No bare `gpt-5.6` entry exists, so an unlisted future variant must still
// fall through to its raw id rather than borrow a sibling's label.
expect(getShortModelName('gpt-5.6-unlisted')).toBe('gpt-5.6-unlisted')
// No bare `gpt-5.6` entry exists, so an unlisted variant of this version
// must not borrow a sibling's curated label. Since #1530 it derives its own
// label from the id instead of surfacing the raw slug. (Versions that DO
// have a bare entry, like gpt-5.5, still fold suffixed ids by the prefix
// rule — unchanged here.)
expect(getShortModelName('gpt-5.6-unlisted')).toBe('GPT-5.6 Unlisted')
})
// Regression for #1530: some doors write the Claude minor with a dot
// (GitHub Copilot's session store: claude-opus-4.8). The derivation must
// accept both spellings, or the name silently loses its minor ("Opus 4").
it('derives dot-form Claude minors the same as dash-form (#1530)', () => {
expect(getShortModelName('claude-opus-4.8')).toBe('Opus 4.8')
expect(getShortModelName('claude-opus-4-8')).toBe('Opus 4.8')
expect(getShortModelName('claude-sonnet-4.9')).toBe('Sonnet 4.9')
expect(getShortModelName('claude-opus-4.8-20300101')).toBe('Opus 4.8')
})
// Regression for #1530: an unknown future GPT version derives its name from
// the id, the way unreleased Claude versions already do — no hand-maintained
// entry per release. Curated entries still win, and the legacy bare-major /
// date-packaged shapes stay raw.
it('derives unknown GPT versions from their ids instead of surfacing raw (#1530)', () => {
expect(getShortModelName('gpt-5.7-terra')).toBe('GPT-5.7 Terra')
expect(getShortModelName('gpt-5.7-codex-spark')).toBe('GPT-5.7 Codex Spark')
expect(getShortModelName('gpt-5.7')).toBe('GPT-5.7')
// Numeric segments are packaging, not part of the name.
expect(getShortModelName('gpt-5.7-20261105')).toBe('GPT-5.7')
// Legacy shapes stay raw: bare-major ids and date-versioned packaging.
expect(getShortModelName('gpt-9')).toBe('gpt-9')
expect(getShortModelName('gpt-4-1106-preview')).toBe('gpt-4-1106-preview')
// Curated labels keep winning over the derivation.
expect(getShortModelName('gpt-5.5')).toBe('GPT-5.5')
expect(getShortModelName('gpt-5.1-codex-mini')).toBe('GPT-5.1 Codex Mini')
expect(getShortModelName('gpt-5-mini')).toBe('GPT-5 Mini')
expect(getShortModelName('gpt-4o')).toBe('GPT-4o')
})
it('names grok-4.5 without disturbing the Grok Build harness label', () => {
@ -390,6 +424,26 @@ describe('getShortModelName', () => {
})
})
describe('modelKeyMatches', () => {
// The primitive provider display tables match with (#1530): a key must be
// the whole id or a whole dash-segment, never mid-version — so a bare
// `gpt-5` key cannot capture `gpt-5.5` or `gpt-5.6-luna`.
it('matches the exact id and dash-suffixed ids', () => {
expect(modelKeyMatches('gpt-5', 'gpt-5')).toBe(true)
expect(modelKeyMatches('gpt-5-mini', 'gpt-5')).toBe(true)
expect(modelKeyMatches('gpt-4.1-2025-04-14', 'gpt-4.1')).toBe(true)
expect(modelKeyMatches('openai/gpt-5', 'gpt-5')).toBe(true)
})
it('rejects mid-version and mid-word matches', () => {
expect(modelKeyMatches('gpt-5.5', 'gpt-5')).toBe(false)
expect(modelKeyMatches('gpt-5.6-luna', 'gpt-5')).toBe(false)
expect(modelKeyMatches('gpt-5.4.1', 'gpt-5.4')).toBe(false)
expect(modelKeyMatches('xgpt-5', 'gpt-5')).toBe(false)
expect(modelKeyMatches('claude-opus-4.8', 'claude-opus-4')).toBe(false)
})
})
describe('claude-fable-5 pricing + name', () => {
it('prices at $10/M input, $50/M output via models.dev/OpenRouter gap-fill', () => {
expect(calculateCost('claude-fable-5', 1_000_000, 0, 0, 0, 0)).toBeCloseTo(10, 6)

View file

@ -6,7 +6,7 @@ import { createRequire } from 'node:module'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { copilot, createCopilotProvider, getVSCodeGlobalStorageDirs, getVSCodeWorkspaceStorageDirs, ChatCompletionContentPartKind } from '../../src/providers/copilot.js'
import { ChatCompletionContentPartKind, copilot, createCopilotProvider, findJetBrainsModelToken, getVSCodeGlobalStorageDirs, getVSCodeWorkspaceStorageDirs } from '../../src/providers/copilot.js'
import { isSqliteAvailable, isSqliteBusyError } from '../../src/sqlite.js'
import { calculateCost } from '../../src/models.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
@ -4319,3 +4319,40 @@ describe('copilot deduplication key prefixes (durable-union contract)', () => {
expect(/:shutdown-residual:[^:]+:\d{13}$/.test(residual)).toBe(true)
})
})
describe('copilot provider - JetBrains model token scan', () => {
// #1530: a token must not capture a newer version — `gpt-5` is not
// `gpt-5.7-terra`, nor `claude-opus-4` `claude-opus-4.9`. A dot only counts
// as a version continuation when a digit follows it; an end-of-sentence
// period or a file extension stays a token boundary.
it('does not match a token inside a newer dotted version', () => {
expect(findJetBrainsModelToken('running on gpt-5.7-terra for coding')).toBe('')
expect(findJetBrainsModelToken('switched to claude-opus-4.9 today')).toBe('')
expect(findJetBrainsModelToken('gpt-5.4.1 beta')).toBe('')
})
// The ids JetBrains writes today must match their own token, not fall
// through to the anthropic-auto bucket (#1531 review).
it('recognises the current dotted ids, longest first', () => {
expect(findJetBrainsModelToken('"model":"gpt-5.5"')).toBe('gpt-5.5')
expect(findJetBrainsModelToken('running on gpt-5.6-luna for coding')).toBe('gpt-5.6-luna')
expect(findJetBrainsModelToken('running on gpt-5.6-terra for coding')).toBe('gpt-5.6-terra')
expect(findJetBrainsModelToken('"model":"gpt-5.6"')).toBe('gpt-5.6')
expect(findJetBrainsModelToken('gpt-5.4-mini beta')).toBe('gpt-5.4-mini')
expect(findJetBrainsModelToken('switched to claude-opus-4.8 today')).toBe('claude-opus-4-8')
expect(findJetBrainsModelToken('switched to claude-opus-4.6 today')).toBe('claude-opus-4-6')
expect(findJetBrainsModelToken('switched to claude-sonnet-4.6 today')).toBe('claude-sonnet-4-6')
expect(findJetBrainsModelToken('switched to claude-haiku-4.5 today')).toBe('claude-haiku-4-5')
})
it('still matches tokens followed by sentence punctuation or file extensions', () => {
expect(findJetBrainsModelToken('switched to gpt-5. thanks')).toBe('gpt-5')
expect(findJetBrainsModelToken('see configs/gpt-5.json for details')).toBe('gpt-5')
expect(findJetBrainsModelToken('running on claude-opus-4.5.')).toBe('claude-opus-4-5')
expect(findJetBrainsModelToken('"model":"gpt-5"')).toBe('gpt-5')
})
it('does not match inside words', () => {
expect(findJetBrainsModelToken('iso3166 codes')).toBe('')
})
})

View file

@ -4,6 +4,7 @@ import { join } from 'path'
import { tmpdir } from 'os'
import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js'
import { fallbackRawModelDisplayName } from '../../src/models.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
import { classifyTurn } from '../../src/classifier.js'
import type { ParsedApiCall, ParsedTurn } from '../../src/types.js'
@ -640,6 +641,11 @@ describe('pi provider - JSONL parsing', () => {
describe('pi provider - display names', () => {
const provider = createPiProvider('/tmp')
// The composition models-report/audit-report actually use: the provider
// hook first, then the global resolver when the hook echoed the raw id.
// Pi carries no local table anymore (#1530), so every name comes from the
// one global getShortModelName all providers share.
const display = (m: string) => fallbackRawModelDisplayName(provider.modelDisplayName(m), m)
it('has correct name and displayName', () => {
expect(provider.name).toBe('pi')
@ -647,13 +653,14 @@ describe('pi provider - display names', () => {
})
it('maps known models to readable names', () => {
expect(provider.modelDisplayName('gpt-5.4')).toBe('GPT-5.4')
expect(provider.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
expect(provider.modelDisplayName('gpt-5')).toBe('GPT-5')
expect(display('gpt-5.4')).toBe('GPT-5.4')
expect(display('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
expect(display('gpt-5')).toBe('GPT-5')
expect(display('gpt-5-mini')).toBe('GPT-5 Mini')
})
it('returns raw name for unknown models', () => {
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
expect(display('some-future-model')).toBe('some-future-model')
})
it('normalizes tool names to capitalized form', () => {