| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- import { describe, expect } from "bun:test"
- import { Effect } from "effect"
- import { CacheHint, LLM, Message } from "../../src/index.js"
- import { LLMClient } from "../../src/route.js"
- import { compileRequest } from "../../src/route/client.js"
- import * as OpenRouter from "../../src/providers/openrouter.js"
- import { it } from "../lib/effect.js"
- import { fixedResponse } from "../lib/http.js"
- import { sseEvents } from "../lib/sse.js"
- describe("OpenRouter", () => {
- it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
- Effect.gen(function* () {
- const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
- expect(model).toMatchObject({
- id: "openai/gpt-4o-mini",
- provider: "openrouter",
- route: { id: "openrouter" },
- })
- expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
- const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
- expect(prepared.route).toBe("openrouter")
- expect(prepared.body).toMatchObject({
- model: "openai/gpt-4o-mini",
- messages: [{ role: "user", content: "Say hello." }],
- stream: true,
- usage: { include: true },
- })
- }),
- )
- it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
- Effect.gen(function* () {
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- system: [
- { type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
- { type: "text", text: "Project instructions" },
- ],
- tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
- prompt: "Hello",
- cache: { tools: true, system: true, messages: { tail: 1 } },
- }),
- )
- expect(prepared.body).toMatchObject({
- tools: [{ cache_control: { type: "ephemeral" } }],
- messages: [
- {
- role: "system",
- content: [
- { text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
- { text: "Project instructions", cache_control: { type: "ephemeral" } },
- ],
- },
- {
- role: "user",
- content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
- },
- ],
- })
- }),
- )
- it.effect("lowers manual assistant and tool-result cache hints", () =>
- Effect.gen(function* () {
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: "none",
- messages: [
- Message.user("Call the tool"),
- Message.assistant([
- { type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
- { type: "tool-call", id: "call_1", name: "lookup", input: {} },
- ]),
- Message.tool({
- id: "call_1",
- name: "lookup",
- result: "Done",
- cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
- }),
- ],
- }),
- )
- expect(prepared.body.messages).toMatchObject([
- { role: "user", content: "Call the tool" },
- { role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
- { role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
- ])
- }),
- )
- it.effect("caps manual cache controls at four breakpoints", () =>
- Effect.gen(function* () {
- const cache = new CacheHint({ type: "ephemeral" })
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: "none",
- system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
- prompt: "Hello",
- }),
- )
- const system = prepared.body.messages[0]
- expect(system?.role).toBe("system")
- expect(
- system && Array.isArray(system.content)
- ? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
- : [],
- ).toHaveLength(4)
- }),
- )
- it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
- Effect.gen(function* () {
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: { messages: "latest-assistant" },
- messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
- }),
- )
- expect(prepared.body.messages).toMatchObject([
- { role: "user", content: "Think" },
- { role: "assistant", cache_control: { type: "ephemeral" } },
- ])
- }),
- )
- it.effect("allows usage accounting to be disabled explicitly", () =>
- Effect.gen(function* () {
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({
- apiKey: "test-key",
- providerOptions: { openrouter: { usage: false } },
- }).model("openai/gpt-4o-mini"),
- cache: "none",
- prompt: "Hello",
- }),
- )
- expect(prepared.body.usage).toEqual({ include: false })
- }),
- )
- it.effect("applies OpenRouter payload options from the model helper", () =>
- Effect.gen(function* () {
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({
- apiKey: "test-key",
- providerOptions: {
- openrouter: {
- usage: true,
- reasoning: { effort: "high" },
- models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
- provider: { order: ["anthropic", "google"], require_parameters: true },
- plugins: [{ id: "response-healing" }],
- web_search_options: { engine: "native", max_results: 3 },
- debug: { echo_upstream_body: true },
- user: "user_123",
- future_option: { enabled: true },
- },
- },
- }).model("anthropic/claude-3.7-sonnet:thinking"),
- prompt: "Think briefly.",
- promptCacheKey: "session_123",
- }),
- )
- expect(prepared.body).toMatchObject({
- usage: { include: true },
- reasoning: { effort: "high" },
- prompt_cache_key: "session_123",
- models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
- provider: { order: ["anthropic", "google"], require_parameters: true },
- plugins: [{ id: "response-healing" }],
- web_search_options: { engine: "native", max_results: 3 },
- debug: { echo_upstream_body: true },
- user: "user_123",
- future_option: { enabled: true },
- })
- }),
- )
- it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
- Effect.gen(function* () {
- const invalid: Record<string, unknown> = {
- usage: "yes",
- models: "anthropic/claude-sonnet-4.6",
- provider: [],
- plugins: {},
- web_search_options: [],
- debug: [],
- user: 123,
- reasoning: [],
- promptCacheKey: 123,
- future_option: { enabled: true },
- }
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({
- apiKey: "test-key",
- providerOptions: { openrouter: invalid },
- }).model("openai/gpt-4o-mini"),
- prompt: "Hello",
- }),
- )
- expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
- expect(prepared.body).not.toHaveProperty("usage")
- expect(prepared.body).not.toHaveProperty("models")
- expect(prepared.body).not.toHaveProperty("provider")
- expect(prepared.body).not.toHaveProperty("plugins")
- expect(prepared.body).not.toHaveProperty("web_search_options")
- expect(prepared.body).not.toHaveProperty("debug")
- expect(prepared.body).not.toHaveProperty("user")
- expect(prepared.body).not.toHaveProperty("reasoning")
- expect(prepared.body).not.toHaveProperty("prompt_cache_key")
- }),
- )
- it.effect("preserves the upstream provider finish reason", () =>
- Effect.gen(function* () {
- const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
- const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
- Effect.provide(
- fixedResponse(
- sseEvents({
- choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
- }),
- ),
- ),
- )
- expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
- }),
- )
- it.effect("fails on a mid-stream provider error", () =>
- Effect.gen(function* () {
- const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
- const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
- Effect.provide(
- fixedResponse(
- sseEvents({
- error: { code: 502, message: "Provider disconnected" },
- }),
- ),
- ),
- Effect.flip,
- )
- expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
- expect(error.message).toContain("Provider disconnected")
- }),
- )
- it.effect("preserves manually supplied reasoning details", () =>
- Effect.gen(function* () {
- const details = [
- { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
- { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
- { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
- { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
- ]
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: "none",
- messages: [
- Message.assistant([
- {
- type: "reasoning",
- text: "Thinking",
- providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
- },
- ]),
- ],
- }),
- )
- expect(prepared.body.messages).toEqual([
- {
- role: "assistant",
- content: null,
- reasoning: "Thinking",
- reasoning_details: details,
- },
- ])
- }),
- )
- it.effect("preserves opaque and duplicate continuation details", () =>
- Effect.gen(function* () {
- const details = [
- { type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
- { type: "reasoning.encrypted", id: "state", data: "opaque" },
- { type: "reasoning.encrypted", id: "state", data: "opaque" },
- ]
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: "none",
- messages: [
- Message.assistant({
- type: "reasoning",
- text: "Thinking",
- providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
- }),
- ],
- }),
- )
- expect(prepared.body.messages).toEqual([
- { role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
- ])
- }),
- )
- it.effect("does not merge distinct adjacent reasoning text blocks", () =>
- Effect.gen(function* () {
- const details = [
- { type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
- { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
- ]
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: "none",
- messages: [
- Message.assistant({
- type: "reasoning",
- text: "AB",
- providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
- }),
- ],
- }),
- )
- expect(prepared.body.messages).toEqual([
- { role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
- ])
- }),
- )
- it.effect("omits scalar reasoning without continuation details", () =>
- Effect.gen(function* () {
- const prepared = yield* compileRequest(
- LLM.request({
- model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
- cache: "none",
- messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
- }),
- )
- expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
- }),
- )
- })
|