recorded-scenarios.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { LLM, LLMEvent, LLMResponse, Message, ToolChoice, ToolDefinition, type LLMRequest, type Model } from "../src"
  4. import { LLMClient } from "../src/route"
  5. import { tool } from "../src/tool"
  6. export const weatherToolName = "get_weather"
  7. // A deterministic system prompt long enough to clear every supported provider's
  8. // minimum cacheable-prefix threshold (Anthropic Haiku 3.5: 2048 tokens; Anthropic
  9. // Opus/Haiku 4.5: 4096 tokens; OpenAI/Gemini/Bedrock: lower). Built by repeating
  10. // a fixed sentence — the cassette replays bit-for-bit, so the exact text matters
  11. // only when re-recording with `RECORD=true`.
  12. export const LARGE_CACHEABLE_SYSTEM = (() => {
  13. const sentence = "You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. "
  14. // ~100 chars per sentence × 250 repeats ≈ 25,000 chars ≈ 5k+ tokens, safely
  15. // above every provider's threshold.
  16. return sentence.repeat(250)
  17. })()
  18. export const weatherTool = ToolDefinition.make({
  19. name: weatherToolName,
  20. description: "Get current weather for a city.",
  21. inputSchema: {
  22. type: "object",
  23. properties: { city: { type: "string" } },
  24. required: ["city"],
  25. additionalProperties: false,
  26. },
  27. })
  28. export const weatherRuntimeTool = tool({
  29. description: weatherTool.description,
  30. parameters: Schema.Struct({ city: Schema.String }),
  31. success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
  32. execute: ({ city }) =>
  33. Effect.succeed(
  34. city === "Paris" ? { temperature: 22, condition: "sunny" } : { temperature: 0, condition: "unknown" },
  35. ),
  36. })
  37. export const textRequest = (input: {
  38. readonly id: string
  39. readonly model: Model
  40. readonly prompt?: string
  41. readonly maxTokens?: number
  42. readonly temperature?: number | false
  43. }) =>
  44. LLM.request({
  45. id: input.id,
  46. model: input.model,
  47. system: "You are concise.",
  48. prompt: input.prompt ?? "Reply with exactly: Hello!",
  49. cache: "none",
  50. providerOptions:
  51. input.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
  52. generation:
  53. input.temperature === false
  54. ? { maxTokens: input.maxTokens ?? 80 }
  55. : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
  56. })
  57. export const weatherToolRequest = (input: {
  58. readonly id: string
  59. readonly model: Model
  60. readonly maxTokens?: number
  61. readonly temperature?: number | false
  62. }) =>
  63. LLM.request({
  64. id: input.id,
  65. model: input.model,
  66. system: "Call tools exactly as requested.",
  67. prompt: "Call get_weather with city exactly Paris.",
  68. tools: [weatherTool],
  69. toolChoice: ToolChoice.make(weatherTool),
  70. cache: "none",
  71. generation:
  72. input.temperature === false
  73. ? { maxTokens: input.maxTokens ?? 80 }
  74. : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
  75. })
  76. export const weatherToolLoopRequest = (input: {
  77. readonly id: string
  78. readonly model: Model
  79. readonly system?: string
  80. readonly maxTokens?: number
  81. readonly temperature?: number | false
  82. }) =>
  83. LLM.request({
  84. id: input.id,
  85. model: input.model,
  86. system: input.system ?? "Use the get_weather tool, then answer in one short sentence.",
  87. prompt: "What is the weather in Paris?",
  88. cache: "none",
  89. generation:
  90. input.temperature === false
  91. ? { maxTokens: input.maxTokens ?? 80 }
  92. : { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
  93. })
  94. export const goldenWeatherToolLoopRequest = (input: {
  95. readonly id: string
  96. readonly model: Model
  97. readonly maxTokens?: number
  98. readonly temperature?: number | false
  99. }) =>
  100. weatherToolLoopRequest({
  101. ...input,
  102. system: "Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.",
  103. })
  104. const RESTROOM_IMAGE_TEXT = "jiggling restroom prison"
  105. const restroomImage = () =>
  106. Effect.promise(() => Bun.file(new URL("./fixtures/media/restroom.png", import.meta.url)).bytes()).pipe(
  107. Effect.map((bytes) => Buffer.from(bytes).toString("base64")),
  108. )
  109. export const imageRequest = (input: {
  110. readonly id: string
  111. readonly model: Model
  112. readonly image: string
  113. readonly maxTokens?: number
  114. readonly temperature?: number | false
  115. }) =>
  116. LLM.request({
  117. id: input.id,
  118. model: input.model,
  119. system: "Read images carefully. Reply only with the visible text.",
  120. messages: [
  121. Message.user([
  122. {
  123. type: "text",
  124. text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.",
  125. },
  126. { type: "media", mediaType: "image/png", data: input.image },
  127. ]),
  128. ],
  129. cache: "none",
  130. generation:
  131. input.temperature === false
  132. ? { maxTokens: input.maxTokens ?? 20 }
  133. : { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 },
  134. })
  135. export const reasoningRequest = (input: {
  136. readonly id: string
  137. readonly model: Model
  138. readonly maxTokens?: number
  139. readonly temperature?: number | false
  140. }) =>
  141. LLM.request({
  142. id: input.id,
  143. model: input.model,
  144. system: "Show concise reasoning when the provider supports visible reasoning summaries.",
  145. prompt: "Think briefly, then reply exactly with: Hello!",
  146. cache: "none",
  147. providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } },
  148. generation:
  149. input.temperature === false
  150. ? { maxTokens: input.maxTokens ?? 120 }
  151. : { maxTokens: input.maxTokens ?? 120, temperature: input.temperature ?? 0 },
  152. })
  153. export const runWeatherToolLoop = (request: LLMRequest) =>
  154. LLMClient.stream({
  155. request,
  156. tools: { [weatherToolName]: weatherRuntimeTool },
  157. stopWhen: LLMClient.stepCountIs(10),
  158. }).pipe(
  159. Stream.runCollect,
  160. Effect.map((events) => Array.from(events)),
  161. )
  162. export const expectFinish = (
  163. events: ReadonlyArray<LLMEvent>,
  164. reason: Extract<LLMEvent, { readonly type: "finish" }>["reason"],
  165. ) => expect(events.at(-1)).toMatchObject({ type: "finish", reason })
  166. export const expectWeatherToolCall = (response: LLMResponse) =>
  167. expect(response.toolCalls).toMatchObject([
  168. { type: "tool-call", id: expect.any(String), name: weatherToolName, input: { city: "Paris" } },
  169. ])
  170. export const expectWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  171. const finishes = events.filter(LLMEvent.is.finish)
  172. expect(finishes).toHaveLength(1)
  173. expect(finishes[0]?.reason).toBe("stop")
  174. const stepFinishes = events.filter(LLMEvent.is.stepFinish)
  175. expect(stepFinishes.map((event) => event.reason)).toEqual(["tool-calls", "stop"])
  176. const toolCalls = events.filter(LLMEvent.is.toolCall)
  177. expect(toolCalls).toHaveLength(1)
  178. expect(toolCalls[0]).toMatchObject({ type: "tool-call", name: weatherToolName, input: { city: "Paris" } })
  179. const toolResults = events.filter(LLMEvent.is.toolResult)
  180. expect(toolResults).toHaveLength(1)
  181. expect(toolResults[0]).toMatchObject({
  182. type: "tool-result",
  183. name: weatherToolName,
  184. result: { type: "json", value: { temperature: 22, condition: "sunny" } },
  185. })
  186. const output = LLMResponse.text({ events })
  187. expect(output).toContain("Paris")
  188. expect(output.trim().length).toBeGreaterThan(0)
  189. }
  190. export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) => {
  191. expectWeatherToolLoop(events)
  192. expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
  193. }
  194. export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" | "image" | "reasoning"
  195. export interface GoldenScenarioContext {
  196. readonly id: string
  197. readonly model: Model
  198. readonly maxTokens?: number
  199. readonly temperature?: number | false
  200. }
  201. const generate = (request: LLMRequest) => LLMClient.generate(request)
  202. const normalizeImageText = (value: string) =>
  203. value
  204. .toLowerCase()
  205. .replace(/[^a-z\s]/g, "")
  206. .replace(/\s+/g, " ")
  207. .trim()
  208. export const goldenScenarioTags = (id: GoldenScenarioID) => {
  209. if (id === "text") return ["text", "golden"]
  210. if (id === "tool-call") return ["tool", "tool-call", "golden"]
  211. if (id === "image") return ["media", "image", "vision", "golden"]
  212. if (id === "reasoning") return ["reasoning", "golden"]
  213. return ["tool", "tool-loop", "golden"]
  214. }
  215. export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) =>
  216. Effect.gen(function* () {
  217. if (id === "text") {
  218. const response = yield* generate(
  219. textRequest({
  220. id: context.id,
  221. model: context.model,
  222. prompt: "Reply exactly with: Hello!",
  223. maxTokens: context.maxTokens ?? 40,
  224. temperature: context.temperature,
  225. }),
  226. )
  227. expect(response.text.trim()).toMatch(/^Hello!?$/)
  228. expectFinish(response.events, "stop")
  229. return
  230. }
  231. if (id === "tool-call") {
  232. const response = yield* generate(
  233. weatherToolRequest({
  234. id: context.id,
  235. model: context.model,
  236. maxTokens: context.maxTokens ?? 80,
  237. temperature: context.temperature,
  238. }),
  239. )
  240. expectWeatherToolCall(response)
  241. expectFinish(response.events, "tool-calls")
  242. return
  243. }
  244. if (id === "image") {
  245. const response = yield* generate(
  246. imageRequest({
  247. id: context.id,
  248. model: context.model,
  249. image: yield* restroomImage(),
  250. maxTokens: context.maxTokens ?? 20,
  251. temperature: context.temperature,
  252. }),
  253. )
  254. expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
  255. expectFinish(response.events, "stop")
  256. return
  257. }
  258. if (id === "reasoning") {
  259. const response = yield* generate(
  260. reasoningRequest({
  261. id: context.id,
  262. model: context.model,
  263. maxTokens: context.maxTokens ?? 120,
  264. temperature: context.temperature,
  265. }),
  266. )
  267. expect(response.text.trim()).toMatch(/^Hello!?$/)
  268. expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0)
  269. expectFinish(response.events, "stop")
  270. return
  271. }
  272. expectGoldenWeatherToolLoop(
  273. yield* runWeatherToolLoop(
  274. goldenWeatherToolLoopRequest({
  275. id: context.id,
  276. model: context.model,
  277. maxTokens: context.maxTokens ?? 80,
  278. temperature: context.temperature,
  279. }),
  280. ),
  281. )
  282. })
  283. const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
  284. if (!usage) return undefined
  285. return Object.fromEntries(
  286. [
  287. ["inputTokens", usage.inputTokens],
  288. ["outputTokens", usage.outputTokens],
  289. ["reasoningTokens", usage.reasoningTokens],
  290. ["cacheReadInputTokens", usage.cacheReadInputTokens],
  291. ["cacheWriteInputTokens", usage.cacheWriteInputTokens],
  292. ["totalTokens", usage.totalTokens],
  293. ].filter((entry) => entry[1] !== undefined),
  294. )
  295. }
  296. const pushText = (summary: Array<Record<string, unknown>>, type: "text" | "reasoning", value: string) => {
  297. const last = summary.at(-1)
  298. if (last?.type === type) {
  299. last.value = `${typeof last.value === "string" ? last.value : ""}${value}`
  300. return
  301. }
  302. summary.push({ type, value })
  303. }
  304. export const eventSummary = (events: ReadonlyArray<LLMEvent>) => {
  305. const summary: Array<Record<string, unknown>> = []
  306. for (const event of events) {
  307. if (event.type === "text-delta") {
  308. pushText(summary, "text", event.text)
  309. continue
  310. }
  311. if (event.type === "reasoning-delta") {
  312. pushText(summary, "reasoning", event.text)
  313. continue
  314. }
  315. if (event.type === "tool-call") {
  316. summary.push({
  317. type: "tool-call",
  318. name: event.name,
  319. input: event.input,
  320. providerExecuted: event.providerExecuted,
  321. })
  322. continue
  323. }
  324. if (event.type === "tool-result") {
  325. summary.push({
  326. type: "tool-result",
  327. name: event.name,
  328. result: event.result,
  329. providerExecuted: event.providerExecuted,
  330. })
  331. continue
  332. }
  333. if (event.type === "tool-error") {
  334. summary.push({ type: "tool-error", name: event.name, message: event.message })
  335. continue
  336. }
  337. if (event.type === "finish") {
  338. summary.push({ type: "finish", reason: event.reason, usage: usageSummary(event.usage) })
  339. }
  340. }
  341. return summary.map((item) => Object.fromEntries(Object.entries(item).filter((entry) => entry[1] !== undefined)))
  342. }