SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.7 KB · 111 lines typescript
Raw Blame History
1import { describe, it, expect } from "vitest";2import { normalizeOpenRouterModel } from "@/lib/ai/providers/openrouter";3import { normalizeMistralModel } from "@/lib/ai/providers/mistral";4import { normalizeDeepSeekModel } from "@/lib/ai/providers/deepseek";5import { normalizeKimiModel } from "@/lib/ai/providers/kimi";6import { normalizeCerebrasModel } from "@/lib/ai/providers/cerebras";7import { normalizeChatCompletionStream, toChatCompletionMessages } from "@/lib/ai/providers/shared/openai-compat/chat-completions";8import { stripJsonFence } from "@/lib/chat/service";9import type { ChatCompletionChunk } from "openai/resources/chat/completions";1011describe("OpenRouter model mapping", () => {12  it("converts per-token USD strings and supported_parameters into a PolyModel", () => {13    const m = normalizeOpenRouterModel({14      id: "openai/gpt-6-astra",15      name: "OpenAI: GPT-6 Astra",16      created: 1788552838,17      context_length: 1050000,18      architecture: { input_modalities: ["file", "image", "text"], output_modalities: ["text"] },19      pricing: { prompt: "0.00001", completion: "0.00005", input_cache_read: "0.000001", overrides: [{ min_prompt_tokens: 272000, prompt: "0.00002", completion: "0.000075" }] },20      top_provider: { context_length: 1050000, max_completion_tokens: 128000, is_moderated: true },21      supported_parameters: ["include_reasoning", "max_completion_tokens", "max_tokens", "reasoning", "reasoning_effort", "response_format", "seed", "structured_outputs", "tool_choice", "tools"],22      reasoning: { mandatory: true, default_enabled: true, supported_efforts: ["max", "xhigh", "high", "medium", "low"], default_effort: "medium" },23    })!;24    expect(m.key).toBe("openrouter/openai/gpt-6-astra");25    expect(m.pricing?.inputPerMillion).toBe(10);26    expect(m.pricing?.outputPerMillion).toBe(50);27    expect(m.pricing?.cachedInputPerMillion).toBe(1);28    expect(m.pricing?.longContext?.thresholdTokens).toBe(272000);29    expect(m.capabilities.vision).toBe(true);30    expect(m.capabilities.files).toBe(true);31    expect(m.capabilities.reasoning).toBe(true);32    expect(m.parameters.temperature).toBe(false); // not in supported_parameters33    expect(m.parameters.seed).toBe(true);34    expect(m.parameters.reasoningEffortLevels).toEqual(["low", "medium", "high", "xhigh", "max"]); // mandatory → no "none"35    expect(m.limits?.maxOutputTokens).toBe(128000);36    expect(m.family).toBe("OpenAI");37  });38  it("hides non-text and expired models", () => {39    expect(normalizeOpenRouterModel({ id: "x/img", name: "img", architecture: { output_modalities: ["image"] } })).toBeNull();40    expect(normalizeOpenRouterModel({ id: "x/old", name: "old", expiration_date: "2020-01-01", architecture: { output_modalities: ["text"] } })).toBeNull();41  });42});4344describe("Mistral model mapping", () => {45  it("uses the listing capabilities and skips non-chat models", () => {46    const m = normalizeMistralModel({ id: "mistral-small-latest", name: "mistral-small-2603", max_context_length: 262144, aliases: ["mistral-small-2603"], capabilities: { completion_chat: true, function_calling: true, vision: true, reasoning: true } })!;47    expect(m.capabilities.vision).toBe(true);48    expect(m.capabilities.reasoning).toBe(true);49    expect(m.limits?.contextTokens).toBe(262144);50    expect(m.metadata?.resolvesTo).toBe("mistral-small-2603");51    expect(normalizeMistralModel({ id: "mistral-embed", capabilities: { completion_chat: false } })).toBeNull();52    expect(normalizeMistralModel({ id: "mistral-ocr-latest", capabilities: { completion_chat: false, ocr: true } })).toBeNull();53  });54});5556describe("id-only listings", () => {57  it("normalizes DeepSeek / Kimi / Cerebras ids with sane defaults", () => {58    expect(normalizeDeepSeekModel({ id: "deepseek-v4-flash-vision-exp" })?.capabilities.vision).toBe(true);59    expect(normalizeDeepSeekModel({ id: "text-embedding" })).toBeNull();60    expect(normalizeKimiModel({ id: "kimi-k3" })?.family).toBe("Kimi K3");61    expect(normalizeKimiModel({ id: "kimi-k2.6" })?.parameters.temperature).toBe(false); // only temperature=1 is accepted62    expect(normalizeKimiModel({ id: "kimi-k3", supports_thinking_type: "only", reasoning_efforts: { support: true, valid_efforts: ["low", "high", "max"] } })?.parameters.reasoningEffortLevels).toEqual(["low", "high", "max"]);63    expect(normalizeCerebrasModel({ id: "gpt-oss-120b" })?.parameters.reasoningEffortLevels).toEqual(["low", "medium", "high"]);64  });65});6667describe("chat-completions normalizer extras", () => {68  async function* fake(chunks: Partial<ChatCompletionChunk>[]): AsyncIterable<ChatCompletionChunk> {69    for (const c of chunks) yield { id: "c", object: "chat.completion.chunk", created: 0, model: "m", choices: [], ...c } as ChatCompletionChunk;70  }71  it("handles Mistral thinking chunks, DeepSeek cache hits and OpenRouter cost", async () => {72    const events = [];73    const stream = fake([74      { choices: [{ index: 0, delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "hmm " }] }, { type: "text", text: "Hi" }] as never }, finish_reason: null }] },75      { choices: [{ index: 0, delta: { reasoning_content: "more" } as never, finish_reason: "stop" }] },76      { choices: [], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110, prompt_cache_hit_tokens: 60, cost: 0.0042 } as never },77    ]);78    for await (const ev of normalizeChatCompletionStream(stream, "openrouter")) events.push(ev);79    const reasoning = events.filter((e) => e.type === "reasoning-delta").map((e) => (e as { text: string }).text).join("");80    const text = events.filter((e) => e.type === "text-delta").map((e) => (e as { text: string }).text).join("");81    expect(reasoning).toBe("hmm more");82    expect(text).toBe("Hi");83    const usage = events.find((e) => e.type === "usage") as { usage: { cachedInputTokens?: number } };84    expect(usage.usage.cachedInputTokens).toBe(60);85    const cost = events.find((e) => e.type === "provider-data") as unknown as { data: { exactCostUsd: number } };86    expect(cost.data.exactCostUsd).toBe(0.0042);87    expect(events.at(-1)).toEqual({ type: "finish", reason: "stop" });88  });89  it("replays reasoning_content on assistant turns when asked", () => {90    const msgs = toChatCompletionMessages(undefined, [{ role: "assistant", content: [{ type: "reasoning", text: "think" }, { type: "text", text: "answer" }] }], { replayReasoningContent: true });91    expect((msgs[0] as unknown as { reasoning_content?: string }).reasoning_content).toBe("think");92    const plain = toChatCompletionMessages(undefined, [{ role: "assistant", content: [{ type: "reasoning", text: "think" }, { type: "text", text: "answer" }] }]);93    expect((plain[0] as unknown as { reasoning_content?: string }).reasoning_content).toBeUndefined();94  });95  it("strips markdown fences around JSON-mode answers", () => {96    expect(stripJsonFence('```json\n{"a":1}\n```')).toBe('{"a":1}');97    expect(stripJsonFence('{"a":1}')).toBe('{"a":1}');98    expect(stripJsonFence("text ```json x``` more")).toBe("text ```json x``` more");99  });100});101102describe("strict schema helper", () => {103  it("adds additionalProperties:false recursively", async () => {104    const { withNoAdditionalProps } = await import("@/lib/ai/providers/shared/openai-compat/factory");105    const out = withNoAdditionalProps({ type: "object", properties: { a: { type: "object", properties: { b: { type: "string" } } }, list: { type: "array", items: { type: "object", properties: { c: { type: "number" } } } } }, required: ["a"] }) as { additionalProperties: boolean; properties: { a: { additionalProperties: boolean }; list: { items: { additionalProperties: boolean } } } };106    expect(out.additionalProperties).toBe(false);107    expect(out.properties.a.additionalProperties).toBe(false);108    expect(out.properties.list.items.additionalProperties).toBe(false);109  });110});111