header-timeout.test.ts 8.0 KB

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