openai-chat.test.ts 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Ref, Schema, Stream } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import {
  5. HttpOptions,
  6. LLM,
  7. AIError,
  8. LLMEvent,
  9. LLMRequest,
  10. Message,
  11. LanguageModel,
  12. ToolCallPart,
  13. ToolDefinition,
  14. Usage,
  15. } from "../../src/index.js"
  16. import * as Azure from "../../src/providers/azure.js"
  17. import * as OpenAI from "../../src/providers/openai.js"
  18. import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
  19. import * as XAI from "../../src/providers/xai.js"
  20. import * as OpenAIChat from "../../src/protocols/openai-chat.js"
  21. import { ProviderShared } from "../../src/protocols/shared.js"
  22. import { Auth, LLMClient } from "../../src/route.js"
  23. import { compileRequest } from "../../src/route/client.js"
  24. import { it } from "../lib/effect.js"
  25. import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
  26. import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
  27. import { sseEvents } from "../lib/sse.js"
  28. const TargetJson = Schema.fromJsonString(Schema.Unknown)
  29. const encodeJson = Schema.encodeSync(TargetJson)
  30. const decodeJson = Schema.decodeUnknownSync(TargetJson)
  31. const model = OpenAIChat.route
  32. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  33. .model({ id: "gpt-4o-mini" })
  34. const request = LLM.request({
  35. id: "req_1",
  36. model,
  37. system: "You are concise.",
  38. prompt: "Say hello.",
  39. generation: { maxTokens: 20, temperature: 0 },
  40. })
  41. describe("OpenAI Chat route", () => {
  42. it.effect("prepares OpenAI Chat payload", () =>
  43. Effect.gen(function* () {
  44. const prepared = yield* compileRequest(request)
  45. expect(prepared.body).toEqual({
  46. model: "gpt-4o-mini",
  47. messages: [
  48. { role: "system", content: "You are concise." },
  49. { role: "user", content: "Say hello." },
  50. ],
  51. stream: true,
  52. stream_options: { include_usage: true },
  53. max_tokens: 20,
  54. temperature: 0,
  55. })
  56. }),
  57. )
  58. it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
  59. Effect.gen(function* () {
  60. const prepared = yield* compileRequest(
  61. LLM.request({
  62. model,
  63. messages: [
  64. Message.user("Before."),
  65. Message.system("Treat <admin> & data literally."),
  66. Message.assistant("After."),
  67. ],
  68. }),
  69. )
  70. expect(prepared.body.messages).toEqual([
  71. {
  72. role: "user",
  73. content: "Before.\n<system-update>\nTreat &lt;admin&gt; &amp; data literally.\n</system-update>",
  74. },
  75. { role: "assistant", content: "After." },
  76. ])
  77. }),
  78. )
  79. it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
  80. Effect.gen(function* () {
  81. const prepared = yield* compileRequest(
  82. LLM.request({
  83. model,
  84. messages: [
  85. Message.assistant([
  86. { type: "reasoning", text: "thinking" },
  87. { type: "text", text: "Hello" },
  88. ]),
  89. ],
  90. }),
  91. )
  92. expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
  93. }),
  94. )
  95. it.effect("concatenates assistant text parts without adding separators", () =>
  96. Effect.gen(function* () {
  97. const prepared = yield* compileRequest(
  98. LLM.request({
  99. model,
  100. messages: [
  101. Message.assistant([
  102. { type: "text", text: "Hello" },
  103. { type: "text", text: " world" },
  104. ]),
  105. ],
  106. }),
  107. )
  108. expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello world" }])
  109. }),
  110. )
  111. it.effect("writes reasoning to a configured custom field on every assistant message", () =>
  112. Effect.gen(function* () {
  113. const prepared = yield* compileRequest(
  114. LLM.request({
  115. model: LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
  116. messages: [
  117. Message.assistant([
  118. {
  119. type: "reasoning",
  120. text: "thinking",
  121. providerMetadata: { openai: { reasoningField: "reasoning" } },
  122. },
  123. { type: "text", text: "Hello" },
  124. ]),
  125. Message.assistant("Done"),
  126. ],
  127. }),
  128. )
  129. expect(prepared.body.messages).toEqual([
  130. { role: "assistant", content: "Hello", vendor_reasoning: "thinking" },
  131. { role: "assistant", content: "Done", vendor_reasoning: "" },
  132. ])
  133. }),
  134. )
  135. it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
  136. Effect.gen(function* () {
  137. const error = yield* compileRequest(
  138. LLM.request({
  139. model: LanguageModel.update(model, { compatibility: { reasoningField: "content" } }),
  140. messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
  141. }),
  142. ).pipe(Effect.flip)
  143. expect(error.message).toContain("reserved field content")
  144. }),
  145. )
  146. it.effect("maps OpenAI provider options to Chat options", () =>
  147. Effect.gen(function* () {
  148. const prepared = yield* compileRequest(
  149. LLM.request({
  150. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
  151. prompt: "think",
  152. providerOptions: { openai: { reasoningEffort: "max" } },
  153. }),
  154. )
  155. expect(prepared.body.store).toBe(false)
  156. expect(prepared.body.reasoning_effort).toBe("max")
  157. }),
  158. )
  159. it.effect("maps the request prompt cache key", () =>
  160. Effect.gen(function* () {
  161. const prepared = yield* compileRequest(
  162. LLM.request({
  163. model: OpenAICompatible.configure({
  164. baseURL: "https://api.compatible.test/v1",
  165. apiKey: "test",
  166. }).model("compatible-model"),
  167. prompt: "Hello",
  168. promptCacheKey: "session_123",
  169. }),
  170. )
  171. expect(prepared.body.prompt_cache_key).toBe("session_123")
  172. }),
  173. )
  174. it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
  175. LLMClient.generate(
  176. LLM.request({
  177. model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
  178. prompt: "Hello",
  179. promptCacheKey: "session_123",
  180. }),
  181. ).pipe(
  182. Effect.provide(
  183. dynamicResponse((input) =>
  184. Effect.gen(function* () {
  185. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  186. expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
  187. const body = decodeJson(yield* Effect.promise(() => web.text()))
  188. expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
  189. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  190. headers: { "content-type": "text/event-stream" },
  191. })
  192. }),
  193. ),
  194. ),
  195. ),
  196. )
  197. it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
  198. Effect.gen(function* () {
  199. const prepared = yield* compileRequest(
  200. LLM.request({
  201. model,
  202. prompt: "think",
  203. providerOptions: { openai: { reasoningEffort: "experimental" } },
  204. }),
  205. )
  206. expect(prepared.body.reasoning_effort).toBe("experimental")
  207. }),
  208. )
  209. it.effect("adds native query params to the Chat Completions URL", () =>
  210. LLMClient.generate(
  211. LLMRequest.update(request, {
  212. model: LanguageModel.update(model, {
  213. route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
  214. }),
  215. }),
  216. ).pipe(
  217. Effect.provide(
  218. dynamicResponse((input) =>
  219. Effect.gen(function* () {
  220. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  221. expect(web.url).toBe("https://api.openai.test/v1/chat/completions?api-version=v1")
  222. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  223. headers: { "content-type": "text/event-stream" },
  224. })
  225. }),
  226. ),
  227. ),
  228. ),
  229. )
  230. it.effect("uses Azure api-key header for static OpenAI Chat keys", () =>
  231. LLMClient.generate(
  232. LLMRequest.update(request, {
  233. model: Azure.configure({
  234. baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
  235. apiKey: "azure-key",
  236. headers: { authorization: "Bearer stale" },
  237. }).chat("gpt-4o-mini"),
  238. }),
  239. ).pipe(
  240. Effect.provide(
  241. dynamicResponse((input) =>
  242. Effect.gen(function* () {
  243. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  244. expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/chat/completions?api-version=v1")
  245. expect(web.headers.get("api-key")).toBe("azure-key")
  246. expect(web.headers.get("authorization")).toBeNull()
  247. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  248. headers: { "content-type": "text/event-stream" },
  249. })
  250. }),
  251. ),
  252. ),
  253. ),
  254. )
  255. it.effect("applies serializable HTTP overlays after payload lowering", () =>
  256. LLMClient.generate(
  257. LLMRequest.update(request, {
  258. model: model.route
  259. .with({ auth: Auth.bearer("fresh-key"), headers: { authorization: "Bearer stale" } })
  260. .model({ id: model.id }),
  261. http: HttpOptions.make({
  262. body: { metadata: { source: "test" } },
  263. headers: { authorization: "Bearer request", "x-custom": "yes" },
  264. query: { debug: "1" },
  265. }),
  266. }),
  267. ).pipe(
  268. Effect.provide(
  269. dynamicResponse((input) =>
  270. Effect.gen(function* () {
  271. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  272. expect(web.url).toBe("https://api.openai.test/v1/chat/completions?debug=1")
  273. expect(web.headers.get("authorization")).toBe("Bearer fresh-key")
  274. expect(web.headers.get("x-custom")).toBe("yes")
  275. expect(decodeJson(input.text)).toMatchObject({
  276. stream: true,
  277. stream_options: { include_usage: true },
  278. metadata: { source: "test" },
  279. })
  280. return input.respond(sseEvents(deltaChunk({}, "stop")), {
  281. headers: { "content-type": "text/event-stream" },
  282. })
  283. }),
  284. ),
  285. ),
  286. ),
  287. )
  288. it.effect("prepares assistant tool-call and tool-result messages", () =>
  289. Effect.gen(function* () {
  290. const prepared = yield* compileRequest(
  291. LLM.request({
  292. id: "req_tool_result",
  293. model,
  294. messages: [
  295. Message.user("What is the weather?"),
  296. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  297. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  298. ],
  299. }),
  300. )
  301. expect(prepared.body).toEqual({
  302. model: "gpt-4o-mini",
  303. messages: [
  304. { role: "user", content: "What is the weather?" },
  305. {
  306. role: "assistant",
  307. content: null,
  308. tool_calls: [
  309. {
  310. id: "call_1",
  311. type: "function",
  312. function: { name: "lookup", arguments: encodeJson({ query: "weather" }) },
  313. },
  314. ],
  315. },
  316. { role: "tool", tool_call_id: "call_1", content: encodeJson({ forecast: "sunny" }) },
  317. ],
  318. stream: true,
  319. stream_options: { include_usage: true },
  320. })
  321. }),
  322. )
  323. it.effect("preserves structured tool errors for the model", () =>
  324. Effect.gen(function* () {
  325. const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
  326. const prepared = yield* compileRequest(
  327. LLM.request({
  328. model,
  329. messages: [
  330. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
  331. Message.tool({ id: "call_1", name: "bash", resultType: "error", result: error }),
  332. ],
  333. }),
  334. )
  335. expect(prepared.body.messages.at(-1)).toEqual({
  336. role: "tool",
  337. tool_call_id: "call_1",
  338. content: ProviderShared.encodeJson(error),
  339. })
  340. }),
  341. )
  342. it.effect("continues image tool results as vision input without base64 text", () =>
  343. Effect.gen(function* () {
  344. const prepared = yield* compileRequest(
  345. LLM.request({
  346. model,
  347. messages: [
  348. Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]),
  349. Message.tool({
  350. id: "call_image",
  351. name: "read",
  352. result: {
  353. type: "content",
  354. value: [
  355. { type: "text", text: "Image read successfully" },
  356. { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
  357. ],
  358. },
  359. }),
  360. ],
  361. }),
  362. )
  363. expect(prepared.body.messages).toEqual([
  364. {
  365. role: "assistant",
  366. content: null,
  367. tool_calls: [
  368. {
  369. id: "call_image",
  370. type: "function",
  371. function: { name: "read", arguments: encodeJson({ path: "pixel.png" }) },
  372. },
  373. ],
  374. },
  375. { role: "tool", tool_call_id: "call_image", content: "Image read successfully" },
  376. {
  377. role: "user",
  378. content: [{ type: "image_url", image_url: { url: "data:image/png;base64,AAECAw==" } }],
  379. },
  380. ])
  381. expect(JSON.stringify(prepared.body.messages)).not.toContain('"content":"AAECAw=="')
  382. }),
  383. )
  384. it.effect("orders parallel tool responses before one aggregated vision message", () =>
  385. Effect.gen(function* () {
  386. const prepared = yield* compileRequest(
  387. LLM.request({
  388. model,
  389. messages: [
  390. Message.assistant([
  391. ToolCallPart.make({ id: "call_1", name: "read", input: {} }),
  392. ToolCallPart.make({ id: "call_2", name: "read", input: {} }),
  393. ]),
  394. Message.make({
  395. role: "tool",
  396. content: [
  397. {
  398. type: "tool-result",
  399. id: "call_1",
  400. name: "read",
  401. result: {
  402. type: "content",
  403. value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
  404. },
  405. },
  406. {
  407. type: "tool-result",
  408. id: "call_2",
  409. name: "read",
  410. result: {
  411. type: "content",
  412. value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
  413. },
  414. },
  415. ],
  416. }),
  417. ],
  418. }),
  419. )
  420. expect(prepared.body.messages.slice(1)).toEqual([
  421. { role: "tool", tool_call_id: "call_1", content: "" },
  422. { role: "tool", tool_call_id: "call_2", content: "" },
  423. {
  424. role: "user",
  425. content: [
  426. { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } },
  427. { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
  428. ],
  429. },
  430. ])
  431. }),
  432. )
  433. it.effect("aggregates consecutive tool images with a following system update", () =>
  434. Effect.gen(function* () {
  435. const prepared = yield* compileRequest(
  436. LLM.request({
  437. model,
  438. messages: [
  439. Message.tool({
  440. id: "call_1",
  441. name: "read",
  442. result: {
  443. type: "content",
  444. value: [{ type: "file", uri: "data:image/png;base64,AAEC", mime: "image/png" }],
  445. },
  446. }),
  447. Message.tool({
  448. id: "call_2",
  449. name: "read",
  450. result: {
  451. type: "content",
  452. value: [{ type: "file", uri: "data:image/webp;base64,UklG", mime: "image/webp" }],
  453. },
  454. }),
  455. Message.system("Inspect both images."),
  456. ],
  457. }),
  458. )
  459. expect(prepared.body.messages).toEqual([
  460. { role: "tool", tool_call_id: "call_1", content: "" },
  461. { role: "tool", tool_call_id: "call_2", content: "" },
  462. {
  463. role: "user",
  464. content: [
  465. { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } },
  466. { type: "image_url", image_url: { url: "data:image/webp;base64,UklG" } },
  467. { type: "text", text: "<system-update>\nInspect both images.\n</system-update>" },
  468. ],
  469. },
  470. ])
  471. }),
  472. )
  473. it.effect("appends system updates without replacing multipart user content", () =>
  474. Effect.gen(function* () {
  475. const prepared = yield* compileRequest(
  476. LLM.request({
  477. model,
  478. messages: [
  479. Message.user({ type: "media", mediaType: "image/png", data: "AAEC" }),
  480. Message.system("Keep the image."),
  481. ],
  482. }),
  483. )
  484. expect(prepared.body.messages).toEqual([
  485. {
  486. role: "user",
  487. content: [
  488. { type: "image_url", image_url: { url: "data:image/png;base64,AAEC" } },
  489. { type: "text", text: "<system-update>\nKeep the image.\n</system-update>" },
  490. ],
  491. },
  492. ])
  493. }),
  494. )
  495. for (const [name, media] of [
  496. ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
  497. ["malformed base64", { mediaType: "image/png", data: "not-base64" }],
  498. ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
  499. ] as const)
  500. it.effect(`rejects ${name}`, () =>
  501. Effect.gen(function* () {
  502. const error = yield* compileRequest(
  503. LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
  504. ).pipe(Effect.flip)
  505. expect(error.message).toMatch(/does not support|does not match|valid base64/)
  506. }),
  507. )
  508. it.effect("rejects oversized image input", () =>
  509. Effect.gen(function* () {
  510. const error = yield* compileRequest(
  511. LLM.request({
  512. model,
  513. messages: [
  514. Message.user({
  515. type: "media",
  516. mediaType: "image/png",
  517. data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
  518. }),
  519. ],
  520. }),
  521. ).pipe(Effect.flip)
  522. expect(error.message).toContain("encoded limit")
  523. }),
  524. )
  525. it.effect("prepares raw and data URL image media as vision input", () =>
  526. Effect.gen(function* () {
  527. const prepared = yield* compileRequest(
  528. LLM.request({
  529. id: "req_media",
  530. model,
  531. messages: [
  532. Message.user([
  533. { type: "media", mediaType: "image/png", data: "AAECAw==" },
  534. { type: "media", mediaType: "image/jpeg", data: "data:image/jpeg;base64,/9j/" },
  535. ]),
  536. ],
  537. }),
  538. )
  539. expect(prepared.body.messages).toEqual([
  540. {
  541. role: "user",
  542. content: [
  543. { type: "image_url", image_url: { url: "data:image/png;base64,AAECAw==" } },
  544. { type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
  545. ],
  546. },
  547. ])
  548. }),
  549. )
  550. it.effect("lowers reasoning-only assistant history", () =>
  551. Effect.gen(function* () {
  552. const prepared = yield* compileRequest(
  553. LLM.request({
  554. id: "req_reasoning",
  555. model,
  556. messages: [Message.assistant({ type: "reasoning", text: "hidden" })],
  557. }),
  558. )
  559. expect(prepared.body.messages).toEqual([{ role: "assistant", content: "", reasoning_content: "hidden" }])
  560. }),
  561. )
  562. it.effect("parses text and usage stream fixtures", () =>
  563. Effect.gen(function* () {
  564. const body = sseEvents(
  565. deltaChunk({ role: "assistant", content: "Hello" }),
  566. deltaChunk({ content: "!" }),
  567. deltaChunk({}, "stop"),
  568. usageChunk({
  569. prompt_tokens: 5,
  570. completion_tokens: 2,
  571. total_tokens: 7,
  572. prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
  573. completion_tokens_details: { reasoning_tokens: 0 },
  574. }),
  575. )
  576. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  577. const usage = new Usage({
  578. inputTokens: 5,
  579. outputTokens: 2,
  580. nonCachedInputTokens: 2,
  581. cacheReadInputTokens: 1,
  582. cacheWriteInputTokens: 2,
  583. reasoningTokens: 0,
  584. totalTokens: 7,
  585. providerMetadata: {
  586. openai: {
  587. prompt_tokens: 5,
  588. completion_tokens: 2,
  589. total_tokens: 7,
  590. prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
  591. completion_tokens_details: { reasoning_tokens: 0 },
  592. },
  593. },
  594. })
  595. expect(response.text).toBe("Hello!")
  596. expect(response.events).toEqual([
  597. { type: "step-start", index: 0 },
  598. { type: "text-start", id: "text-0" },
  599. { type: "text-delta", id: "text-0", text: "Hello" },
  600. { type: "text-delta", id: "text-0", text: "!" },
  601. { type: "text-end", id: "text-0" },
  602. {
  603. type: "step-finish",
  604. index: 0,
  605. reason: { normalized: "stop", raw: "stop" },
  606. usage,
  607. providerMetadata: undefined,
  608. },
  609. {
  610. type: "finish",
  611. reason: { normalized: "stop", raw: "stop" },
  612. usage,
  613. },
  614. ])
  615. }),
  616. )
  617. it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
  618. Effect.gen(function* () {
  619. const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
  620. for (const field of fields) {
  621. const response = yield* LLMClient.generate(request).pipe(
  622. Effect.provide(
  623. fixedResponse(
  624. sseEvents(
  625. { choices: [{ delta: { [field]: "thinking" } }] },
  626. { choices: [{ delta: { content: "Hello" } }] },
  627. { choices: [{ delta: {}, finish_reason: "stop" }] },
  628. ),
  629. ),
  630. ),
  631. )
  632. expect(response.reasoning).toBe("thinking")
  633. expect(response.text).toBe("Hello")
  634. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  635. openai: { reasoningField: field },
  636. })
  637. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  638. expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
  639. }
  640. }),
  641. )
  642. it.effect("parses and replays a configured custom reasoning field", () =>
  643. Effect.gen(function* () {
  644. const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
  645. const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
  646. Effect.provide(
  647. fixedResponse(
  648. sseEvents(
  649. { choices: [{ delta: { vendor_reasoning: "thinking" } }] },
  650. { choices: [{ delta: { content: "Hello" } }] },
  651. { choices: [{ delta: {}, finish_reason: "stop" }] },
  652. ),
  653. ),
  654. ),
  655. )
  656. expect(response.reasoning).toBe("thinking")
  657. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  658. openai: { reasoningField: "vendor_reasoning" },
  659. })
  660. const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] }))
  661. expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
  662. }),
  663. )
  664. it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
  665. Effect.gen(function* () {
  666. const details = [
  667. { type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
  668. { type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
  669. ]
  670. const response = yield* LLMClient.generate(
  671. LLMRequest.update(request, {
  672. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  673. }),
  674. ).pipe(
  675. Effect.provide(
  676. fixedResponse(
  677. sseEvents(
  678. { choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
  679. { choices: [{ delta: { reasoning_details: [details[1]] } }] },
  680. {
  681. choices: [
  682. {
  683. delta: {
  684. tool_calls: [
  685. { index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
  686. ],
  687. },
  688. finish_reason: "tool_calls",
  689. },
  690. ],
  691. },
  692. ),
  693. ),
  694. ),
  695. )
  696. expect(response.reasoning).toBe("thinking")
  697. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  698. openai: { reasoningField: "reasoning", reasoningDetails: details },
  699. })
  700. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  701. expect(replay.body.messages).toEqual([
  702. {
  703. role: "assistant",
  704. content: null,
  705. reasoning: "thinking",
  706. reasoning_details: details,
  707. tool_calls: [
  708. {
  709. id: "call_1",
  710. type: "function",
  711. function: { name: "lookup", arguments: '{"query":"weather"}' },
  712. },
  713. ],
  714. },
  715. ])
  716. }),
  717. )
  718. it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
  719. Effect.gen(function* () {
  720. const details = [
  721. { type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
  722. { type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
  723. ]
  724. const response = yield* LLMClient.generate(request).pipe(
  725. Effect.provide(
  726. fixedResponse(
  727. sseEvents(
  728. { choices: [{ delta: { reasoning_details: [details[0]] } }] },
  729. { choices: [{ delta: { reasoning_details: [details[1]] } }] },
  730. { choices: [{ delta: { content: "Hello" } }] },
  731. { choices: [{ delta: {}, finish_reason: "stop" }] },
  732. ),
  733. ),
  734. ),
  735. )
  736. expect(response.reasoning).toBe("thinking")
  737. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  738. openai: { reasoningDetails: details },
  739. })
  740. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  741. expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
  742. }),
  743. )
  744. it.effect("preserves unknown reasoning details while using scalar display text", () =>
  745. Effect.gen(function* () {
  746. const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
  747. const response = yield* LLMClient.generate(request).pipe(
  748. Effect.provide(
  749. fixedResponse(
  750. sseEvents(
  751. { choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
  752. { choices: [{ delta: { content: "Hello" } }] },
  753. { choices: [{ delta: {}, finish_reason: "stop" }] },
  754. ),
  755. ),
  756. ),
  757. )
  758. expect(response.reasoning).toBe("thinking")
  759. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  760. openai: { reasoningField: "reasoning", reasoningDetails: details },
  761. })
  762. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  763. expect(replay.body.messages).toEqual([
  764. { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
  765. ])
  766. }),
  767. )
  768. it.effect("uses scalar display text for signature-only reasoning details", () =>
  769. Effect.gen(function* () {
  770. const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
  771. const response = yield* LLMClient.generate(request).pipe(
  772. Effect.provide(
  773. fixedResponse(
  774. sseEvents(
  775. { choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
  776. { choices: [{ delta: { content: "Hello" } }] },
  777. { choices: [{ delta: {}, finish_reason: "stop" }] },
  778. ),
  779. ),
  780. ),
  781. )
  782. expect(response.reasoning).toBe("thinking")
  783. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  784. openai: { reasoningField: "reasoning", reasoningDetails: details },
  785. })
  786. }),
  787. )
  788. it.effect("preserves scalar reasoning after content starts", () =>
  789. Effect.gen(function* () {
  790. const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
  791. const response = yield* LLMClient.generate(request).pipe(
  792. Effect.provide(
  793. fixedResponse(
  794. sseEvents(
  795. { choices: [{ delta: { reasoning_details: details } }] },
  796. { choices: [{ delta: { content: "Hello" } }] },
  797. { choices: [{ delta: { reasoning: "scalar" } }] },
  798. { choices: [{ delta: {}, finish_reason: "stop" }] },
  799. ),
  800. ),
  801. ),
  802. )
  803. expect(response.reasoning).toBe("detailscalar")
  804. expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
  805. expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
  806. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  807. openai: { reasoningField: "reasoning", reasoningDetails: details },
  808. })
  809. }),
  810. )
  811. it.effect("preserves an explicitly empty reasoning details array", () =>
  812. Effect.gen(function* () {
  813. const response = yield* LLMClient.generate(request).pipe(
  814. Effect.provide(
  815. fixedResponse(
  816. sseEvents(
  817. { choices: [{ delta: { reasoning_details: [] } }] },
  818. { choices: [{ delta: { content: "Hello" } }] },
  819. { choices: [{ delta: {}, finish_reason: "stop" }] },
  820. ),
  821. ),
  822. ),
  823. )
  824. expect(response.reasoning).toBe("")
  825. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  826. openai: { reasoningDetails: [] },
  827. })
  828. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  829. expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
  830. }),
  831. )
  832. it.effect("attaches signature-only details that arrive after content", () =>
  833. Effect.gen(function* () {
  834. const details = [
  835. { type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
  836. { type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
  837. ]
  838. const merged = [
  839. {
  840. type: "reasoning.text",
  841. text: "thinking",
  842. signature: "signed",
  843. format: "anthropic-claude-v1",
  844. index: 0,
  845. },
  846. ]
  847. const response = yield* LLMClient.generate(request).pipe(
  848. Effect.provide(
  849. fixedResponse(
  850. sseEvents(
  851. { choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
  852. { choices: [{ delta: { content: "Hello" } }] },
  853. { choices: [{ delta: { reasoning_details: [details[1]] } }] },
  854. { choices: [{ delta: {}, finish_reason: "stop" }] },
  855. ),
  856. ),
  857. ),
  858. )
  859. expect(response.reasoning).toBe("thinking")
  860. expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
  861. expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
  862. openai: { reasoningField: "reasoning", reasoningDetails: merged },
  863. })
  864. expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
  865. expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
  866. expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
  867. expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
  868. openai: { reasoningField: "reasoning", reasoningDetails: merged },
  869. })
  870. expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
  871. response.events.findIndex(LLMEvent.is.textStart),
  872. )
  873. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  874. expect(replay.body.messages).toEqual([
  875. { role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
  876. ])
  877. }),
  878. )
  879. it.effect("preserves metadata-only reasoning when the stream ends", () =>
  880. Effect.gen(function* () {
  881. const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
  882. const response = yield* LLMClient.generate(request).pipe(
  883. Effect.provide(
  884. fixedResponse(
  885. sseEvents(
  886. { choices: [{ delta: { reasoning_details: details } }] },
  887. { choices: [{ delta: {}, finish_reason: "stop" }] },
  888. ),
  889. ),
  890. ),
  891. )
  892. expect(response.message.content).toEqual([
  893. { type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
  894. ])
  895. expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
  896. expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
  897. const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  898. expect(replay.body.messages).toEqual([{ role: "assistant", content: "", reasoning_details: details }])
  899. }),
  900. )
  901. it.effect("flushes details-only display reasoning when the stream ends", () =>
  902. Effect.gen(function* () {
  903. const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
  904. const response = yield* LLMClient.generate(request).pipe(
  905. Effect.provide(
  906. fixedResponse(
  907. sseEvents(
  908. { choices: [{ delta: { reasoning_details: details } }] },
  909. { choices: [{ delta: {}, finish_reason: "stop" }] },
  910. ),
  911. ),
  912. ),
  913. )
  914. expect(response.reasoning).toBe("summary")
  915. expect(response.message.content).toEqual([
  916. { type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
  917. ])
  918. }),
  919. )
  920. it.effect("replays details from multiple reasoning parts in order", () =>
  921. Effect.gen(function* () {
  922. const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
  923. const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
  924. const replay = yield* compileRequest(
  925. LLM.request({
  926. model,
  927. messages: [
  928. Message.assistant([
  929. {
  930. type: "reasoning",
  931. text: "first",
  932. providerMetadata: { openai: { reasoningDetails: [first] } },
  933. },
  934. {
  935. type: "reasoning",
  936. text: "second",
  937. providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
  938. },
  939. ]),
  940. ],
  941. }),
  942. )
  943. expect(replay.body.messages).toEqual([
  944. { role: "assistant", content: "", reasoning: "firstsecond", reasoning_details: [first, second] },
  945. ])
  946. }),
  947. )
  948. it.effect("retains scalar replay for mixed structured reasoning parts", () =>
  949. Effect.gen(function* () {
  950. const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
  951. const replay = yield* compileRequest(
  952. LLM.request({
  953. model,
  954. messages: [
  955. Message.assistant([
  956. {
  957. type: "reasoning",
  958. text: "A",
  959. providerMetadata: { openai: { reasoningDetails: [detail] } },
  960. },
  961. { type: "reasoning", text: "B" },
  962. ]),
  963. ],
  964. }),
  965. )
  966. expect(replay.body.messages).toEqual([
  967. { role: "assistant", content: "", reasoning_content: "AB", reasoning_details: [detail] },
  968. ])
  969. }),
  970. )
  971. it.effect("replays native scalar reasoning alongside native details", () =>
  972. Effect.gen(function* () {
  973. const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
  974. const replay = yield* compileRequest(
  975. LLM.request({
  976. model,
  977. messages: [
  978. Message.make({
  979. role: "assistant",
  980. content: [{ type: "reasoning", text: "thinking" }],
  981. native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
  982. }),
  983. ],
  984. }),
  985. )
  986. expect(replay.body.messages).toEqual([
  987. { role: "assistant", content: "", reasoning_content: "thinking", reasoning_details: details },
  988. ])
  989. }),
  990. )
  991. it.effect("assembles streamed tool call input", () =>
  992. Effect.gen(function* () {
  993. const body = sseEvents(
  994. deltaChunk({
  995. role: "assistant",
  996. tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
  997. }),
  998. deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
  999. deltaChunk({}, "tool_calls"),
  1000. )
  1001. const response = yield* LLMClient.generate(
  1002. LLMRequest.update(request, {
  1003. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  1004. }),
  1005. ).pipe(Effect.provide(fixedResponse(body)))
  1006. expect(response.events).toEqual([
  1007. { type: "step-start", index: 0 },
  1008. { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
  1009. { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
  1010. { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
  1011. { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
  1012. {
  1013. type: "tool-call",
  1014. id: "call_1",
  1015. name: "lookup",
  1016. input: { query: "weather" },
  1017. providerExecuted: undefined,
  1018. providerMetadata: undefined,
  1019. },
  1020. {
  1021. type: "step-finish",
  1022. index: 0,
  1023. reason: { normalized: "tool-calls", raw: "tool_calls" },
  1024. usage: undefined,
  1025. providerMetadata: undefined,
  1026. },
  1027. { type: "finish", reason: { normalized: "tool-calls", raw: "tool_calls" }, usage: undefined },
  1028. ])
  1029. }),
  1030. )
  1031. it.effect("ignores empty identity fields on later tool call deltas", () =>
  1032. Effect.gen(function* () {
  1033. const body = sseEvents(
  1034. deltaChunk({
  1035. tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
  1036. }),
  1037. deltaChunk({
  1038. tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
  1039. }),
  1040. deltaChunk({}, "tool_calls"),
  1041. )
  1042. const response = yield* LLMClient.generate(
  1043. LLMRequest.update(request, {
  1044. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  1045. }),
  1046. ).pipe(Effect.provide(fixedResponse(body)))
  1047. expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
  1048. }),
  1049. )
  1050. it.effect("buffers tool call deltas until the function name arrives", () =>
  1051. Effect.gen(function* () {
  1052. const body = sseEvents(
  1053. deltaChunk({
  1054. tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
  1055. }),
  1056. deltaChunk({
  1057. tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
  1058. }),
  1059. deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
  1060. deltaChunk({}, "tool_calls"),
  1061. )
  1062. const response = yield* LLMClient.generate(
  1063. LLMRequest.update(request, {
  1064. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  1065. }),
  1066. ).pipe(Effect.provide(fixedResponse(body)))
  1067. expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
  1068. }),
  1069. )
  1070. it.effect("fails when a buffered tool call never receives a function name", () =>
  1071. Effect.gen(function* () {
  1072. const body = sseEvents(
  1073. deltaChunk({
  1074. tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
  1075. }),
  1076. deltaChunk({}, "tool_calls"),
  1077. )
  1078. const error = yield* LLMClient.generate(
  1079. LLMRequest.update(request, {
  1080. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  1081. }),
  1082. ).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
  1083. expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
  1084. }),
  1085. )
  1086. it.effect("finalizes a streamed tool call when the provider ends without a finish reason", () =>
  1087. Effect.gen(function* () {
  1088. const body = sseEvents(
  1089. deltaChunk({
  1090. role: "assistant",
  1091. tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query"' } }],
  1092. }),
  1093. deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
  1094. )
  1095. const input = LLMRequest.update(request, {
  1096. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  1097. })
  1098. const response = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)))
  1099. expect(response.events).toEqual([
  1100. { type: "step-start", index: 0 },
  1101. { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
  1102. { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
  1103. { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
  1104. { type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
  1105. {
  1106. type: "tool-call",
  1107. id: "call_1",
  1108. name: "lookup",
  1109. input: { query: "weather" },
  1110. providerExecuted: undefined,
  1111. providerMetadata: undefined,
  1112. },
  1113. {
  1114. type: "step-finish",
  1115. index: 0,
  1116. reason: { normalized: "tool-calls" },
  1117. usage: undefined,
  1118. providerMetadata: undefined,
  1119. },
  1120. { type: "finish", reason: { normalized: "tool-calls" }, usage: undefined },
  1121. ])
  1122. }),
  1123. )
  1124. it.effect("fails on malformed stream events", () =>
  1125. Effect.gen(function* () {
  1126. const body = sseEvents(deltaChunk({ content: 123 }))
  1127. const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
  1128. expect(error.message).toContain("Invalid openai/openai-chat stream event")
  1129. }),
  1130. )
  1131. it.effect("surfaces transport errors that occur mid-stream", () =>
  1132. Effect.gen(function* () {
  1133. const layer = truncatedStream(
  1134. [`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
  1135. systemError("ECONNRESET", "socket closed unexpectedly"),
  1136. )
  1137. const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
  1138. const error = yield* LLMClient.stream(request).pipe(
  1139. Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
  1140. Stream.runDrain,
  1141. Effect.provide(layer),
  1142. Effect.flip,
  1143. )
  1144. expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
  1145. expect(error.reason).toMatchObject({
  1146. _tag: "Transport",
  1147. message: "ECONNRESET: socket closed unexpectedly",
  1148. transport: "http",
  1149. operation: "read",
  1150. code: "ECONNRESET",
  1151. url: "https://api.openai.test/v1/chat/completions",
  1152. })
  1153. }),
  1154. )
  1155. it.effect("surfaces transport errors before the first stream frame", () =>
  1156. Effect.gen(function* () {
  1157. const error = yield* LLMClient.generate(request).pipe(
  1158. Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
  1159. Effect.flip,
  1160. )
  1161. expect(error.reason).toMatchObject({
  1162. _tag: "Transport",
  1163. message: "ECONNRESET: socket closed before output",
  1164. transport: "http",
  1165. operation: "read",
  1166. code: "ECONNRESET",
  1167. })
  1168. }),
  1169. )
  1170. it.effect("fails HTTP provider errors before stream parsing", () =>
  1171. Effect.gen(function* () {
  1172. const error = yield* LLMClient.generate(request).pipe(
  1173. Effect.provide(
  1174. fixedResponse('{"error":{"message":"Bad request","type":"invalid_request_error"}}', {
  1175. status: 400,
  1176. headers: { "content-type": "application/json" },
  1177. }),
  1178. ),
  1179. Effect.flip,
  1180. )
  1181. expect(error).toBeInstanceOf(AIError)
  1182. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
  1183. }),
  1184. )
  1185. it.effect("short-circuits the upstream stream when the consumer takes a prefix", () =>
  1186. Effect.gen(function* () {
  1187. // The body has more chunks than we'll consume. If `Stream.take(1)` did
  1188. // not interrupt the upstream HTTP body the test would hang waiting for
  1189. // the rest of the stream to drain.
  1190. const body = sseEvents(
  1191. deltaChunk({ role: "assistant", content: "Hello" }),
  1192. deltaChunk({ content: " world" }),
  1193. deltaChunk({}, "stop"),
  1194. )
  1195. const events = Array.from(
  1196. yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))),
  1197. )
  1198. expect(events.map((event) => event.type)).toEqual(["step-start"])
  1199. }),
  1200. )
  1201. })