import { describe, it, expect } from "vitest"; import { filterSettings, snapEffort } from "@/lib/ai/core/normalize"; import { estimateCost, tokensPerSecond } from "@/lib/ai/core/pricing"; import { codeFromStatus, refineByMessage, parseRetryAfter, normalizeGenericError } from "@/lib/ai/core/errors"; import { StreamAccumulator, safeJson } from "@/lib/ai/core/stream-utils"; import { modelKey, parseModelKey, type PolyModel } from "@/lib/ai/core/types"; import { normalizeAnthropicModel } from "@/lib/ai/providers/anthropic"; import { normalizeOpenAIModel } from "@/lib/ai/providers/openai"; import { normalizeXaiModel } from "@/lib/ai/providers/xai"; import { deriveTitle } from "@/lib/chat/service"; const model: PolyModel = { key: "openai/gpt-5.5", id: "gpt-5.5", provider: "openai", displayName: "GPT-5.5", capabilities: { text: true, vision: true, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: true, tools: true, structuredOutput: true, streaming: true, files: true, webSearch: true }, limits: { contextTokens: 1_050_000, maxOutputTokens: 128_000 }, parameters: { temperature: true, topP: true, maxTokens: true, stop: false, seed: false, reasoningEffort: true, reasoningEffortLevels: ["none", "low", "medium", "high", "xhigh"], temperatureRange: { min: 0, max: 2 } }, status: "active", pricing: { inputPerMillion: 5, cachedInputPerMillion: 0.5, outputPerMillion: 30 }, }; describe("parameter normalization", () => { it("drops unsupported parameters and clamps ranges", () => { const { settings, dropped } = filterSettings({ temperature: 3, seed: 42, stop: ["x"], topK: 5, maxTokens: 999_999, reasoningEffort: "max", verbosity: "low" }, model); expect(settings.temperature).toBe(2); expect(settings.seed).toBeUndefined(); expect(settings.stop).toBeUndefined(); expect(settings.topK).toBeUndefined(); expect(settings.maxTokens).toBe(128_000); expect(settings.reasoningEffort).toBe("xhigh"); // snapped to the closest accepted level expect(settings.verbosity).toBeUndefined(); expect(dropped.map((d) => d.name).sort()).toEqual(["seed", "stop", "topK", "verbosity"]); }); it("snaps effort levels", () => { expect(snapEffort("minimal", ["low", "medium", "high"])).toBe("low"); expect(snapEffort("max", ["low", "medium", "high"])).toBe("high"); expect(snapEffort("none", ["low", "medium"])).toBe("low"); }); it("passes everything through when no model sheet is available", () => { const { settings } = filterSettings({ temperature: 0.3, seed: 1 }, undefined); expect(settings).toEqual({ temperature: 0.3, seed: 1 }); }); }); describe("pricing", () => { it("estimates cost with cached tokens and long-context tiers", () => { const c = estimateCost({ inputTokens: 1_000_000, outputTokens: 100_000, cachedInputTokens: 500_000 }, model.pricing); expect(c.known).toBe(true); expect(c.totalUsd).toBeCloseTo(0.5 * 5 + 0.5 * 0.5 + 0.1 * 30, 6); const lc = estimateCost({ inputTokens: 300_000, outputTokens: 1000 }, { inputPerMillion: 2, outputPerMillion: 6, longContext: { thresholdTokens: 200_000, inputPerMillion: 4, outputPerMillion: 12 } }); expect(lc.totalUsd).toBeCloseTo(0.3 * 4 + 0.001 * 12, 6); expect(estimateCost({ inputTokens: 10, outputTokens: 10 }, null).known).toBe(false); expect(tokensPerSecond(500, 5000, 1000)).toBe(125); }); }); describe("error normalization", () => { it("maps statuses and refines by message", () => { expect(codeFromStatus(401)).toBe("INVALID_API_KEY"); expect(codeFromStatus(429)).toBe("RATE_LIMITED"); expect(codeFromStatus(503)).toBe("PROVIDER_UNAVAILABLE"); expect(refineByMessage("INVALID_PARAMETER", "This model's maximum context length is 128000 tokens")).toBe("CONTEXT_TOO_LONG"); expect(refineByMessage("INVALID_PARAMETER", "You exceeded your current quota, please check your plan and billing details")).toBe("INSUFFICIENT_CREDITS"); expect(refineByMessage("UNKNOWN_PROVIDER_ERROR", "Incorrect API key provided")).toBe("INVALID_API_KEY"); expect(parseRetryAfter(new Headers({ "retry-after": "2" }))).toBe(2000); expect(parseRetryAfter({ "retry-after-ms": "750" })).toBe(750); const e = normalizeGenericError("xai", Object.assign(new Error("Rate limit"), { status: 429, headers: { "retry-after": "1" } })); expect(e.code).toBe("RATE_LIMITED"); expect(e.retryable).toBe(true); expect(e.retryAfterMs).toBe(1000); const abort = normalizeGenericError("openai", Object.assign(new Error("aborted"), { name: "AbortError" })); expect(abort.code).toBe("CANCELLED"); expect(abort.retryable).toBe(false); }); it("never leaks key material in messages", () => { const e = normalizeGenericError("openai", Object.assign(new Error("bad key sk-proj-abcdefghijklmnopqrstuvwxyz0123456789"), { status: 401 })); expect(e.message).toBe("Invalid API key"); const e2 = normalizeGenericError("openai", Object.assign(new Error("oops sk-proj-abcdefghijklmnopqrstuvwxyz0123456789"), { status: 500 })); expect(e2.message).not.toContain("abcdefghijklmnop"); }); }); describe("stream accumulator", () => { it("rebuilds text, reasoning, tool calls and usage", () => { const acc = new StreamAccumulator(); acc.push({ type: "start", id: "r1", model: "m" }); acc.push({ type: "reasoning-delta", text: "think " }); acc.push({ type: "text-delta", text: "Hello" }); acc.push({ type: "text-delta", text: " world" }); acc.push({ type: "tool-start", id: "c1", name: "calc" }); acc.push({ type: "tool-delta", id: "c1", argumentsDelta: '{"expression":' }); acc.push({ type: "tool-delta", id: "c1", argumentsDelta: '"1+1"}' }); acc.push({ type: "tool-end", id: "c1", name: "calc", arguments: {}, argumentsText: "" }); acc.push({ type: "usage", usage: { inputTokens: 10, outputTokens: 5 } }); acc.push({ type: "finish", reason: "tool-calls" }); const r = acc.toResponse("openai", "m"); expect(r.text).toBe("Hello world"); expect(r.reasoning).toBe("think "); expect(r.toolCalls).toEqual([{ type: "tool-call", id: "c1", name: "calc", arguments: { expression: "1+1" }, argumentsText: '{"expression":"1+1"}', providerData: undefined }]); expect(r.usage?.inputTokens).toBe(10); expect(r.finishReason).toBe("tool-calls"); expect(safeJson("not json")).toEqual({ _raw: "not json" }); }); }); describe("model normalization", () => { it("normalizes Anthropic Models API entries", () => { const m = normalizeAnthropicModel({ id: "claude-opus-5", display_name: "Claude Opus 5", created_at: "2026-04-01T00:00:00Z", type: "model", max_input_tokens: 1_000_000, max_tokens: 128_000, capabilities: { thinking: { supported: true, types: { adaptive: { supported: true }, enabled: { supported: false } } }, effort: { supported: true, low: { supported: true }, medium: { supported: true }, high: { supported: true }, xhigh: { supported: true }, max: { supported: true } }, image_input: { supported: true }, pdf_input: { supported: true }, structured_outputs: { supported: true } }, } as never); expect(m.key).toBe("anthropic/claude-opus-5"); expect(m.parameters.temperature).toBe(false); // sampling removed on adaptive-only generations expect(m.parameters.reasoningEffortLevels).toEqual(["none", "low", "medium", "high", "xhigh", "max"]); expect(m.pricing?.inputPerMillion).toBe(5); const haiku = normalizeAnthropicModel({ id: "claude-haiku-4-5-20251001", display_name: "Claude Haiku 4.5", created_at: "", type: "model", max_input_tokens: 200_000, max_tokens: 64_000, capabilities: { thinking: { supported: true, types: { adaptive: { supported: false }, enabled: { supported: true } } }, effort: { supported: false } } } as never); expect(haiku.parameters.temperature).toBe(true); expect(haiku.parameters.thinkingBudget).toBe(true); expect(haiku.parameters.reasoningEffort).toBe(false); expect(haiku.pricing?.inputPerMillion).toBe(1); }); it("filters OpenAI non-chat models and merges the catalog", () => { expect(normalizeOpenAIModel({ id: "gpt-4o-mini-tts", object: "model", created: 0, owned_by: "openai" })).toBeNull(); expect(normalizeOpenAIModel({ id: "text-embedding-3-large", object: "model", created: 0, owned_by: "openai" })).toBeNull(); expect(normalizeOpenAIModel({ id: "gpt-4", object: "model", created: 0, owned_by: "openai", shutdown_date: "2020-01-01" } as never)).toBeNull(); const m = normalizeOpenAIModel({ id: "gpt-5.5", object: "model", created: 0, owned_by: "openai" })!; expect(m.capabilities.reasoning).toBe(true); expect(m.parameters.seed).toBe(false); expect(m.parameters.reasoningEffortLevels).toContain("none"); expect(m.pricing?.outputPerMillion).toBe(30); expect(m.metadata?.samplingMode).toBe("conditional"); }); it("converts xAI price units (cents per 100M tokens)", () => { const m = normalizeXaiModel({ id: "grok-4.6", input_modalities: ["text", "image"], output_modalities: ["text"], prompt_text_token_price: 20000, cached_prompt_text_token_price: 5000, completion_text_token_price: 60000, long_context_threshold: 200000, prompt_text_token_price_long_context: 40000, completion_text_token_price_long_context: 120000, aliases: [] }); expect(m.pricing?.inputPerMillion).toBe(2); expect(m.pricing?.outputPerMillion).toBe(6); expect(m.pricing?.longContext?.inputPerMillion).toBe(4); expect(m.parameters.frequencyPenalty).toBe(false); expect(m.limits?.contextTokens).toBe(500_000); }); it("model keys round-trip", () => { expect(parseModelKey(modelKey("gemini", "gemini-3.8-flash"))).toEqual({ provider: "gemini", id: "gemini-3.8-flash" }); expect(parseModelKey("nope")).toBeNull(); expect(parseModelKey("foo/bar")).toBeNull(); }); }); describe("titles", () => { it("derives a clean title from the first message", () => { expect(deriveTitle("# Hello **world**! How are you? More text.")).toBe("Hello world!"); expect(deriveTitle(" ")).toBeUndefined(); expect(deriveTitle("a".repeat(100))!.length).toBeLessThanOrEqual(64); }); });