compile.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Ref, Schema } from "effect"
  3. import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  4. import { LLM, mergeProviderOptions } from "../src"
  5. import { AnthropicMessages, OpenAIChat } from "../src/protocols"
  6. import { Auth, LLMClient } from "../src/route"
  7. import { compileRequest } from "../src/route/client"
  8. import { it } from "./lib/effect"
  9. import { dynamicResponse } from "./lib/http"
  10. import { deltaChunk } from "./lib/openai-chunks"
  11. import { sseEvents } from "./lib/sse"
  12. const TargetJson = Schema.fromJsonString(Schema.Unknown)
  13. const decodeJson = Schema.decodeUnknownSync(TargetJson)
  14. describe("request option precedence", () => {
  15. test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
  16. const merged = mergeProviderOptions(
  17. {
  18. openai: {
  19. include: ["route"],
  20. metadata: { route: true, shared: "route" },
  21. nullable: "route",
  22. primitive: "route",
  23. },
  24. },
  25. {
  26. openai: {
  27. include: ["model"],
  28. metadata: { model: true, shared: "model" },
  29. nullable: null,
  30. primitive: "model",
  31. },
  32. },
  33. { openai: { metadata: { request: true }, primitive: false } },
  34. )
  35. expect(merged).toEqual({
  36. openai: {
  37. include: ["model"],
  38. metadata: { route: true, model: true, request: true, shared: "model" },
  39. nullable: null,
  40. primitive: false,
  41. },
  42. })
  43. })
  44. it.effect("compiles bodies with route defaults, model defaults, and call options in order", () =>
  45. Effect.gen(function* () {
  46. const route = OpenAIChat.route.with({
  47. endpoint: { baseURL: "https://api.openai.test/v1/" },
  48. auth: Auth.bearer("test"),
  49. generation: { maxTokens: 10, temperature: 1, stop: ["route"] },
  50. providerOptions: { openai: { store: false, reasoningEffort: "low" } },
  51. })
  52. const model = route.model({
  53. id: "gpt-4o-mini",
  54. defaults: {
  55. generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] },
  56. providerOptions: { openai: { reasoningEffort: "medium" } },
  57. },
  58. })
  59. const prepared = yield* compileRequest(
  60. LLM.request({
  61. model,
  62. prompt: "Say hello.",
  63. generation: { maxTokens: 30, topP: 0.9, stop: ["request"] },
  64. providerOptions: { openai: { store: true } },
  65. }),
  66. )
  67. expect(prepared.body).toMatchObject({
  68. model: "gpt-4o-mini",
  69. stream: true,
  70. max_tokens: 30,
  71. temperature: 0.5,
  72. top_p: 0.9,
  73. frequency_penalty: 0.25,
  74. store: true,
  75. reasoning_effort: "medium",
  76. })
  77. expect(prepared.body.stop).toEqual(["request"])
  78. }),
  79. )
  80. it.effect("applies model HTTP defaults before request HTTP overlays", () =>
  81. LLMClient.generate(
  82. LLM.request({
  83. model: OpenAIChat.route
  84. .with({
  85. endpoint: { baseURL: "https://api.openai.test/v1/" },
  86. auth: Auth.bearer("fresh-key"),
  87. http: {
  88. body: { metadata: { route: true, shared: "route" }, value: "route" },
  89. headers: { "x-route": "route", "x-shared": "route" },
  90. query: { route: "1", shared: "route" },
  91. },
  92. })
  93. .model({
  94. id: "gpt-4o-mini",
  95. defaults: {
  96. http: {
  97. body: { metadata: { model: true, shared: "model" }, value: "model" },
  98. headers: { "x-model": "model", "x-shared": "model" },
  99. query: { model: "1", shared: "model" },
  100. },
  101. },
  102. }),
  103. prompt: "Say hello.",
  104. http: {
  105. body: { metadata: { request: true }, value: null },
  106. headers: { "x-request": "request" },
  107. query: { request: "1" },
  108. },
  109. }),
  110. ).pipe(
  111. Effect.provide(
  112. dynamicResponse((input) =>
  113. Effect.gen(function* () {
  114. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  115. expect(web.url).toBe("https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1")
  116. expect(web.headers.get("authorization")).toBe("Bearer fresh-key")
  117. expect(web.headers.get("x-route")).toBe("route")
  118. expect(web.headers.get("x-model")).toBe("model")
  119. expect(web.headers.get("x-request")).toBe("request")
  120. expect(web.headers.get("x-shared")).toBe("model")
  121. expect(decodeJson(input.text)).toMatchObject({
  122. metadata: { route: true, model: true, request: true, shared: "model" },
  123. value: null,
  124. })
  125. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  126. headers: { "content-type": "text/event-stream" },
  127. })
  128. }),
  129. ),
  130. ),
  131. ),
  132. )
  133. it.effect("transforms the final HTTP request after serialization and authentication", () =>
  134. LLMClient.generate(
  135. LLM.request({
  136. model: OpenAIChat.route
  137. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("fresh-key") })
  138. .model({ id: "gpt-4o-mini" }),
  139. prompt: "Say hello.",
  140. }),
  141. {
  142. http: (request, handler) =>
  143. Effect.gen(function* () {
  144. return yield* handler(
  145. request.pipe(
  146. HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"),
  147. HttpClientRequest.setMethod("PUT"),
  148. HttpClientRequest.setHeader("x-plugin", "transformed"),
  149. HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"),
  150. ),
  151. )
  152. }),
  153. },
  154. ).pipe(
  155. Effect.provide(
  156. dynamicResponse((input) =>
  157. Effect.gen(function* () {
  158. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  159. expect(web.url).toBe("https://proxy.test/v1/chat/completions")
  160. expect(web.method).toBe("PUT")
  161. expect(web.headers.get("x-plugin")).toBe("transformed")
  162. expect(web.headers.get("content-type")).toBe("application/custom+json")
  163. expect(decodeJson(input.text)).toEqual({ transformed: true })
  164. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  165. headers: { "content-type": "text/event-stream" },
  166. })
  167. }),
  168. ),
  169. ),
  170. ),
  171. )
  172. it.effect("transforms the HTTP response before protocol decoding", () =>
  173. Effect.gen(function* () {
  174. const response = yield* LLMClient.generate(
  175. LLM.request({
  176. model: OpenAIChat.route
  177. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  178. .model({ id: "gpt-4o-mini" }),
  179. prompt: "Say hello.",
  180. }),
  181. {
  182. http: (request, handler) =>
  183. Effect.gen(function* () {
  184. const response = yield* handler(request)
  185. return HttpClientResponse.fromWeb(
  186. response.request,
  187. new Response((yield* response.text).replace("network", "hooked"), {
  188. status: response.status,
  189. headers: response.headers,
  190. }),
  191. )
  192. }),
  193. },
  194. ).pipe(
  195. Effect.provide(
  196. dynamicResponse((input) =>
  197. Effect.succeed(
  198. input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
  199. headers: { "content-type": "text/event-stream" },
  200. }),
  201. ),
  202. ),
  203. ),
  204. )
  205. expect(response.text).toBe("hooked")
  206. }),
  207. )
  208. it.effect("can inspect an error response and retry the native request", () =>
  209. Effect.gen(function* () {
  210. const attempts = yield* Ref.make(0)
  211. const response = yield* LLMClient.generate(
  212. LLM.request({
  213. model: OpenAIChat.route
  214. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
  215. .model({ id: "gpt-4o-mini" }),
  216. prompt: "Say hello.",
  217. }),
  218. {
  219. http: (request, handler) =>
  220. Effect.gen(function* () {
  221. const response = yield* handler(request)
  222. expect(response.status).toBe(401)
  223. return yield* handler(HttpClientRequest.setHeader(request, "authorization", "Bearer refreshed"))
  224. }),
  225. },
  226. ).pipe(
  227. Effect.provide(
  228. dynamicResponse((input) =>
  229. Effect.gen(function* () {
  230. yield* Ref.update(attempts, (value) => value + 1)
  231. if (input.request.headers.authorization !== "Bearer refreshed")
  232. return input.respond("unauthorized", { status: 401 })
  233. return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
  234. headers: { "content-type": "text/event-stream" },
  235. })
  236. }),
  237. ),
  238. ),
  239. )
  240. expect(response.text).toBe("retried")
  241. expect(yield* Ref.get(attempts)).toBe(2)
  242. }),
  243. )
  244. it.effect("applies raw body overlays after protocol lowering", () =>
  245. LLMClient.generate(
  246. LLM.request({
  247. model: OpenAIChat.route
  248. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  249. .model({ id: "gpt-4o-mini" }),
  250. prompt: "Say hello.",
  251. http: { body: { model: "gpt-5", messages: [], tools: [] } },
  252. }),
  253. ).pipe(
  254. Effect.provide(
  255. dynamicResponse((input) =>
  256. Effect.gen(function* () {
  257. expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
  258. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  259. headers: { "content-type": "text/event-stream" },
  260. })
  261. }),
  262. ),
  263. ),
  264. ),
  265. )
  266. it.effect("uses model output limits after route limits and before call maxTokens", () =>
  267. Effect.gen(function* () {
  268. const route = AnthropicMessages.route.with({
  269. endpoint: { baseURL: "https://api.anthropic.test/v1/" },
  270. auth: Auth.header("x-api-key", "test"),
  271. limits: { output: 128 },
  272. })
  273. const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
  274. const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
  275. const withMaxTokens = yield* compileRequest(
  276. LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
  277. )
  278. expect(withoutMaxTokens.body.max_tokens).toBe(64)
  279. expect(withMaxTokens.body.max_tokens).toBe(32)
  280. }),
  281. )
  282. })