import { describe, it, expect } from "vitest"; import { humanizeChatError, retryPhrase, secondsLeft } from "@/lib/chat/humanize-error"; import { splitStreamingMarkdown, normalizeMath, endsInsideFence, extensionForLanguage } from "@/lib/chat/markdown-blocks"; import { deprecationNotice, suggestReplacement, largerContextModel } from "@/lib/chat/deprecation"; import { chatRequestSchema, chatAdoptSchema } from "@/lib/chat/schemas"; import { toStoredError, stripJsonFence } from "@/lib/chat/service"; import type { PolyModel } from "@/lib/client/types"; const caps = { text: true, vision: false, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: false, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false }; const mk = (key: string, extra: Partial = {}): PolyModel => ({ key, id: key.split("/")[1], provider: key.split("/")[0] as PolyModel["provider"], displayName: key.split("/")[1], capabilities: caps, parameters: {}, status: "active", pricing: { inputPerMillion: 1, outputPerMillion: 2 }, ...extra }); describe("humanizeChatError", () => { it("writes provider-specific rate limit copy with the retry delay", () => { const h = humanizeChatError({ code: "RATE_LIMITED", provider: "openai", retryAfterMs: 18_000, retryable: true, message: "429" }); expect(h.title).toBe("OpenAI rate limit reached."); expect(h.description).toContain("Retry in 18 seconds."); expect(h.canRetry).toBe(true); expect(h.retryAfterMs).toBe(18_000); }); it("points invalid keys and quota problems to the Providers settings", () => { expect(humanizeChatError({ code: "INVALID_API_KEY", provider: "anthropic" }).suggestProviders).toBe(true); expect(humanizeChatError({ code: "INSUFFICIENT_CREDITS", provider: "gemini" }).title).toBe("Google Gemini quota exhausted."); expect(humanizeChatError({ code: "INVALID_API_KEY", provider: "anthropic" }).canRetry).toBe(false); }); it("covers outage, context, parameter and timeout codes", () => { expect(humanizeChatError({ code: "PROVIDER_UNAVAILABLE", provider: "xai" }).title).toBe("xAI is having an outage."); expect(humanizeChatError({ code: "CONTEXT_TOO_LONG" }).suggestSwitch).toBe(true); expect(humanizeChatError({ code: "INVALID_PARAMETER" }).description).toMatch(/Reset the model configuration/); expect(humanizeChatError({ code: "REQUEST_TIMEOUT", provider: "mistral" }).title).toBe("Mistral timed out."); }); it("falls back for unknown codes and keeps diagnostics", () => { const h = humanizeChatError({ code: "WEIRD", provider: "cerebras", providerCode: "x_y", status: 418, message: "teapot" }, { requestId: "req_1" }); expect(h.title).toBe("Cerebras returned an unexpected error."); expect(h.details).toMatchObject({ code: "WEIRD", providerCode: "x_y", status: 418, requestId: "req_1", raw: "teapot" }); }); it("formats retry phrases and countdowns", () => { expect(retryPhrase(null)).toBe("Retry in a moment."); expect(retryPhrase(1000)).toBe("Retry in 1 second."); expect(retryPhrase(90_000)).toMatch(/about 2 minutes/); expect(secondsLeft(5000, 1000, 2000)).toBe(4); expect(secondsLeft(5000, 1000, 7000)).toBeNull(); expect(secondsLeft(null, 0, 0)).toBeNull(); }); }); describe("splitStreamingMarkdown", () => { it("cuts completed paragraphs and keeps the tail live", () => { const { blocks, tail } = splitStreamingMarkdown("Para one.\n\nPara two.\n\nPara thr"); expect(blocks).toEqual(["Para one.", "Para two."]); expect(tail).toBe("Para thr"); }); it("never cuts inside a code fence", () => { const { blocks, tail } = splitStreamingMarkdown("Intro\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); expect(blocks).toEqual(["Intro"]); expect(tail).toContain("const b = 2;"); expect(endsInsideFence("```\nx")).toBe(true); expect(endsInsideFence("```\nx\n```")).toBe(false); }); it("keeps loose lists, quotes and indented continuations together", () => { expect(splitStreamingMarkdown("1. a\n\n2. b\n\n3. c").blocks).toEqual([]); expect(splitStreamingMarkdown("> q1\n\n> q2\n\nnext").blocks).toEqual(["> q1\n\n> q2"]); expect(splitStreamingMarkdown("- item\n\n continued\n\nAfter").blocks).toEqual(["- item\n\n continued"]); }); it("round-trips: blocks + tail reproduce the text modulo blank lines", () => { const text = "# Title\n\nSome text.\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\nEnd"; const { blocks, tail } = splitStreamingMarkdown(text); expect([...blocks, tail].join("\n\n")).toBe(text); }); it("honours a minimum tail length", () => { const { blocks, tail } = splitStreamingMarkdown("A.\n\nB.\n\nC", { minTailChars: 4 }); expect(blocks).toEqual(["A."]); expect(tail).toBe("B.\n\nC"); }); }); describe("normalizeMath", () => { it("rewrites LaTeX delimiters outside code", () => { expect(normalizeMath("Euler: \\(e^{i\\pi}+1=0\\) and \\[\\int_0^1 x\\,dx\\]")).toBe("Euler: $e^{i\\pi}+1=0$ and $$\n\\int_0^1 x\\,dx\n$$"); expect(normalizeMath("`\\(keep\\)` and ```\n\\[keep\\]\n```")).toBe("`\\(keep\\)` and ```\n\\[keep\\]\n```"); expect(normalizeMath("plain")).toBe("plain"); }); it("maps languages to file extensions", () => { expect(extensionForLanguage("typescript")).toBe("ts"); expect(extensionForLanguage("unknownlang")).toBe("txt"); }); }); describe("deprecation helpers", () => { const now = Date.UTC(2026, 8, 11); it("detects deprecated and soon-retiring models", () => { expect(deprecationNotice(mk("openai/a"), now)).toBeNull(); expect(deprecationNotice(mk("openai/a", { status: "deprecated" }), now)?.kind).toBe("deprecated"); expect(deprecationNotice(mk("openai/a", { metadata: { shutdownDate: "2026-10-01" } }), now)?.kind).toBe("retiring"); expect(deprecationNotice(mk("openai/a", { metadata: { shutdownDate: "2027-10-01" } }), now)).toBeNull(); }); it("suggests the newest same-provider active model, same family first", () => { const old = mk("openai/gpt-4o", { status: "deprecated", family: "gpt-4" }); const models = [old, mk("anthropic/claude", { metadata: { sortWeight: 99 } }), mk("openai/gpt-5", { family: "gpt-5", metadata: { sortWeight: 50 } }), mk("openai/gpt-4.1", { family: "gpt-4", metadata: { sortWeight: 10 } }), mk("openai/o1", { status: "deprecated" })]; expect(suggestReplacement(old, models)?.key).toBe("openai/gpt-4.1"); expect(suggestReplacement(mk("openai/x", { status: "deprecated" }), models)?.key).toBe("openai/gpt-5"); expect(suggestReplacement(old, models, new Set(["anthropic"]))).toBeNull(); }); it("finds a larger-context model", () => { const cur = mk("openai/small", { limits: { contextTokens: 128_000 } }); const models = [cur, mk("openai/big", { limits: { contextTokens: 400_000 } }), mk("gemini/huge", { limits: { contextTokens: 1_000_000 } })]; expect(largerContextModel(cur, models, new Set(["openai", "gemini"]))?.key).toBe("openai/big"); expect(largerContextModel(cur, models, new Set(["gemini"]))?.key).toBe("gemini/huge"); expect(largerContextModel(mk("gemini/huge", { limits: { contextTokens: 1_000_000 } }), models, new Set(["openai", "gemini"]))).toBeNull(); }); }); describe("chat schemas", () => { it("accepts ephemeral requests with history and projectId", () => { const parsed = chatRequestSchema.parse({ modelKey: "openai/gpt-5", message: { text: "hi" }, ephemeral: true, history: [{ role: "user", content: "a" }, { role: "assistant", content: "b" }], projectId: "prj_1" }); expect(parsed.ephemeral).toBe(true); expect(parsed.history?.length).toBe(2); expect(parsed.action).toBe("send"); }); it("validates adopt payloads", () => { expect(chatAdoptSchema.safeParse({ modelKey: "openai/gpt-5", conversationId: "cnv_1", content: "answer" }).success).toBe(true); expect(chatAdoptSchema.safeParse({ modelKey: "openai/gpt-5", arenaResponseId: "arr_1", userText: "prompt" }).success).toBe(true); expect(chatAdoptSchema.safeParse({ modelKey: "openai/gpt-5", conversationId: "cnv_1" }).success).toBe(false); expect(chatAdoptSchema.safeParse({ modelKey: "openai/gpt-5", content: "x" }).success).toBe(false); }); it("stores safe error diagnostics", () => { const e = toStoredError({ code: "RATE_LIMITED", message: "429 Too Many Requests", provider: "openai", status: 429, retryable: true, retryAfterMs: 18_000, providerCode: "rate_limit_exceeded" }); expect(e).toMatchObject({ code: "RATE_LIMITED", provider: "openai", status: 429, retryAfterMs: 18_000, providerCode: "rate_limit_exceeded", detail: "429 Too Many Requests" }); expect(e.message).toMatch(/rate-limiting/); expect(stripJsonFence("```json\n{\"a\":1}\n```")).toBe('{"a":1}'); }); });