llm.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import { describe, expect, test } from "bun:test"
  2. import { CacheHint, LLM, LLMResponse } from "../src"
  3. import * as OpenAIChat from "../src/protocols/openai-chat"
  4. import * as OpenAIResponses from "../src/protocols/openai-responses"
  5. import {
  6. GenerationOptions,
  7. LLMRequest,
  8. Message,
  9. LanguageModel,
  10. ToolCallPart,
  11. ToolChoice,
  12. ToolDefinition,
  13. ToolResultPart,
  14. } from "../src/schema"
  15. const chatRoute = OpenAIChat.route
  16. const responsesRoute = OpenAIResponses.route
  17. describe("llm constructors", () => {
  18. test("builds canonical schema classes from ergonomic input", () => {
  19. const request = LLM.request({
  20. id: "req_1",
  21. model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
  22. system: "You are concise.",
  23. prompt: "Say hello.",
  24. })
  25. expect(request).toBeInstanceOf(LLMRequest)
  26. expect(request.model).toBeInstanceOf(LanguageModel)
  27. expect(request.messages[0]).toBeInstanceOf(Message)
  28. expect(request.system).toEqual([{ type: "text", text: "You are concise." }])
  29. expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }])
  30. expect(request.generation).toBeUndefined()
  31. expect(request.tools).toEqual([])
  32. })
  33. test("updates requests without spreading schema class instances", () => {
  34. const base = LLM.request({
  35. id: "req_1",
  36. model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
  37. prompt: "Say hello.",
  38. })
  39. const updated = LLMRequest.update(base, {
  40. generation: GenerationOptions.make({ maxTokens: 20 }),
  41. messages: [...base.messages, Message.assistant("Hi.")],
  42. })
  43. expect(updated).toBeInstanceOf(LLMRequest)
  44. expect(updated.id).toBe("req_1")
  45. expect(updated.model).toEqual(base.model)
  46. expect(updated.generation).toEqual({ maxTokens: 20 })
  47. expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"])
  48. })
  49. test("keeps request options separate from route defaults", () => {
  50. const request = LLM.request({
  51. model: LanguageModel.make({
  52. id: "fake-model",
  53. provider: "fake",
  54. route: chatRoute.with({
  55. generation: { maxTokens: 100, temperature: 1 },
  56. providerOptions: { openai: { store: false, metadata: { model: true } } },
  57. http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
  58. }),
  59. }),
  60. prompt: "Say hello.",
  61. generation: { temperature: 0 },
  62. providerOptions: { openai: { store: true, metadata: { request: true } } },
  63. http: { body: { metadata: { request: true } }, headers: { "x-shared": "request" }, query: { request: "1" } },
  64. })
  65. expect(request.generation).toEqual({ temperature: 0 })
  66. expect(request.providerOptions).toEqual({ openai: { store: true, metadata: { request: true } } })
  67. expect(request.http).toEqual({
  68. body: { metadata: { request: true } },
  69. headers: { "x-shared": "request" },
  70. query: { request: "1" },
  71. })
  72. })
  73. test("updates canonical requests from the request datatype", () => {
  74. const base = LLM.request({
  75. id: "req_1",
  76. model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
  77. prompt: "Say hello.",
  78. })
  79. const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] })
  80. expect(updated).toBeInstanceOf(LLMRequest)
  81. expect(updated.id).toBe("req_1")
  82. expect(LLMRequest.input(updated).id).toBe("req_1")
  83. expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"])
  84. expect(LLMRequest.update(updated, {})).toBe(updated)
  85. })
  86. test("updates canonical models from the model datatype", () => {
  87. const base = LanguageModel.make({
  88. id: "fake-model",
  89. provider: "fake",
  90. route: chatRoute,
  91. })
  92. const updated = LanguageModel.update(base, {
  93. route: responsesRoute,
  94. defaults: { generation: { maxTokens: 20 } },
  95. compatibility: { toolSchema: "gemini", requireFinishReason: false },
  96. })
  97. const updatedInput = LanguageModel.input(updated)
  98. expect(updated).toBeInstanceOf(LanguageModel)
  99. expect(String(updated.id)).toBe("fake-model")
  100. expect(updated.route).toBe(responsesRoute)
  101. expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
  102. expect(updated.compatibility).toEqual({ toolSchema: "gemini", requireFinishReason: false })
  103. expect(updatedInput.defaults).toBe(updated.defaults)
  104. expect(updatedInput.compatibility).toBe(updated.compatibility)
  105. expect(String(updatedInput.provider)).toBe("fake")
  106. expect(LanguageModel.update(updated, {})).toBe(updated)
  107. })
  108. test("carries model defaults and compatibility through route model selection", () => {
  109. const model = chatRoute.model({
  110. id: "kimi-k2",
  111. defaults: {
  112. limits: { context: 128_000, output: 8_192 },
  113. generation: { maxTokens: 1_024, stop: ["END"] },
  114. providerOptions: { openai: { parallelToolCalls: false } },
  115. http: { body: { extra_body: true } },
  116. },
  117. compatibility: { toolSchema: "moonshot" },
  118. })
  119. const request = LLM.request({ model, prompt: "Say hello." })
  120. expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
  121. expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
  122. expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } })
  123. expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
  124. expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" })
  125. expect(request.generation).toBeUndefined()
  126. expect(request.providerOptions).toBeUndefined()
  127. expect(request.http).toBeUndefined()
  128. })
  129. test("builds tool choices from names and tools", () => {
  130. const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })
  131. expect(tool).toBeInstanceOf(ToolDefinition)
  132. expect(ToolChoice.make("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" }))
  133. expect(ToolChoice.named("required")).toEqual(new ToolChoice({ type: "tool", name: "required" }))
  134. expect(ToolChoice.make(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" }))
  135. })
  136. test("builds tool choice modes from reserved strings", () => {
  137. expect(ToolChoice.make("auto")).toEqual(new ToolChoice({ type: "auto" }))
  138. expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" }))
  139. expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
  140. expect(
  141. LLM.request({
  142. model: LanguageModel.make({
  143. id: "fake-model",
  144. provider: "fake",
  145. route: chatRoute,
  146. }),
  147. prompt: "Use tools if needed.",
  148. toolChoice: "required",
  149. }).toolChoice,
  150. ).toEqual(new ToolChoice({ type: "required" }))
  151. })
  152. test("builds assistant tool calls and tool result messages", () => {
  153. const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })
  154. const result = ToolResultPart.make({ id: "call_1", name: "lookup", result: { temperature: 72 } })
  155. expect(Message.assistant([call]).content).toEqual([call])
  156. expect(Message.tool(result).content).toEqual([
  157. { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } },
  158. ])
  159. })
  160. test("builds chronological text-only system updates separately from the initial system prompt", () => {
  161. const update = Message.system([
  162. { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
  163. ])
  164. const request = LLM.request({
  165. model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
  166. system: "Initial operator prompt.",
  167. messages: [Message.user("Review this."), update],
  168. })
  169. expect(update).toBeInstanceOf(Message)
  170. expect(update).toEqual({
  171. role: "system",
  172. content: [{ type: "text", text: "Use parameterized SQL.", cache: { type: "ephemeral" } }],
  173. })
  174. expect(request.system).toEqual([{ type: "text", text: "Initial operator prompt." }])
  175. expect(request.messages.map((message) => message.role)).toEqual(["user", "system"])
  176. })
  177. test("extracts output text from response events", () => {
  178. expect(
  179. LLMResponse.text({
  180. events: [
  181. { type: "text-delta", id: "text-0", text: "hi" },
  182. { type: "finish", reason: { normalized: "stop" } },
  183. ],
  184. }),
  185. ).toBe("hi")
  186. })
  187. })