feat(mcplocal): Anthropic models auto-follow the newest in their family
`claude-opus-4-20250514` was pinned as the heavy provider and had been returning 404 on every gate ranking and every pagination title. The only visible symptom was a fallback that looked like an ordinary one -- it surfaced here because the degradation notice added earlier in this branch finally printed the reason. Checked against GET /v1/models: BOTH pins were dead. The fast tier's claude-haiku-3-5-20241022 is gone too, so that tier had been silently 404ing as well. The provider's listModels() asserted "Anthropic doesn't have a models listing endpoint" and returned four hardcoded dated ids. That endpoint does exist and answers fine with the OAuth token this deployment uses; the hardcoded list was simply stale, and a test pinned it in place. So: `claude-<family>-latest` (or a bare `opus` / `sonnet` / `haiku` / `fable`) now resolves against the live list. Exact ids pass through untouched, so pinning still works when someone wants it. Newest is decided by `created_at`, never by parsing the version out of the id. That is not incidental: `claude-opus-4-5` sorts ABOVE `claude-opus-5` as a string, and "4-5" parses as a larger minor than "5". There is a test for exactly that trap. Resolution is cached (12h, MCPCTL_ANTHROPIC_MODEL_TTL_MS) so it is not a per-call network hop, and shared across instances since the model list is account-wide. When the endpoint is unreachable it falls back to a pinned known-good id per family and says so on stderr -- the map going stale can then only cost availability, never correctness. The constructor default was `claude-sonnet-4-20250514`, also retired; it now tracks the family too. Local config: heavy -> claude-opus-latest, fast -> claude-haiku-latest. Verified live: opus-latest -> claude-opus-5, haiku-latest -> claude-haiku-4-5-20251001, sonnet-latest -> claude-sonnet-5, and an exact id passes through. Also found while there: both anthropic entries were named "anthropic", and the registry keys by name, so the second silently OVERWROTE the first and one tier's model was discarded entirely. Renamed to anthropic-fast / anthropic-heavy so NamedProvider keeps them distinct and the tier split is real. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
This commit is contained in:
83
src/mcplocal/tests/anthropic-model-resolution.test.ts
Normal file
83
src/mcplocal/tests/anthropic-model-resolution.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { AnthropicProvider } from '../src/providers/anthropic.js';
|
||||
|
||||
/** The real payload shape, abbreviated — newest first, as the API returns it. */
|
||||
const MODELS = [
|
||||
{ id: 'claude-opus-5', created_at: '2026-07-24T00:00:00Z' },
|
||||
{ id: 'claude-sonnet-5', created_at: '2026-06-29T00:00:00Z' },
|
||||
{ id: 'claude-fable-5', created_at: '2026-06-07T00:00:00Z' },
|
||||
{ id: 'claude-opus-4-8', created_at: '2026-05-28T00:00:00Z' },
|
||||
{ id: 'claude-opus-4-5-20251101', created_at: '2025-11-24T00:00:00Z' },
|
||||
{ id: 'claude-haiku-4-5-20251001', created_at: '2025-10-15T00:00:00Z' },
|
||||
];
|
||||
|
||||
function providerWith(models: typeof MODELS | Error): AnthropicProvider {
|
||||
const p = new AnthropicProvider({ apiKey: 'sk-ant-api-test' });
|
||||
// Stub the private transport rather than the network.
|
||||
(p as unknown as { get: (path: string) => Promise<unknown> }).get = async () => {
|
||||
if (models instanceof Error) throw models;
|
||||
return { data: models, has_more: false };
|
||||
};
|
||||
return p;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// The cache is static — clear it so cases don't leak into each other.
|
||||
(AnthropicProvider as unknown as { modelCache: Map<string, unknown> }).modelCache.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Anthropic model resolution', () => {
|
||||
it('resolves a family selector to the newest member', async () => {
|
||||
await expect(providerWith(MODELS).resolveModel('claude-opus-latest')).resolves.toBe('claude-opus-5');
|
||||
});
|
||||
|
||||
it('accepts the bare family name too', async () => {
|
||||
await expect(providerWith(MODELS).resolveModel('opus')).resolves.toBe('claude-opus-5');
|
||||
await expect(providerWith(MODELS).resolveModel('haiku')).resolves.toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('picks by created_at, not by parsing the version', async () => {
|
||||
// The trap: claude-opus-4-5 sorts ABOVE claude-opus-5 as a string, and
|
||||
// "4-5" parses as a bigger minor than "5". Only the date is reliable.
|
||||
const out = await providerWith(MODELS).resolveModel('claude-opus-latest');
|
||||
expect(out).toBe('claude-opus-5');
|
||||
expect(out).not.toBe('claude-opus-4-5-20251101');
|
||||
});
|
||||
|
||||
it('leaves an exact model id alone, so pinning still works', async () => {
|
||||
const p = providerWith(MODELS);
|
||||
await expect(p.resolveModel('claude-opus-4-8')).resolves.toBe('claude-opus-4-8');
|
||||
await expect(p.resolveModel('claude-haiku-4-5-20251001')).resolves.toBe('claude-haiku-4-5-20251001');
|
||||
});
|
||||
|
||||
it('does not treat a dated id as a family selector', async () => {
|
||||
await expect(providerWith(MODELS).resolveModel('claude-opus-4-20250514'))
|
||||
.resolves.toBe('claude-opus-4-20250514');
|
||||
});
|
||||
|
||||
it('falls back deterministically when the models endpoint is unreachable', async () => {
|
||||
const err = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
await expect(providerWith(new Error('network down')).resolveModel('claude-opus-latest'))
|
||||
.resolves.toBe('claude-opus-5');
|
||||
// Loud, not silent.
|
||||
expect(err).toHaveBeenCalledWith(expect.stringContaining('falling back'));
|
||||
});
|
||||
|
||||
it('caches, so resolution is not a per-call network hop', async () => {
|
||||
const p = providerWith(MODELS);
|
||||
let calls = 0;
|
||||
(p as unknown as { get: () => Promise<unknown> }).get = async () => {
|
||||
calls++;
|
||||
return { data: MODELS, has_more: false };
|
||||
};
|
||||
await p.resolveModel('claude-opus-latest');
|
||||
await p.resolveModel('claude-opus-latest');
|
||||
await p.resolveModel('claude-opus-latest');
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('lists real models instead of a hardcoded table', async () => {
|
||||
await expect(providerWith(MODELS).listModels()).resolves.toContain('claude-opus-5');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user