import { describe, expect, it, vi, beforeEach } from 'vitest'; import { z } from 'zod'; import { estimateUsd, priceFor, addUsage, ZERO_USAGE } from './pricing.js'; import { ModelRouter } from './router.js'; import { AiNotConfiguredError, type ModelProvider, type Role } from './types.js'; import { recentCosts, setCostSink, recordCost } from './costs.js'; import { identifyCollectible, IdentificationSchema } from './helpers/identify.js'; describe('pricing', () => { it('estimates usd from the price table', () => { expect(priceFor('claude-opus-5')?.input).toBe(5); expect(estimateUsd('claude-haiku-4-5', { inputTokens: 1_000_000, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })).toBe(1); expect(estimateUsd('claude-opus-5', { inputTokens: 0, outputTokens: 1000, cacheReadTokens: 1_000_000, cacheWriteTokens: 0 })).toBeCloseTo(0.025 + 0.5, 5); expect(estimateUsd('unknown-local-model', { ...ZERO_USAGE, inputTokens: 5000 })).toBe(0); expect(addUsage(ZERO_USAGE, { inputTokens: 1, outputTokens: 2, cacheReadTokens: 3, cacheWriteTokens: 4 }).cacheWriteTokens).toBe(4); }); }); describe('router', () => { it('reports not configured without keys', () => { const r = new ModelRouter({ persistCosts: false }); expect(r.configured).toBe(false); expect(() => r.providerFor('research')).toThrow(AiNotConfiguredError); expect(r.table().every((t) => t.provider === null)).toBe(true); }); it('routes roles and honours overrides', () => { const r = new ModelRouter({ anthropicApiKey: 'sk-test', openaiApiKey: 'sk-openai', overrides: { research: 'anthropic:claude-opus-5', classify: 'claude-haiku-4-5', embed: 'openai:text-embedding-3-small' }, persistCosts: false }); expect(r.providerFor('research').id).toBe('anthropic'); expect(r.modelFor('research')).toBe('claude-opus-5'); expect(r.modelFor('normalize')).toBe('claude-haiku-4-5'); expect(r.providerFor('embed').id).toBe('openai'); expect(r.modelFor('vision')).toBe('claude-opus-5'); const table = r.table(); expect(table.find((t) => t.role === 'embed')?.provider).toBe('openai'); }); it('embeddings unavailable with Anthropic only', () => { const r = new ModelRouter({ anthropicApiKey: 'sk-test', persistCosts: false }); expect(r.supports('embed')).toBe(false); expect(() => r.providerFor('embed')).toThrow(/embeddings/); }); }); describe('cost sink', () => { beforeEach(() => setCostSink(null)); it('records and buffers events', async () => { const sink = vi.fn(); setCostSink(sink); await recordCost({ provider: 'anthropic', model: 'claude-haiku-4-5', role: 'classify', usage: ZERO_USAGE, usdEst: 0.001 }); expect(sink).toHaveBeenCalledTimes(1); expect(recentCosts().at(-1)?.role).toBe('classify'); }); }); function fakeProvider(data: unknown): ModelProvider { return { id: 'fake', supports: () => true, modelFor: () => 'fake-model', complete: async () => ({ text: '', model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0, stopReason: 'end_turn', refusal: null }), stream: async function* () {}, runTools: async function* () {}, extract: async (_role: Role, req: { schema: z.ZodType }) => { const parsed = req.schema.parse(data); return { data: parsed, confidence: (parsed as { confidence?: number }).confidence ?? 1, model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0 }; }, vision: async (req: { schema: z.ZodType }) => ({ data: req.schema.parse(data), confidence: 0.9, model: 'fake-model', provider: 'fake', usage: ZERO_USAGE, usdEst: 0 }), embed: async () => ({ vectors: [], model: 'fake', provider: 'fake', dimensions: 0, usdEst: 0 }), }; } describe('identifyCollectible', () => { it('validates schema and rejects unknown categories', async () => { const guess = IdentificationSchema.parse({ categorySlug: 'not_a_real_slug', name: 'Charizard', brand: null, franchise: 'Pokémon', set: 'Base Set', number: '4/102', year: 1999, variant: '1st Edition Holo', language: 'English', grader: 'psa', grade: '10', certificationNumber: null, conditionNotes: null, likelyGradeRange: null, confidence: 0.82, rationale: 'Holo foil, 1st edition stamp visible', searchQueries: ['Charizard Base Set 1st Edition 4/102'], warnings: [], }); const res = await identifyCollectible({ text: '1999 Pokemon Base Set Charizard 1st Edition PSA 10', provider: fakeProvider(guess) }); expect(res.data.categorySlug).toBeNull(); expect(res.data.warnings[0]).toMatch(/unknown category/); expect(res.confidence).toBeCloseTo(0.82); }); it('requires input', async () => { await expect(identifyCollectible({ provider: fakeProvider({}) })).rejects.toThrow(/provide images or text/); }); });