openrouter.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. import { describe, expect } from "bun:test"
  2. import { Effect } from "effect"
  3. import { CacheHint, LLM, Message } from "../../src"
  4. import { LLMClient } from "../../src/route"
  5. import { compileRequest } from "../../src/route/client"
  6. import * as OpenRouter from "../../src/providers/openrouter"
  7. import { it } from "../lib/effect"
  8. import { fixedResponse } from "../lib/http"
  9. import { sseEvents } from "../lib/sse"
  10. describe("OpenRouter", () => {
  11. it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
  12. Effect.gen(function* () {
  13. const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
  14. expect(model).toMatchObject({
  15. id: "openai/gpt-4o-mini",
  16. provider: "openrouter",
  17. route: { id: "openrouter" },
  18. })
  19. expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
  20. const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
  21. expect(prepared.route).toBe("openrouter")
  22. expect(prepared.body).toMatchObject({
  23. model: "openai/gpt-4o-mini",
  24. messages: [{ role: "user", content: "Say hello." }],
  25. stream: true,
  26. usage: { include: true },
  27. })
  28. }),
  29. )
  30. it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
  31. Effect.gen(function* () {
  32. const prepared = yield* compileRequest(
  33. LLM.request({
  34. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  35. system: [
  36. { type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
  37. { type: "text", text: "Project instructions" },
  38. ],
  39. tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
  40. prompt: "Hello",
  41. cache: { tools: true, system: true, messages: { tail: 1 } },
  42. }),
  43. )
  44. expect(prepared.body).toMatchObject({
  45. tools: [{ cache_control: { type: "ephemeral" } }],
  46. messages: [
  47. {
  48. role: "system",
  49. content: [
  50. { text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
  51. { text: "Project instructions", cache_control: { type: "ephemeral" } },
  52. ],
  53. },
  54. {
  55. role: "user",
  56. content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
  57. },
  58. ],
  59. })
  60. }),
  61. )
  62. it.effect("lowers manual assistant and tool-result cache hints", () =>
  63. Effect.gen(function* () {
  64. const prepared = yield* compileRequest(
  65. LLM.request({
  66. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  67. cache: "none",
  68. messages: [
  69. Message.user("Call the tool"),
  70. Message.assistant([
  71. { type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
  72. { type: "tool-call", id: "call_1", name: "lookup", input: {} },
  73. ]),
  74. Message.tool({
  75. id: "call_1",
  76. name: "lookup",
  77. result: "Done",
  78. cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
  79. }),
  80. ],
  81. }),
  82. )
  83. expect(prepared.body.messages).toMatchObject([
  84. { role: "user", content: "Call the tool" },
  85. { role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
  86. { role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
  87. ])
  88. }),
  89. )
  90. it.effect("caps manual cache controls at four breakpoints", () =>
  91. Effect.gen(function* () {
  92. const cache = new CacheHint({ type: "ephemeral" })
  93. const prepared = yield* compileRequest(
  94. LLM.request({
  95. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  96. cache: "none",
  97. system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
  98. prompt: "Hello",
  99. }),
  100. )
  101. const system = prepared.body.messages[0]
  102. expect(system?.role).toBe("system")
  103. expect(
  104. system && Array.isArray(system.content)
  105. ? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
  106. : [],
  107. ).toHaveLength(4)
  108. }),
  109. )
  110. it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
  111. Effect.gen(function* () {
  112. const prepared = yield* compileRequest(
  113. LLM.request({
  114. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  115. cache: { messages: "latest-assistant" },
  116. messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
  117. }),
  118. )
  119. expect(prepared.body.messages).toMatchObject([
  120. { role: "user", content: "Think" },
  121. { role: "assistant", cache_control: { type: "ephemeral" } },
  122. ])
  123. }),
  124. )
  125. it.effect("allows usage accounting to be disabled explicitly", () =>
  126. Effect.gen(function* () {
  127. const prepared = yield* compileRequest(
  128. LLM.request({
  129. model: OpenRouter.configure({
  130. apiKey: "test-key",
  131. providerOptions: { openrouter: { usage: false } },
  132. }).model("openai/gpt-4o-mini"),
  133. cache: "none",
  134. prompt: "Hello",
  135. }),
  136. )
  137. expect(prepared.body.usage).toEqual({ include: false })
  138. }),
  139. )
  140. it.effect("applies OpenRouter payload options from the model helper", () =>
  141. Effect.gen(function* () {
  142. const prepared = yield* compileRequest(
  143. LLM.request({
  144. model: OpenRouter.configure({
  145. apiKey: "test-key",
  146. providerOptions: {
  147. openrouter: {
  148. usage: true,
  149. reasoning: { effort: "high" },
  150. models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
  151. provider: { order: ["anthropic", "google"], require_parameters: true },
  152. plugins: [{ id: "response-healing" }],
  153. web_search_options: { engine: "native", max_results: 3 },
  154. debug: { echo_upstream_body: true },
  155. user: "user_123",
  156. future_option: { enabled: true },
  157. },
  158. },
  159. }).model("anthropic/claude-3.7-sonnet:thinking"),
  160. prompt: "Think briefly.",
  161. promptCacheKey: "session_123",
  162. }),
  163. )
  164. expect(prepared.body).toMatchObject({
  165. usage: { include: true },
  166. reasoning: { effort: "high" },
  167. prompt_cache_key: "session_123",
  168. models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
  169. provider: { order: ["anthropic", "google"], require_parameters: true },
  170. plugins: [{ id: "response-healing" }],
  171. web_search_options: { engine: "native", max_results: 3 },
  172. debug: { echo_upstream_body: true },
  173. user: "user_123",
  174. future_option: { enabled: true },
  175. })
  176. }),
  177. )
  178. it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
  179. Effect.gen(function* () {
  180. const invalid: Record<string, unknown> = {
  181. usage: "yes",
  182. models: "anthropic/claude-sonnet-4.6",
  183. provider: [],
  184. plugins: {},
  185. web_search_options: [],
  186. debug: [],
  187. user: 123,
  188. reasoning: [],
  189. promptCacheKey: 123,
  190. future_option: { enabled: true },
  191. }
  192. const prepared = yield* compileRequest(
  193. LLM.request({
  194. model: OpenRouter.configure({
  195. apiKey: "test-key",
  196. providerOptions: { openrouter: invalid },
  197. }).model("openai/gpt-4o-mini"),
  198. prompt: "Hello",
  199. }),
  200. )
  201. expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
  202. expect(prepared.body).not.toHaveProperty("usage")
  203. expect(prepared.body).not.toHaveProperty("models")
  204. expect(prepared.body).not.toHaveProperty("provider")
  205. expect(prepared.body).not.toHaveProperty("plugins")
  206. expect(prepared.body).not.toHaveProperty("web_search_options")
  207. expect(prepared.body).not.toHaveProperty("debug")
  208. expect(prepared.body).not.toHaveProperty("user")
  209. expect(prepared.body).not.toHaveProperty("reasoning")
  210. expect(prepared.body).not.toHaveProperty("prompt_cache_key")
  211. }),
  212. )
  213. it.effect("preserves the upstream provider finish reason", () =>
  214. Effect.gen(function* () {
  215. const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
  216. const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
  217. Effect.provide(
  218. fixedResponse(
  219. sseEvents({
  220. choices: [{ delta: { content: "Hello" }, finish_reason: "stop", native_finish_reason: "end_turn" }],
  221. }),
  222. ),
  223. ),
  224. )
  225. expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
  226. }),
  227. )
  228. it.effect("fails on a mid-stream provider error", () =>
  229. Effect.gen(function* () {
  230. const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
  231. const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
  232. Effect.provide(
  233. fixedResponse(
  234. sseEvents({
  235. error: { code: 502, message: "Provider disconnected" },
  236. }),
  237. ),
  238. ),
  239. Effect.flip,
  240. )
  241. expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
  242. expect(error.message).toContain("Provider disconnected")
  243. }),
  244. )
  245. it.effect("preserves manually supplied reasoning details", () =>
  246. Effect.gen(function* () {
  247. const details = [
  248. { type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
  249. { type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
  250. { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
  251. { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
  252. ]
  253. const prepared = yield* compileRequest(
  254. LLM.request({
  255. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  256. cache: "none",
  257. messages: [
  258. Message.assistant([
  259. {
  260. type: "reasoning",
  261. text: "Thinking",
  262. providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
  263. },
  264. ]),
  265. ],
  266. }),
  267. )
  268. expect(prepared.body.messages).toEqual([
  269. {
  270. role: "assistant",
  271. content: null,
  272. reasoning: "Thinking",
  273. reasoning_details: details,
  274. },
  275. ])
  276. }),
  277. )
  278. it.effect("preserves opaque and duplicate continuation details", () =>
  279. Effect.gen(function* () {
  280. const details = [
  281. { type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
  282. { type: "reasoning.encrypted", id: "state", data: "opaque" },
  283. { type: "reasoning.encrypted", id: "state", data: "opaque" },
  284. ]
  285. const prepared = yield* compileRequest(
  286. LLM.request({
  287. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  288. cache: "none",
  289. messages: [
  290. Message.assistant({
  291. type: "reasoning",
  292. text: "Thinking",
  293. providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
  294. }),
  295. ],
  296. }),
  297. )
  298. expect(prepared.body.messages).toEqual([
  299. { role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
  300. ])
  301. }),
  302. )
  303. it.effect("does not merge distinct adjacent reasoning text blocks", () =>
  304. Effect.gen(function* () {
  305. const details = [
  306. { type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
  307. { type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
  308. ]
  309. const prepared = yield* compileRequest(
  310. LLM.request({
  311. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  312. cache: "none",
  313. messages: [
  314. Message.assistant({
  315. type: "reasoning",
  316. text: "AB",
  317. providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
  318. }),
  319. ],
  320. }),
  321. )
  322. expect(prepared.body.messages).toEqual([
  323. { role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
  324. ])
  325. }),
  326. )
  327. it.effect("omits scalar reasoning without continuation details", () =>
  328. Effect.gen(function* () {
  329. const prepared = yield* compileRequest(
  330. LLM.request({
  331. model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
  332. cache: "none",
  333. messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
  334. }),
  335. )
  336. expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
  337. }),
  338. )
  339. })