-
-
Notifications
You must be signed in to change notification settings - Fork 128
feat(claude-models): resolve the Claude model list at runtime #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
leeroybrun
merged 11 commits into
happier-dev:dev
from
danljungstrom:feat/claude-dynamic-model-list
Aug 12, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
3896083
refactor(claude-models): give the Claude model list a single owner
danljungstrom ba5645d
feat(claude-models)!: consume the dynamic Claude model list
danljungstrom 3203138
fix(claude-models): probe the selected runtime identity
leeroybrun 86f1b51
fix(claude-models): unify runtime capability state
leeroybrun be2184c
fix(claude-models): address current-head review findings
leeroybrun 599812c
fix(claude): preserve startup effort dialog intent
leeroybrun 2484abc
test(claude): close exact-head review gaps
leeroybrun 4641956
test(claude): assert startup dialog control channel
leeroybrun c4ef502
fix(claude-models): trust authenticated catalog capabilities
leeroybrun 139c0ee
fix(claude): retain startup effort evidence
leeroybrun fa239ff
test(claude): type startup mode regression
leeroybrun File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
233 changes: 233 additions & 0 deletions
233
apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { createEnvKeyScope } from '@/testkit/env/envScope'; | ||
|
|
||
| import type { AnthropicModelEntry } from './fetchAnthropicModels'; | ||
|
|
||
| const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ | ||
| fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise<AnthropicModelEntry[] | null>>(), | ||
| readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise<unknown>>(), | ||
| })); | ||
|
|
||
| vi.mock('./fetchAnthropicModels', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('./fetchAnthropicModels')>(); | ||
| return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; | ||
| }); | ||
|
|
||
| vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile')>(); | ||
| return { ...actual, readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock }; | ||
| }); | ||
|
|
||
| import { createClaudeModelEffortLevelsTracker } from './claudeModelEffortLevelsTracker'; | ||
| import { resetClaudeModelCatalogCacheForTests } from './resolveClaudeModelCatalog'; | ||
|
|
||
| const envKeys = [ | ||
| 'ANTHROPIC_API_KEY', | ||
| 'ANTHROPIC_AUTH_TOKEN', | ||
| 'ANTHROPIC_OAUTH_TOKEN', | ||
| 'CLAUDE_CODE_OAUTH_TOKEN', | ||
| 'ANTHROPIC_BASE_URL', | ||
| ] as const; | ||
| let envScope = createEnvKeyScope(envKeys); | ||
|
|
||
| const effortCapabilities = (tiers: readonly string[]): AnthropicModelEntry['capabilities'] => ({ | ||
| effort: { | ||
| supported: true, | ||
| ...Object.fromEntries(tiers.map((tier) => [tier, { supported: true }])), | ||
| }, | ||
| }); | ||
|
|
||
| const createTracker = (): ReturnType<typeof createClaudeModelEffortLevelsTracker> => | ||
| createClaudeModelEffortLevelsTracker({ resolveTimeoutMs: () => 1_000 }); | ||
|
|
||
| beforeEach(() => { | ||
| fetchAnthropicModelsMock.mockReset(); | ||
| readClaudeCodeNativeCredentialMock.mockReset(); | ||
| readClaudeCodeNativeCredentialMock.mockResolvedValue(null); | ||
| resetClaudeModelCatalogCacheForTests(); | ||
| envScope.restore(); | ||
| envScope = createEnvKeyScope(envKeys); | ||
| process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| // Leave no cached catalog behind: the cache is module state, so a later suite sharing this | ||
| // module context would otherwise read entries this one populated. | ||
| resetClaudeModelCatalogCacheForTests(); | ||
| envScope.restore(); | ||
| envScope = createEnvKeyScope(envKeys); | ||
| }); | ||
|
|
||
| describe('createClaudeModelEffortLevelsTracker', () => { | ||
| it('reports the tiers a discovered model declares', async () => { | ||
| fetchAnthropicModelsMock.mockResolvedValue([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'high', 'xhigh']) }, | ||
| ]); | ||
| const tracker = createTracker(); | ||
|
|
||
| await tracker.refresh('claude-opus-9'); | ||
|
|
||
| expect(tracker.getModelId()).toBe('claude-opus-9'); | ||
| expect(tracker.getLevels()).toEqual(['low', 'high', 'xhigh']); | ||
| }); | ||
|
|
||
| it('forgets the previous model on reset to the CLI default', async () => { | ||
| fetchAnthropicModelsMock.mockResolvedValue([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) }, | ||
| ]); | ||
| const tracker = createTracker(); | ||
| await tracker.refresh('claude-opus-9'); | ||
| expect(tracker.getLevels()).not.toEqual([]); | ||
|
|
||
| await tracker.refresh(undefined); | ||
|
|
||
| // Leaving the tiers live would clamp whatever is selected next against this model. | ||
| expect(tracker.getModelId()).toBeNull(); | ||
| expect(tracker.getLevels()).toEqual([]); | ||
| }); | ||
|
|
||
| it('does not consult the catalog for a curated model', async () => { | ||
| const tracker = createTracker(); | ||
|
|
||
| await tracker.refresh('claude-haiku-4-5'); | ||
|
|
||
| // Curated models resolve effort from the static table, so a network round trip is wasted work. | ||
| expect(fetchAnthropicModelsMock).not.toHaveBeenCalled(); | ||
| expect(tracker.getLevels()).toEqual([]); | ||
| expect(tracker.getModelId()).toBe('claude-haiku-4-5'); | ||
| }); | ||
|
|
||
| it('reports no tiers when the catalog lookup fails', async () => { | ||
| fetchAnthropicModelsMock.mockRejectedValue(new Error('network')); | ||
| const tracker = createTracker(); | ||
|
|
||
| await tracker.refresh('claude-opus-9'); | ||
|
|
||
| // Fails closed: no evidence means no `--effort`, never a guessed level. | ||
| expect(tracker.getLevels()).toEqual([]); | ||
| }); | ||
|
|
||
| it('does not let a late lookup publish a superseded model tiers', async () => { | ||
| // Concurrent resolutions for one account share a single fetch, so both refreshes await the same | ||
| // promise. The guard has to be the model id, not which lookup happened to start first. | ||
| let releaseCatalog: ((entries: AnthropicModelEntry[]) => void) | null = null; | ||
| fetchAnthropicModelsMock.mockImplementation(() => new Promise<AnthropicModelEntry[]>((resolve) => { | ||
| releaseCatalog = resolve; | ||
| })); | ||
|
|
||
| const tracker = createTracker(); | ||
| const first = tracker.refresh('claude-opus-9'); | ||
| const second = tracker.refresh('claude-sonnet-9'); | ||
| expect(tracker.getModelId()).toBe('claude-sonnet-9'); | ||
|
|
||
| // Credential resolution is async, so the fetch starts a tick after refresh is called. | ||
| await new Promise((resolve) => { setTimeout(resolve, 0); }); | ||
|
|
||
| const release = releaseCatalog as ((entries: AnthropicModelEntry[]) => void) | null; | ||
| if (!release) { | ||
| throw new Error('expected the catalog lookup to be in flight'); | ||
| } | ||
| release([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) }, | ||
| { id: 'claude-sonnet-9', displayName: 'Sonnet 9', capabilities: effortCapabilities(['low']) }, | ||
| ]); | ||
| await Promise.all([first, second]); | ||
|
|
||
| // The superseded lookup must not publish Opus 9 tiers under Sonnet 9. | ||
| expect(tracker.getModelId()).toBe('claude-sonnet-9'); | ||
| expect(tracker.getLevels()).toEqual(['low']); | ||
| }); | ||
|
|
||
| it('does not block the caller past its budget on a cold catalog', async () => { | ||
| // SessionClient awaits the user-message callback as part of the pending-queue handoff, so an | ||
| // unbounded wait here would hold the queue behind this fetch. | ||
| fetchAnthropicModelsMock.mockImplementation(() => new Promise<AnthropicModelEntry[]>(() => {})); | ||
| const tracker = createTracker(); | ||
|
|
||
| const startedAt = Date.now(); | ||
| await tracker.refreshWithin('claude-opus-9', 30); | ||
|
|
||
| expect(Date.now() - startedAt).toBeLessThan(2_000); | ||
| // Past the budget the turn proceeds with no evidence, exactly as it would have before. | ||
| expect(tracker.getLevels()).toEqual([]); | ||
| }); | ||
|
|
||
| it('returns immediately once the catalog is cached', async () => { | ||
| fetchAnthropicModelsMock.mockResolvedValue([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) }, | ||
| ]); | ||
| const tracker = createTracker(); | ||
| await tracker.refresh('claude-opus-9'); | ||
|
|
||
| // A different model on a warm catalog must still resolve within the budget, not fall back to []. | ||
| const second = createTracker(); | ||
| await second.refreshWithin('claude-opus-9', 30); | ||
| expect(second.getLevels()).toEqual(['low', 'xhigh']); | ||
| }); | ||
|
|
||
| it('shares one catalog fetch across concurrent refreshes for the same account', async () => { | ||
| fetchAnthropicModelsMock.mockResolvedValue([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low']) }, | ||
| ]); | ||
| const trackerA = createTracker(); | ||
| const trackerB = createTracker(); | ||
|
|
||
| await Promise.all([trackerA.refresh('claude-opus-9'), trackerB.refresh('claude-opus-9')]); | ||
|
|
||
| // The preflight probe and the session publisher both resolve at session start; one fetch is enough. | ||
| expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('joins a startup prewarm for the same model inside the first-turn budget', async () => { | ||
| let releaseCatalog: ((entries: AnthropicModelEntry[]) => void) | null = null; | ||
| fetchAnthropicModelsMock.mockImplementation(() => new Promise<AnthropicModelEntry[]>((resolve) => { | ||
| releaseCatalog = resolve; | ||
| })); | ||
| const tracker = createTracker(); | ||
|
|
||
| void tracker.refresh('claude-opus-9'); | ||
| const firstTurn = tracker.refreshWithin('claude-opus-9', 1_000); | ||
| await new Promise((resolve) => { setTimeout(resolve, 0); }); | ||
|
|
||
| const release = releaseCatalog as ((entries: AnthropicModelEntry[]) => void) | null; | ||
| if (!release) throw new Error('expected the startup catalog lookup to be in flight'); | ||
| release([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'medium']) }, | ||
| ]); | ||
| await firstTurn; | ||
|
|
||
| expect(tracker.getLevels()).toEqual(['low', 'medium']); | ||
| expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('retries the same discovered model after an unavailable catalog recovers', async () => { | ||
| fetchAnthropicModelsMock | ||
| .mockRejectedValueOnce(new Error('network')) | ||
| .mockResolvedValueOnce([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'high']) }, | ||
| ]); | ||
| const tracker = createTracker(); | ||
|
|
||
| await tracker.refresh('claude-opus-9'); | ||
| await tracker.refresh('claude-opus-9'); | ||
|
|
||
| expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); | ||
| expect(tracker.getLevels()).toEqual(['low', 'high']); | ||
| }); | ||
|
|
||
| it('does not resolve the catalog again after the same model settles successfully', async () => { | ||
| fetchAnthropicModelsMock.mockResolvedValue([ | ||
| { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities([]) }, | ||
| ]); | ||
| const resolveTimeoutMs = vi.fn(() => 1_000); | ||
| const tracker = createClaudeModelEffortLevelsTracker({ resolveTimeoutMs }); | ||
|
|
||
| await tracker.refresh('claude-opus-9'); | ||
| await tracker.refresh('claude-opus-9'); | ||
|
|
||
| // An empty supported-tier list is still a successfully settled catalog answer, not a retry signal. | ||
| expect(resolveTimeoutMs).toHaveBeenCalledTimes(1); | ||
| expect(tracker.getLevels()).toEqual([]); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.