generate-object.test.ts 6.2 KB

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