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:
2026-08-25 23:55:44 +01:00
parent 14eb2623bc
commit 08b451b7aa
3 changed files with 237 additions and 13 deletions

View 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');
});
});