84 lines
3.6 KiB
TypeScript
84 lines
3.6 KiB
TypeScript
|
|
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');
|
||
|
|
});
|
||
|
|
});
|