compile.test.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { HttpClientRequest } 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. transform: (request) =>
  143. Effect.sync(() => {
  144. expect(request.headers.authorization).toBe("Bearer fresh-key")
  145. request.url = "https://proxy.test/v1/chat/completions"
  146. request.headers["x-plugin"] = "transformed"
  147. request.body = JSON.stringify({ transformed: true })
  148. }),
  149. },
  150. ).pipe(
  151. Effect.provide(
  152. dynamicResponse((input) =>
  153. Effect.gen(function* () {
  154. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  155. expect(web.url).toBe("https://proxy.test/v1/chat/completions")
  156. expect(web.headers.get("x-plugin")).toBe("transformed")
  157. expect(decodeJson(input.text)).toEqual({ transformed: true })
  158. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  159. headers: { "content-type": "text/event-stream" },
  160. })
  161. }),
  162. ),
  163. ),
  164. ),
  165. )
  166. it.effect("rejects raw body overlays for protocol-owned roots", () =>
  167. Effect.gen(function* () {
  168. const model = OpenAIChat.route
  169. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  170. .model({ id: "gpt-4o-mini" })
  171. const error = yield* compileRequest(
  172. LLM.request({
  173. model,
  174. prompt: "Say hello.",
  175. http: { body: { model: "gpt-5", messages: [], tools: [] } },
  176. }),
  177. ).pipe(Effect.flip)
  178. expect(error.reason).toMatchObject({
  179. _tag: "InvalidRequest",
  180. message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
  181. })
  182. }),
  183. )
  184. it.effect("uses model output limits after route limits and before call maxTokens", () =>
  185. Effect.gen(function* () {
  186. const route = AnthropicMessages.route.with({
  187. endpoint: { baseURL: "https://api.anthropic.test/v1/" },
  188. auth: Auth.header("x-api-key", "test"),
  189. limits: { output: 128 },
  190. })
  191. const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
  192. const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
  193. const withMaxTokens = yield* compileRequest(
  194. LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
  195. )
  196. expect(withoutMaxTokens.body.max_tokens).toBe(64)
  197. expect(withMaxTokens.body.max_tokens).toBe(32)
  198. }),
  199. )
  200. })