369 lines
13 KiB
TypeScript
369 lines
13 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Effect } from "effect"
|
|
import { CacheHint, LLM, Message } from "../../src"
|
|
import { LLMClient } from "../../src/route"
|
|
import { compileRequest } from "../../src/route/client"
|
|
import * as OpenRouter from "../../src/providers/openrouter"
|
|
import { it } from "../lib/effect"
|
|
import { fixedResponse } from "../lib/http"
|
|
import { sseEvents } from "../lib/sse"
|
|
|
|
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" },
|
|
promptCacheKey: "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 },
|
|
},
|
|
},
|
|
}).model("anthropic/claude-3.7-sonnet:thinking"),
|
|
prompt: "Think briefly.",
|
|
}),
|
|
)
|
|
|
|
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 }])
|
|
}),
|
|
)
|
|
})
|