recorded-scenarios.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { LLM, LLMEvent, LLMResponse, type LLMRequest, type ModelRef } from "../src"
  4. import { LLMClient } from "../src/route"
  5. import { tool } from "../src/tool"
  6. export const weatherToolName = "get_weather"
  7. export const weatherTool = LLM.toolDefinition({
  8. name: weatherToolName,
  9. description: "Get current weather for a city.",
  10. inputSchema: {
  11. type: "object",
  12. properties: { city: { type: "string" } },
  13. required: ["city"],
  14. additionalProperties: false,
  15. },
  16. })
  17. export const weatherRuntimeTool = tool({
  18. description: weatherTool.description,
  19. parameters: Schema.Struct({ city: Schema.String }),
  20. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  21. execute: ({ city }) =>
  22. Effect.succeed(
  23. city === "Paris" ? { temperature: 22, condition: "sunny" } : { temperature: 0, condition: "unknown" },
  24. ),
  25. })
  26. export const textRequest = (input: {
  27. readonly id: string
  28. readonly model: ModelRef
  29. readonly prompt?: string
  30. readonly maxTokens?: number
  31. readonly temperature?: number | false
  32. }) =>
  33. LLM.request({
  34. id: input.id,
  35. model: input.model,
  36. system: "You are concise.",
  37. prompt: input.prompt ?? "Reply with exactly: Hello!",
  38. generation:
  39. input.temperature === false
  40. ? { maxTokens: input.maxTokens ?? 20 }
  41. : { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 },
  42. })
  43. export const weatherToolRequest = (input: {
  44. readonly id: string
  45. readonly model: ModelRef
  46. readonly maxTokens?: number
  47. readonly temperature?: number | false
  48. }) =>
  49. LLM.request({
  50. id: input.id,
  51. model: input.model,
  52. system: "Call tools exactly as requested.",
  53. prompt: "Call get_weather with city exactly Paris.",
  54. tools: [weatherTool],
  55. toolChoice: LLM.toolChoice(weatherTool),
  56. generation:
  57. input.temperature === false
  58. ? { maxTokens: input.maxTokens ?? 80 }
  59. : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
  60. })
  61. export const weatherToolLoopRequest = (input: {
  62. readonly id: string
  63. readonly model: ModelRef
  64. readonly system?: string
  65. readonly maxTokens?: number
  66. readonly temperature?: number | false
  67. }) =>
  68. LLM.request({
  69. id: input.id,
  70. model: input.model,
  71. system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
  72. prompt: "What is the weather in Paris?",
  73. generation:
  74. input.temperature === false
  75. ? { maxTokens: input.maxTokens ?? 80 }
  76. : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
  77. })
  78. export const goldenWeatherToolLoopRequest = (input: {
  79. readonly id: string
  80. readonly model: ModelRef
  81. readonly maxTokens?: number
  82. readonly temperature?: number | false
  83. }) =>
  84. weatherToolLoopRequest({
  85. ...input,
  86. system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.",
  87. })
  88. export const runWeatherToolLoop = (request: LLMRequest) =>
  89. LLMClient.stream({
  90. request,
  91. tools: { [weatherToolName]: weatherRuntimeTool },
  92. stopWhen: LLMClient.stepCountIs(10),
  93. }).pipe(
  94. Stream.runCollect,
  95. Effect.map((events) => Array.from(events)),
  96. )
  97. export const expectFinish = (
  98. events: ReadonlyArray<LLMEvent>,
  99. reason: Extract<LLMEvent, { readonly type: "request-finish" }>["reason"],
  100. ) => expect(events.at(-1)).toMatchObject({ type: "request-finish", reason })
  101. export const expectWeatherToolCall = (response: LLMResponse) =>
  102. expect(response.toolCalls).toMatchObject([
  103. { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } },
  104. ])
  105. export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  106. const finishes = events.filter(LLMEvent.is.requestFinish)
  107. expect(finishes).toHaveLength(2)
  108. expect(finishes[0]?.reason).toBe("tool-calls")
  109. expect(finishes.at(-1)?.reason).toBe("stop")
  110. const toolCalls = events.filter(LLMEvent.is.toolCall)
  111. expect(toolCalls).toHaveLength(1)
  112. expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } })
  113. const toolResults = events.filter(LLMEvent.is.toolResult)
  114. expect(toolResults).toHaveLength(1)
  115. expect(toolResults[0]).toMatchObject({
  116. type: "tool-result",
  117. name: weatherToolName,
  118. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  119. })
  120. const output = LLMResponse.text({ events })
  121. expect(output).toContain("Paris")
  122. expect(output.trim().length).toBeGreaterThan(0)
  123. }
  124. export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  125. expectWeatherToolLoop(events)
  126. expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
  127. }
  128. export type GoldenScenarioID = "text" | "tool-call" | "tool-loop"
  129. export interface GoldenScenarioContext {
  130. readonly id: string
  131. readonly model: ModelRef
  132. readonly maxTokens?: number
  133. readonly temperature?: number | false
  134. }
  135. const generate = (request: LLMRequest) => LLMClient.generate(request)
  136. export const goldenScenarioTags = (id: GoldenScenarioID) => {
  137. if (id === "text") return ["text", "golden"]
  138. if (id === "tool-call") return ["tool", "tool-call", "golden"]
  139. return ["tool", "tool-loop", "golden"]
  140. }
  141. export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) =>
  142. Effect.gen(function* () {
  143. if (id === "text") {
  144. const response = yield* generate(
  145. textRequest({
  146. id: context.id,
  147. model: context.model,
  148. prompt: "Reply exactly with: Hello!",
  149. maxTokens: context.maxTokens ?? 40,
  150. temperature: context.temperature,
  151. }),
  152. )
  153. expect(response.text.trim()).toMatch(/^Hello!?$/)
  154. expectFinish(response.events, "stop")
  155. return
  156. }
  157. if (id === "tool-call") {
  158. const response = yield* generate(
  159. weatherToolRequest({
  160. id: context.id,
  161. model: context.model,
  162. maxTokens: context.maxTokens ?? 80,
  163. temperature: context.temperature,
  164. }),
  165. )
  166. expectWeatherToolCall(response)
  167. expectFinish(response.events, "tool-calls")
  168. return
  169. }
  170. expectGoldenWeatherToolLoop(
  171. yield* runWeatherToolLoop(
  172. goldenWeatherToolLoopRequest({
  173. id: context.id,
  174. model: context.model,
  175. maxTokens: context.maxTokens ?? 80,
  176. temperature: context.temperature,
  177. }),
  178. ),
  179. )
  180. })
  181. const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
  182. if (!usage) return undefined
  183. return Object.fromEntries(
  184. [
  185. ["inputTokens", usage.inputTokens],
  186. ["outputTokens", usage.outputTokens],
  187. ["reasoningTokens", usage.reasoningTokens],
  188. ["cacheReadInputTokens", usage.cacheReadInputTokens],
  189. ["cacheWriteInputTokens", usage.cacheWriteInputTokens],
  190. ["totalTokens", usage.totalTokens],
  191. ].filter((entry) => entry[1] !== undefined),
  192. )
  193. }
  194. const pushText = (summary: Array<Record<string, unknown>>, type: "text" | "reasoning", value: string) => {
  195. const last = summary.at(-1)
  196. if (last?.type === type) {
  197. last.value = `${last.value ?? ""}${value}`
  198. return
  199. }
  200. summary.push({ type, value })
  201. }
  202. export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
  203. const summary: Array<Record<string, unknown>> = []
  204. for (const event of events) {
  205. if (event.type === "text-delta") {
  206. pushText(summary, "text", event.text)
  207. continue
  208. }
  209. if (event.type === "reasoning-delta") {
  210. pushText(summary, "reasoning", event.text)
  211. continue
  212. }
  213. if (event.type === "tool-call") {
  214. summary.push({
  215. type: "tool-call",
  216. name: event.name,
  217. input: event.input,
  218. providerExecuted: event.providerExecuted,
  219. })
  220. continue
  221. }
  222. if (event.type === "tool-result") {
  223. summary.push({
  224. type: "tool-result",
  225. name: event.name,
  226. result: event.result,
  227. providerExecuted: event.providerExecuted,
  228. })
  229. continue
  230. }
  231. if (event.type === "tool-error") {
  232. summary.push({ type: "tool-error", name: event.name, message: event.message })
  233. continue
  234. }
  235. if (event.type === "request-finish") {
  236. summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
  237. }
  238. }
  239. return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
  240. }