generate-object.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { LLM } from "../src"
  4. import * as OpenAIChat from "../src/protocols/openai-chat"
  5. import { Tool, toDefinitions } from "../src/tool"
  6. import { it } from "./lib/effect"
  7. import { dynamicResponse } from "./lib/http"
  8. import { finishChunk, toolCallChunk } from "./lib/openai-chunks"
  9. import { sseEvents } from "./lib/sse"
  10. type OpenAIChatBody = {
  11. readonly tool_choice?: unknown
  12. readonly tools?: ReadonlyArray<{
  13. readonly function: {
  14. readonly parameters: unknown
  15. }
  16. }>
  17. }
  18. const model = OpenAIChat.model({
  19. id: "gpt-4o-mini",
  20. baseURL: "https://api.openai.test/v1/",
  21. headers: { authorization: "Bearer test" },
  22. })
  23. const Json = Schema.fromJsonString(Schema.Unknown)
  24. const decodeJson = Schema.decodeUnknownSync(Json)
  25. const decodeBody = (text: string): OpenAIChatBody => decodeJson(text) as OpenAIChatBody
  26. describe("Tool.make (dynamic JSON Schema)", () => {
  27. test("forwards JSON Schema and description through toDefinitions", () => {
  28. const jsonSchema = {
  29. type: "object" as const,
  30. properties: { city: { type: "string" } },
  31. required: ["city"],
  32. }
  33. const lookup = Tool.make({
  34. description: "Look up something",
  35. jsonSchema,
  36. execute: () => Effect.succeed({ ok: true }),
  37. })
  38. const [definition] = toDefinitions({ lookup })
  39. expect(definition?.name).toBe("lookup")
  40. expect(definition?.description).toBe("Look up something")
  41. expect(definition?.inputSchema).toEqual(jsonSchema)
  42. })
  43. test("execute receives the raw input untouched", async () => {
  44. const seen: unknown[] = []
  45. const tool = Tool.make({
  46. description: "echo",
  47. jsonSchema: { type: "object" },
  48. execute: (params) =>
  49. Effect.sync(() => {
  50. seen.push(params)
  51. return { ok: true }
  52. }),
  53. })
  54. const result = await Effect.runPromise(tool.execute({ hello: "world" }))
  55. expect(seen).toEqual([{ hello: "world" }])
  56. expect(result).toEqual({ ok: true })
  57. })
  58. })
  59. describe("LLM.generateObject", () => {
  60. it.effect("forces a synthetic tool call and decodes the input", () =>
  61. Effect.gen(function* () {
  62. const bodies: OpenAIChatBody[] = []
  63. const layer = dynamicResponse((input) =>
  64. Effect.sync(() => {
  65. bodies.push(decodeBody(input.text))
  66. return input.respond(
  67. sseEvents(
  68. toolCallChunk("call_1", "generate_object", '{"city":"Paris","temp":22}'),
  69. finishChunk("tool_calls"),
  70. ),
  71. { headers: { "content-type": "text/event-stream" } },
  72. )
  73. }),
  74. )
  75. const response = yield* LLM.generateObject({
  76. model,
  77. prompt: "Return a structured weather report.",
  78. schema: Schema.Struct({ city: Schema.String, temp: Schema.Number }),
  79. }).pipe(Effect.provide(layer))
  80. expect(response.object).toEqual({ city: "Paris", temp: 22 })
  81. expect(response.response.toolCalls).toHaveLength(1)
  82. expect(bodies).toHaveLength(1)
  83. expect(bodies[0].tool_choice).toEqual({ type: "function", function: { name: "generate_object" } })
  84. const tool = bodies[0].tools?.[0]
  85. expect(bodies[0].tools).toHaveLength(1)
  86. expect(tool).toMatchObject({
  87. type: "function",
  88. function: { name: "generate_object" },
  89. })
  90. const params = tool?.function.parameters as {
  91. readonly type?: unknown
  92. readonly required?: unknown
  93. readonly properties?: Record<string, unknown>
  94. }
  95. expect(params.type).toBe("object")
  96. expect(params.required).toEqual(["city", "temp"])
  97. expect(params.properties?.city).toMatchObject({ type: "string" })
  98. expect(params.properties?.temp).toBeDefined()
  99. }),
  100. )
  101. it.effect("accepts a raw JSON Schema and returns the input untouched", () =>
  102. Effect.gen(function* () {
  103. const bodies: OpenAIChatBody[] = []
  104. const layer = dynamicResponse((input) =>
  105. Effect.sync(() => {
  106. bodies.push(decodeBody(input.text))
  107. return input.respond(
  108. sseEvents(toolCallChunk("call_1", "generate_object", '{"name":"Ada","age":30}'), finishChunk("tool_calls")),
  109. { headers: { "content-type": "text/event-stream" } },
  110. )
  111. }),
  112. )
  113. const response = yield* LLM.generateObject({
  114. model,
  115. prompt: "Extract the user.",
  116. jsonSchema: {
  117. type: "object",
  118. properties: { name: { type: "string" }, age: { type: "number" } },
  119. required: ["name", "age"],
  120. },
  121. }).pipe(Effect.provide(layer))
  122. expect(response.object).toEqual({ name: "Ada", age: 30 })
  123. expect(bodies[0].tools?.[0]?.function.parameters).toEqual({
  124. type: "object",
  125. properties: { name: { type: "string" }, age: { type: "number" } },
  126. required: ["name", "age"],
  127. })
  128. }),
  129. )
  130. it.effect("fails when the model does not call the synthetic tool", () =>
  131. Effect.gen(function* () {
  132. const layer = dynamicResponse((input) =>
  133. Effect.sync(() =>
  134. input.respond(sseEvents({ id: "x", choices: [{ delta: { content: "no thanks" }, finish_reason: "stop" }] }), {
  135. headers: { "content-type": "text/event-stream" },
  136. }),
  137. ),
  138. )
  139. const exit = yield* LLM.generateObject({
  140. model,
  141. prompt: "Return a structured value.",
  142. schema: Schema.Struct({ value: Schema.Number }),
  143. }).pipe(Effect.provide(layer), Effect.exit)
  144. expect(exit._tag).toBe("Failure")
  145. }),
  146. )
  147. it.effect("fails with a decode error when the tool input does not match the schema", () =>
  148. Effect.gen(function* () {
  149. const layer = dynamicResponse((input) =>
  150. Effect.sync(() =>
  151. input.respond(
  152. sseEvents(
  153. toolCallChunk("call_1", "generate_object", '{"value":"not-a-number"}'),
  154. finishChunk("tool_calls"),
  155. ),
  156. { headers: { "content-type": "text/event-stream" } },
  157. ),
  158. ),
  159. )
  160. const exit = yield* LLM.generateObject({
  161. model,
  162. prompt: "Return a structured value.",
  163. schema: Schema.Struct({ value: Schema.Number }),
  164. }).pipe(Effect.provide(layer), Effect.exit)
  165. expect(exit._tag).toBe("Failure")
  166. }),
  167. )
  168. })