adapter.test.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Schema, Stream } from "effect"
  3. import { LLM, LLMRequest, LLMResponse } from "../src"
  4. import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
  5. import { compileRequest } from "../src/route/client"
  6. import { LanguageModel } from "../src/schema"
  7. import { testEffect } from "./lib/effect"
  8. import { dynamicResponse } from "./lib/http"
  9. const updateModel = (model: LanguageModel, patch: Partial<LanguageModel.Input>) => LanguageModel.update(model, patch)
  10. const Json = Schema.fromJsonString(Schema.Unknown)
  11. const encodeJson = Schema.encodeSync(Json)
  12. type FakeBody = {
  13. readonly body: string
  14. }
  15. const FakeEvent = Schema.Union([
  16. Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
  17. Schema.Struct({ type: Schema.Literal("finish"), reason: Schema.Literal("stop") }),
  18. ])
  19. type FakeEvent = Schema.Schema.Type<typeof FakeEvent>
  20. const decodeFakeEvents = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(FakeEvent)))
  21. const fakeFraming: FramingDef<FakeEvent> = {
  22. id: "fake-json-array",
  23. frame: (bytes) =>
  24. Stream.fromEffect(
  25. bytes.pipe(
  26. Stream.decodeText(),
  27. Stream.runFold(
  28. () => "",
  29. (text, event) => text + event,
  30. ),
  31. Effect.flatMap(decodeFakeEvents),
  32. Effect.orDie,
  33. ),
  34. ).pipe(Stream.flatMap(Stream.fromIterable)),
  35. }
  36. const raiseEvent = (event: FakeEvent): import("../src/schema").LLMEvent =>
  37. event.type === "finish"
  38. ? { type: "finish", reason: { normalized: event.reason } }
  39. : { type: "text-delta", id: "text-0", text: event.text }
  40. const fakeProtocol = Protocol.make<FakeBody, FakeEvent, FakeEvent, void>({
  41. id: "fake",
  42. body: {
  43. schema: Schema.Struct({
  44. body: Schema.String,
  45. }),
  46. from: (request) =>
  47. Effect.succeed({
  48. body: [
  49. ...request.messages
  50. .flatMap((message) => message.content)
  51. .filter((part) => part.type === "text")
  52. .map((part) => part.text),
  53. ...request.tools.map((tool) => `tool:${tool.name}:${tool.description}`),
  54. ].join("\n"),
  55. }),
  56. },
  57. stream: {
  58. event: FakeEvent,
  59. initial: () => undefined,
  60. step: (state, event) => Effect.succeed([state, [raiseEvent(event)]] as const),
  61. },
  62. })
  63. const fake = Route.make({
  64. id: "fake",
  65. protocol: fakeProtocol,
  66. endpoint: Endpoint.path("/chat"),
  67. framing: fakeFraming,
  68. })
  69. const configuredFake = fake.with({ endpoint: { baseURL: "https://fake.local" } })
  70. const gemini = Route.make({
  71. id: "gemini-fake",
  72. protocol: fakeProtocol,
  73. endpoint: Endpoint.path("/chat"),
  74. framing: fakeFraming,
  75. })
  76. const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local" } })
  77. const request = LLM.request({
  78. id: "req_1",
  79. model: LanguageModel.make({
  80. id: "fake-model",
  81. provider: "fake-provider",
  82. route: configuredFake,
  83. }),
  84. prompt: "hello",
  85. })
  86. const echoLayer = dynamicResponse(({ text, respond }) =>
  87. Effect.succeed(
  88. respond(
  89. encodeJson([
  90. { type: "text", text: `echo:${text}` },
  91. { type: "finish", reason: "stop" },
  92. ]),
  93. ),
  94. ),
  95. )
  96. const it = testEffect(echoLayer)
  97. const unterminated = testEffect(
  98. dynamicResponse(({ respond }) => Effect.succeed(respond(encodeJson([{ type: "text", text: "partial" }])))),
  99. )
  100. describe("llm route", () => {
  101. it.effect("stream and generate use the route pipeline", () =>
  102. Effect.gen(function* () {
  103. const llm = yield* LLMClient.Service
  104. const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
  105. const response = yield* llm.generate(request)
  106. const reduced = LLMResponse.fromEvents(events)
  107. expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
  108. expect(reduced).toBeDefined()
  109. if (!reduced) throw new Error("stream reducer did not produce a completed response")
  110. expect(response.events).toEqual(events)
  111. expect(response.message).toEqual(reduced.message)
  112. expect(response.usage).toEqual(reduced.usage)
  113. expect(response.finishReason).toEqual(reduced.finishReason)
  114. expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }])
  115. }),
  116. )
  117. unterminated.effect("fails when the normalized stream ends without a terminal event", () =>
  118. Effect.gen(function* () {
  119. const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
  120. expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
  121. expect(error.message).toContain("The provider response ended unexpectedly.")
  122. }),
  123. )
  124. it.effect("selects routes by model route value", () =>
  125. Effect.gen(function* () {
  126. const prepared = yield* compileRequest(
  127. LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
  128. )
  129. expect(prepared.route).toBe("gemini-fake")
  130. }),
  131. )
  132. it.effect("builds models from configured routes", () =>
  133. Effect.gen(function* () {
  134. const configured = fake.with({ provider: "fake-provider", endpoint: { baseURL: "https://fake.local" } })
  135. expect(configured.model({ id: "fake-model" })).toMatchObject({
  136. provider: "fake-provider",
  137. })
  138. }),
  139. )
  140. it.effect("does not register duplicate route ids globally", () =>
  141. Effect.gen(function* () {
  142. const duplicate = Route.make({
  143. id: "fake",
  144. protocol: Protocol.make({
  145. ...fakeProtocol,
  146. body: {
  147. ...fakeProtocol.body,
  148. from: () => Effect.succeed({ body: "late-default" }),
  149. },
  150. }),
  151. endpoint: Endpoint.path("/chat", { baseURL: "https://fake.local" }),
  152. framing: fakeFraming,
  153. })
  154. const prepared = yield* compileRequest(
  155. LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
  156. )
  157. expect(prepared.body).toEqual({ body: "late-default" })
  158. }),
  159. )
  160. })