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
2026-08-25 23:55:44 +01:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
fix(mcplocal): learn which models reject sampling params, don't hardcode them
Auto-following to the newest Opus moved the failure rather than removing it.
The deploy's own smoke output showed it: 404 "model not found" became
HTTP 400: `temperature` is deprecated for this model.
Anthropic removed temperature/top_p/top_k on the current generation (Opus 5,
Sonnet 5, Opus 4.7/4.8, Fable 5), and the adapter sends temperature: 0
unconditionally. So the gate's prompt-selection was still degrading on every
call -- just with a different status code.
A list of which models accept sampling would rot exactly the way the pinned
model ids did, which is the whole thing this branch is trying to stop. So the
provider learns it instead: the first 400 naming a sampling parameter drops it
and retries, and remembers the model so every later call omits it up front.
One wasted call, once, rather than a hardcoded table to maintain.
The match is deliberately narrow -- a 400 must actually name temperature/top_p/
top_k. An unrelated 400 (missing max_tokens, bad schema) propagates untouched;
there is a test for that, because a broad match here would silently swallow
real request errors and retry them pointlessly.
Verified live: claude-opus-latest -> claude-opus-5, first attempt 400 on
temperature, retry succeeds past it. The retry then hit HTTP 429 -- the
personal OAuth token's rate limit, which is the pre-existing credential-tiering
issue, not this path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GqMidYEGUJG5fxeoTELBu2
2026-08-26 00:11:50 +01:00
|
|
|
|
|
|
|
|
describe('sampling-parameter rejection', () => {
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
(AnthropicProvider as unknown as { rejectsSampling: Set<string> }).rejectsSampling.clear();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/** Rejects `temperature` exactly as the current Anthropic models do. */
|
|
|
|
|
function samplingStrictProvider(): { provider: AnthropicProvider; bodies: Array<Record<string, unknown>> } {
|
|
|
|
|
const provider = new AnthropicProvider({ apiKey: 'sk-ant-api-test' });
|
|
|
|
|
const bodies: Array<Record<string, unknown>> = [];
|
|
|
|
|
(provider as unknown as { request: (b: unknown) => Promise<unknown> }).request = async (b) => {
|
|
|
|
|
const body = b as Record<string, unknown>;
|
|
|
|
|
bodies.push({ ...body });
|
|
|
|
|
if (body.temperature !== undefined) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
'Anthropic HTTP 400: {"type":"error","error":{"type":"invalid_request_error",'
|
|
|
|
|
+ '"message":"`temperature` is deprecated for this model."}}',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return { content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn' };
|
|
|
|
|
};
|
|
|
|
|
return { provider, bodies };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('retries without temperature when the model rejects it', async () => {
|
|
|
|
|
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
|
|
|
const { provider, bodies } = samplingStrictProvider();
|
|
|
|
|
|
|
|
|
|
const result = await provider.complete({
|
|
|
|
|
model: 'claude-opus-5',
|
|
|
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
|
|
|
temperature: 0,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(result.content).toBe('ok');
|
|
|
|
|
expect(bodies).toHaveLength(2);
|
|
|
|
|
expect(bodies[0]!.temperature).toBe(0);
|
|
|
|
|
expect(bodies[1]!.temperature).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('remembers, so the wasted call happens once and not forever', async () => {
|
|
|
|
|
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
|
|
|
const { provider, bodies } = samplingStrictProvider();
|
|
|
|
|
const req = { model: 'claude-opus-5', messages: [{ role: 'user' as const, content: 'hi' }], temperature: 0 };
|
|
|
|
|
|
|
|
|
|
await provider.complete(req);
|
|
|
|
|
await provider.complete(req);
|
|
|
|
|
await provider.complete(req);
|
|
|
|
|
|
|
|
|
|
// 2 for the first call (reject + retry), then 1 each — not 2 each.
|
|
|
|
|
expect(bodies).toHaveLength(4);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('does not swallow unrelated 400s', async () => {
|
|
|
|
|
const provider = new AnthropicProvider({ apiKey: 'sk-ant-api-test' });
|
|
|
|
|
(provider as unknown as { request: () => Promise<unknown> }).request = async () => {
|
|
|
|
|
throw new Error('Anthropic HTTP 400: {"error":{"message":"max_tokens is required"}}');
|
|
|
|
|
};
|
|
|
|
|
await expect(provider.complete({
|
|
|
|
|
model: 'claude-opus-5',
|
|
|
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
|
|
|
temperature: 0,
|
|
|
|
|
})).rejects.toThrow(/max_tokens/);
|
|
|
|
|
});
|
|
|
|
|
});
|