pdf.recorded.test.ts 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { LLM, LLMResponse, Message, ToolDefinition, type LanguageModel } from "../../src/index.js"
  4. import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers.js"
  5. import { LLMClient } from "../../src/route.js"
  6. import { Tool } from "../../src/tool.js"
  7. import { runTools } from "../lib/tool-runtime.js"
  8. import { recordedTests } from "../recorded-test.js"
  9. const CODE = "ORCHID-7391"
  10. const PDF =
  11. "JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
  12. const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
  13. const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
  14. const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
  15. const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
  16. const bedrock = AmazonBedrock.configure({
  17. apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
  18. region: process.env.AWS_REGION ?? "us-east-1",
  19. })
  20. const targets: ReadonlyArray<{
  21. readonly id: string
  22. readonly name: string
  23. readonly provider: string
  24. readonly protocol: string
  25. readonly requires: string
  26. readonly filename: string
  27. readonly maxTokens: number
  28. readonly model: LanguageModel
  29. }> = [
  30. {
  31. id: "openai",
  32. name: "OpenAI Responses gpt-4o-mini",
  33. provider: "openai",
  34. protocol: "openai-responses",
  35. requires: "OPENAI_API_KEY",
  36. filename: "verification.pdf",
  37. maxTokens: 40,
  38. model: openai.responses("gpt-4o-mini"),
  39. },
  40. {
  41. id: "anthropic",
  42. name: "Anthropic Haiku 4.5",
  43. provider: "anthropic",
  44. protocol: "anthropic-messages",
  45. requires: "ANTHROPIC_API_KEY",
  46. filename: "verification.pdf",
  47. maxTokens: 40,
  48. model: anthropic.model("claude-haiku-4-5-20251001"),
  49. },
  50. {
  51. id: "gemini",
  52. name: "Gemini 3.5 Flash",
  53. provider: "google",
  54. protocol: "gemini",
  55. requires: "GOOGLE_API_KEY",
  56. filename: "verification.pdf",
  57. maxTokens: 256,
  58. model: google.model("gemini-3.5-flash"),
  59. },
  60. {
  61. id: "xai",
  62. name: "xAI Grok 4.5",
  63. provider: "xai",
  64. protocol: "openai-responses",
  65. requires: "XAI_API_KEY",
  66. filename: "verification.pdf",
  67. maxTokens: 40,
  68. model: xai.responses("grok-4.5"),
  69. },
  70. {
  71. id: "bedrock",
  72. name: "Bedrock Claude Haiku 4.5",
  73. provider: "amazon-bedrock",
  74. protocol: "bedrock-converse",
  75. requires: "AWS_BEDROCK_API_KEY",
  76. filename: "verification",
  77. maxTokens: 40,
  78. model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
  79. },
  80. ]
  81. const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
  82. const prompt = "Return only the verification code from the PDF."
  83. const readPdf = ToolDefinition.make({
  84. name: "read_pdf",
  85. description: "Read the attached PDF.",
  86. inputSchema: { type: "object", properties: {}, additionalProperties: false },
  87. })
  88. const readPdfRuntime = Tool.make({
  89. description: readPdf.description,
  90. parameters: Schema.Struct({ path: Schema.String }),
  91. success: Schema.String,
  92. execute: () => Effect.succeed("PDF read successfully"),
  93. toModelOutput: () => [
  94. { type: "text", text: "PDF read successfully" },
  95. {
  96. type: "file",
  97. uri: `data:application/pdf;base64,${PDF}`,
  98. mime: "application/pdf",
  99. name: "verification.pdf",
  100. },
  101. ],
  102. })
  103. const expectCode = (response: LLMResponse) => {
  104. expect(response.finishReason.normalized).toBe("stop")
  105. expect(response.text.toUpperCase()).toContain(CODE)
  106. }
  107. describe("PDF recorded", () => {
  108. for (const target of targets) {
  109. recorded.effect.with(
  110. `reads a user PDF with ${target.name}`,
  111. {
  112. id: `${target.id}-user-input`,
  113. provider: target.provider,
  114. protocol: target.protocol,
  115. requires: [target.requires],
  116. tags: ["user-input"],
  117. },
  118. Effect.gen(function* () {
  119. expectCode(
  120. yield* LLMClient.generate(
  121. LLM.request({
  122. id: `recorded_pdf_${target.id}_user_input`,
  123. model: target.model,
  124. cache: "none",
  125. generation: { maxTokens: target.maxTokens, temperature: 0 },
  126. messages: [
  127. Message.user([
  128. { type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
  129. { type: "text", text: prompt },
  130. ]),
  131. ],
  132. }),
  133. ),
  134. )
  135. }),
  136. )
  137. recorded.effect.with(
  138. `reads a PDF tool result with ${target.name}`,
  139. {
  140. id: `${target.id}-tool-result`,
  141. provider: target.provider,
  142. protocol: target.protocol,
  143. requires: [target.requires],
  144. tags: ["tool", "tool-result"],
  145. },
  146. Effect.gen(function* () {
  147. if (target.id === "gemini") {
  148. const events = Array.from(
  149. yield* runTools({
  150. request: LLM.request({
  151. id: "recorded_pdf_gemini_tool_result",
  152. model: target.model,
  153. system:
  154. "Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
  155. prompt: "Use read_pdf with path verification.pdf and return the verification code.",
  156. cache: "none",
  157. generation: { maxTokens: target.maxTokens, temperature: 0 },
  158. }),
  159. tools: { read_pdf: readPdfRuntime },
  160. }).pipe(Stream.runCollect),
  161. )
  162. expect(events.at(-1)).toMatchObject({ type: "finish", reason: { normalized: "stop" } })
  163. expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
  164. return
  165. }
  166. expectCode(
  167. yield* LLMClient.generate(
  168. LLM.request({
  169. id: `recorded_pdf_${target.id}_tool_result`,
  170. model: target.model,
  171. system: "Read the PDF returned by the tool and follow the user's response format exactly.",
  172. cache: "none",
  173. generation: { maxTokens: target.maxTokens, temperature: 0 },
  174. messages: [
  175. Message.user(prompt),
  176. Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
  177. Message.tool({
  178. id: "call_pdf_1",
  179. name: readPdf.name,
  180. resultType: "content",
  181. result: [
  182. { type: "text", text: "PDF read successfully" },
  183. {
  184. type: "file",
  185. uri: `data:application/pdf;base64,${PDF}`,
  186. mime: "application/pdf",
  187. name: target.filename,
  188. },
  189. ],
  190. }),
  191. ],
  192. tools: [readPdf],
  193. }),
  194. ),
  195. )
  196. }),
  197. )
  198. }
  199. })