header-timeout.test.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { afterEach, expect } from "bun:test"
  2. import { createServer, type Server } from "node:http"
  3. import { streamText } from "ai"
  4. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  5. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  6. import { Effect } from "effect"
  7. import { ProviderV2 } from "@kirincode-ai/core/provider"
  8. import { ModelV2 } from "@kirincode-ai/core/model"
  9. import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. import { testProviderConfig } from "../lib/test-provider"
  12. import { Env } from "@/env"
  13. import { Plugin } from "@/plugin"
  14. import { Provider } from "@/provider/provider"
  15. import { ProviderError } from "@/provider/error"
  16. afterEach(async () => {
  17. await disposeAllInstances()
  18. })
  19. const it = testEffect(
  20. LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node])),
  21. )
  22. it.live("headerTimeout does not abort delayed SSE body after headers arrive", () =>
  23. Effect.gen(function* () {
  24. const server = yield* Effect.acquireRelease(
  25. Effect.promise(() => delayedBodyServer(1_000)),
  26. (server) => Effect.sync(() => server.server.close()),
  27. )
  28. yield* provideTmpdirInstance(
  29. () =>
  30. Effect.gen(function* () {
  31. const provider = yield* Provider.Service
  32. const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
  33. const result = streamText({
  34. model: yield* provider.getLanguage(model),
  35. messages: [{ role: "user", content: "hello" }],
  36. })
  37. expect(yield* Effect.promise(() => result.text)).toBe("late")
  38. }),
  39. { config: providerConfig(server.url, { headerTimeout: 500 }) },
  40. )
  41. }),
  42. )
  43. it.live("chunkTimeout raises a response stream error when SSE body stalls", () =>
  44. Effect.gen(function* () {
  45. const server = yield* Effect.acquireRelease(
  46. Effect.promise(() => delayedBodyServer(250)),
  47. (server) => Effect.sync(() => server.server.close()),
  48. )
  49. yield* provideTmpdirInstance(
  50. () =>
  51. Effect.gen(function* () {
  52. const provider = yield* Provider.Service
  53. const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
  54. const result = streamText({
  55. model: yield* provider.getLanguage(model),
  56. onError() {},
  57. messages: [{ role: "user", content: "hello" }],
  58. })
  59. const error = yield* Effect.promise(async () => {
  60. try {
  61. for await (const part of result.fullStream) {
  62. if (part.type === "error") return part.error
  63. }
  64. } catch (error) {
  65. return error
  66. }
  67. })
  68. expect(error).toBeInstanceOf(ProviderError.ResponseStreamError)
  69. }),
  70. { config: providerConfig(server.url, { chunkTimeout: 50 }) },
  71. )
  72. }),
  73. )
  74. it.live("headerTimeout aborts when response headers do not arrive", () =>
  75. Effect.gen(function* () {
  76. const server = yield* Effect.acquireRelease(
  77. Effect.promise(() => delayedHeaderServer(250)),
  78. (server) => Effect.sync(() => server.server.close()),
  79. )
  80. yield* provideTmpdirInstance(
  81. () =>
  82. Effect.gen(function* () {
  83. const provider = yield* Provider.Service
  84. const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
  85. const result = streamText({
  86. model: yield* provider.getLanguage(model),
  87. onError() {},
  88. messages: [{ role: "user", content: "hello" }],
  89. })
  90. const errors = yield* Effect.promise(async () => {
  91. const errors: string[] = []
  92. for await (const part of result.fullStream) {
  93. if (part.type === "error") errors.push(String(part.error))
  94. }
  95. return errors
  96. })
  97. expect(errors.join("\n")).toContain("response headers timed out")
  98. }),
  99. { config: providerConfig(server.url, { headerTimeout: 50 }) },
  100. )
  101. }),
  102. )
  103. it.live("headerTimeout is opt-in for non-OpenAI providers", () =>
  104. Effect.gen(function* () {
  105. const server = yield* Effect.acquireRelease(
  106. Effect.promise(() => delayedHeaderServer(100)),
  107. (server) => Effect.sync(() => server.server.close()),
  108. )
  109. yield* provideTmpdirInstance(
  110. () =>
  111. Effect.gen(function* () {
  112. const provider = yield* Provider.Service
  113. const model = yield* provider.getModel(ProviderV2.ID.make("test"), ModelV2.ID.make("test-model"))
  114. const result = streamText({
  115. model: yield* provider.getLanguage(model),
  116. messages: [{ role: "user", content: "hello" }],
  117. })
  118. expect(yield* Effect.promise(() => result.text)).toBe("ok")
  119. }),
  120. { config: providerConfig(server.url) },
  121. )
  122. }),
  123. )
  124. it.live("OpenAI Codex headerTimeout default can be disabled by config", () =>
  125. Effect.gen(function* () {
  126. yield* withAuthContent(
  127. Effect.gen(function* () {
  128. yield* provideTmpdirInstance(
  129. () =>
  130. Effect.gen(function* () {
  131. const provider = yield* Provider.Service
  132. const openai = yield* provider.getProvider(ProviderV2.ID.openai)
  133. expect(openai.options.headerTimeout).toBe(false)
  134. }),
  135. { config: { provider: { openai: { options: { headerTimeout: false } } } } },
  136. )
  137. }),
  138. )
  139. }),
  140. )
  141. it.live("OpenAI API auth gets default headerTimeout", () =>
  142. Effect.gen(function* () {
  143. yield* withAuthContent(
  144. Effect.gen(function* () {
  145. yield* provideTmpdirInstance(() =>
  146. Effect.gen(function* () {
  147. const provider = yield* Provider.Service
  148. const openai = yield* provider.getProvider(ProviderV2.ID.openai)
  149. expect(openai.options.headerTimeout).toBe(10_000)
  150. }),
  151. )
  152. }),
  153. { openai: { type: "api", key: "sk-test" } },
  154. )
  155. }),
  156. )
  157. function providerConfig(url: string, options: Record<string, unknown> = {}) {
  158. const config = testProviderConfig(url)
  159. return {
  160. ...config,
  161. provider: {
  162. test: {
  163. ...config.provider.test,
  164. options: { ...config.provider.test.options, ...options },
  165. },
  166. },
  167. }
  168. }
  169. async function delayedHeaderServer(delay: number): Promise<{ server: Server; url: string }> {
  170. const server = createServer((_, res) => {
  171. setTimeout(() => {
  172. res.writeHead(200, { "content-type": "text/event-stream" })
  173. res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n')
  174. }, delay)
  175. })
  176. await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
  177. const address = server.address()
  178. if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
  179. return { server, url: `http://127.0.0.1:${address.port}` }
  180. }
  181. async function delayedBodyServer(delay: number): Promise<{ server: Server; url: string }> {
  182. const server = createServer((_, res) => {
  183. res.writeHead(200, { "content-type": "text/event-stream" })
  184. res.flushHeaders()
  185. setTimeout(() => {
  186. res.end('data: {"choices":[{"delta":{"content":"late"}}]}\n\ndata: [DONE]\n\n')
  187. }, delay)
  188. })
  189. await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
  190. const address = server.address()
  191. if (!address || typeof address === "string") throw new Error("server did not bind to a TCP port")
  192. return { server, url: `http://127.0.0.1:${address.port}` }
  193. }
  194. function withAuthContent<A, E, R>(self: Effect.Effect<A, E, R>, value: Record<string, unknown> = defaultAuthContent()) {
  195. return Effect.acquireUseRelease(
  196. Effect.sync(() => {
  197. const previous = process.env.KIRINCODE_AUTH_CONTENT
  198. process.env.KIRINCODE_AUTH_CONTENT = JSON.stringify(value)
  199. return previous
  200. }),
  201. () => self,
  202. (previous) =>
  203. Effect.sync(() => {
  204. if (previous === undefined) delete process.env.KIRINCODE_AUTH_CONTENT
  205. else process.env.KIRINCODE_AUTH_CONTENT = previous
  206. }),
  207. )
  208. }
  209. function defaultAuthContent() {
  210. return {
  211. openai: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
  212. }
  213. }