openai-responses.test.ts 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459
  1. import { describe, expect } from "bun:test"
  2. import { ConfigProvider, Effect, Layer, Stream } from "effect"
  3. import { Headers, HttpClientRequest } from "effect/unstable/http"
  4. import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
  5. import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
  6. import * as Azure from "../../src/providers/azure"
  7. import * as OpenAI from "../../src/providers/openai"
  8. import * as OpenAIResponses from "../../src/protocols/openai-responses"
  9. import * as ProviderShared from "../../src/protocols/shared"
  10. import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
  11. import { it } from "../lib/effect"
  12. import { dynamicResponse, fixedResponse } from "../lib/http"
  13. import { sseEvents } from "../lib/sse"
  14. const model = OpenAIResponses.route
  15. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  16. .model({ id: "gpt-4.1-mini" })
  17. const request = LLM.request({
  18. id: "req_1",
  19. model,
  20. system: "You are concise.",
  21. prompt: "Say hello.",
  22. generation: { maxTokens: 20, temperature: 0 },
  23. })
  24. const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
  25. type OpenAIToolOutput = Extract<
  26. OpenAIResponses.OpenAIResponsesBody["input"][number],
  27. { readonly type: "function_call_output" }
  28. >
  29. const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => {
  30. const output = body.input.find(
  31. (item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output",
  32. )
  33. expect(output).toBeDefined()
  34. return output!
  35. }
  36. describe("OpenAI Responses route", () => {
  37. it.effect("prepares OpenAI Responses target", () =>
  38. Effect.gen(function* () {
  39. const prepared = yield* LLMClient.prepare(request)
  40. expect(prepared.body).toEqual({
  41. model: "gpt-4.1-mini",
  42. input: [
  43. { role: "system", content: "You are concise." },
  44. { role: "user", content: [{ type: "input_text", text: "Say hello." }] },
  45. ],
  46. stream: true,
  47. max_output_tokens: 20,
  48. temperature: 0,
  49. })
  50. }),
  51. )
  52. it.effect("lowers semantic service tier options", () =>
  53. Effect.gen(function* () {
  54. const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
  55. expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
  56. const prepared = yield* LLMClient.prepare(input)
  57. expect(prepared.body).toMatchObject({ service_tier: "priority" })
  58. expect(prepared.body).not.toHaveProperty("serviceTier")
  59. }),
  60. )
  61. it.effect("omits unsupported semantic service tiers", () =>
  62. Effect.gen(function* () {
  63. const prepared = yield* LLMClient.prepare(
  64. LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
  65. )
  66. expect(prepared.body).not.toHaveProperty("service_tier")
  67. }),
  68. )
  69. it.effect("flattens top-level object unions in function schemas", () =>
  70. Effect.gen(function* () {
  71. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  72. LLM.updateRequest(request, {
  73. tools: [
  74. {
  75. name: "read",
  76. description: "Read a path or resource.",
  77. inputSchema: {
  78. type: "object",
  79. anyOf: [
  80. {
  81. type: "object",
  82. properties: {
  83. path: { type: "string" },
  84. reference: { anyOf: [{ type: "string" }, { type: "null" }] },
  85. limit: { type: "integer", maximum: 2000 },
  86. },
  87. required: ["path"],
  88. },
  89. {
  90. type: "object",
  91. properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
  92. required: ["resource"],
  93. },
  94. ],
  95. },
  96. },
  97. ],
  98. }),
  99. )
  100. expect(prepared.body.tools).toEqual([
  101. {
  102. type: "function",
  103. name: "read",
  104. description: "Read a path or resource.",
  105. parameters: {
  106. type: "object",
  107. properties: {
  108. path: { type: "string" },
  109. reference: { type: "string" },
  110. limit: { type: "integer", maximum: 2000 },
  111. resource: { type: "string" },
  112. },
  113. additionalProperties: false,
  114. },
  115. },
  116. ])
  117. }),
  118. )
  119. it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
  120. Effect.gen(function* () {
  121. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  122. LLM.request({
  123. model,
  124. messages: [
  125. Message.user("Before."),
  126. Message.system("Treat </system-update> literally."),
  127. Message.assistant("After."),
  128. ],
  129. }),
  130. )
  131. expect(prepared.body.input).toEqual([
  132. {
  133. role: "user",
  134. content: [
  135. { type: "input_text", text: "Before." },
  136. { type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
  137. ],
  138. },
  139. { role: "assistant", content: [{ type: "output_text", text: "After." }] },
  140. ])
  141. }),
  142. )
  143. it.effect("prepares OpenAI Responses WebSocket target", () =>
  144. Effect.gen(function* () {
  145. const prepared = yield* LLMClient.prepare(
  146. LLM.updateRequest(request, {
  147. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
  148. "gpt-4.1-mini",
  149. ),
  150. }),
  151. )
  152. expect(prepared.route).toBe("openai-responses-websocket")
  153. expect(prepared.protocol).toBe("openai-responses")
  154. expect(prepared.metadata).toEqual({ transport: "websocket-json" })
  155. expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", stream: true })
  156. }),
  157. )
  158. it.effect("streams OpenAI Responses over WebSocket", () =>
  159. Effect.gen(function* () {
  160. const sent: string[] = []
  161. const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
  162. let closed = false
  163. const deps = Layer.mergeAll(
  164. Layer.succeed(
  165. RequestExecutor.Service,
  166. RequestExecutor.Service.of({
  167. execute: () => Effect.die("unexpected HTTP request"),
  168. }),
  169. ),
  170. Layer.succeed(
  171. WebSocketExecutor.Service,
  172. WebSocketExecutor.Service.of({
  173. open: (input) =>
  174. Effect.succeed({
  175. sendText: (message) =>
  176. Effect.sync(() => {
  177. opened.push({ url: input.url, authorization: input.headers.authorization })
  178. sent.push(message)
  179. }),
  180. messages: Stream.fromArray([
  181. ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
  182. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
  183. ]),
  184. close: Effect.sync(() => {
  185. closed = true
  186. }),
  187. }),
  188. }),
  189. ),
  190. )
  191. const response = yield* LLMClient.generate(
  192. LLM.request({
  193. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
  194. "gpt-4.1-mini",
  195. ),
  196. prompt: "Say hello.",
  197. }),
  198. ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
  199. expect(response.text).toBe("Hi")
  200. expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
  201. expect(closed).toBe(true)
  202. expect(sent).toHaveLength(1)
  203. expect(JSON.parse(sent[0])).toEqual({
  204. type: "response.create",
  205. model: "gpt-4.1-mini",
  206. input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
  207. store: false,
  208. })
  209. }),
  210. )
  211. it.effect("fails immediately when WebSocket is already closed", () =>
  212. Effect.gen(function* () {
  213. const error = yield* WebSocketExecutor.fromWebSocket(
  214. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
  215. { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
  216. { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
  217. ).pipe(Effect.flip)
  218. expect(error.message).toContain("closed before opening")
  219. }),
  220. )
  221. it.effect("adds native query params to the Responses URL", () =>
  222. Effect.gen(function* () {
  223. yield* LLMClient.generate(
  224. LLM.updateRequest(request, {
  225. model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }),
  226. }),
  227. ).pipe(
  228. Effect.provide(
  229. dynamicResponse((input) =>
  230. Effect.gen(function* () {
  231. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  232. expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1")
  233. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  234. headers: { "content-type": "text/event-stream" },
  235. })
  236. }),
  237. ),
  238. ),
  239. )
  240. }),
  241. )
  242. it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
  243. Effect.gen(function* () {
  244. yield* LLMClient.generate(
  245. LLM.updateRequest(request, {
  246. model: Azure.configure({
  247. baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
  248. apiKey: "azure-key",
  249. headers: { authorization: "Bearer stale" },
  250. }).responses("gpt-4.1-mini"),
  251. }),
  252. ).pipe(
  253. Effect.provide(
  254. dynamicResponse((input) =>
  255. Effect.gen(function* () {
  256. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  257. expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/responses?api-version=v1")
  258. expect(web.headers.get("api-key")).toBe("azure-key")
  259. expect(web.headers.get("authorization")).toBeNull()
  260. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  261. headers: { "content-type": "text/event-stream" },
  262. })
  263. }),
  264. ),
  265. ),
  266. )
  267. }),
  268. )
  269. it.effect("loads OpenAI default auth from Effect Config", () =>
  270. LLMClient.generate(
  271. LLM.updateRequest(request, {
  272. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
  273. }),
  274. ).pipe(
  275. configEnv({ OPENAI_API_KEY: "env-key" }),
  276. Effect.provide(
  277. dynamicResponse((input) =>
  278. Effect.gen(function* () {
  279. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  280. expect(web.headers.get("authorization")).toBe("Bearer env-key")
  281. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  282. headers: { "content-type": "text/event-stream" },
  283. })
  284. }),
  285. ),
  286. ),
  287. ),
  288. )
  289. it.effect("lets explicit auth override OpenAI default API key auth", () =>
  290. LLMClient.generate(
  291. LLM.updateRequest(request, {
  292. model: OpenAI.configure({
  293. baseURL: "https://api.openai.test/v1/",
  294. auth: Auth.bearer("oauth-token"),
  295. }).responses("gpt-4.1-mini"),
  296. }),
  297. ).pipe(
  298. Effect.provide(
  299. dynamicResponse((input) =>
  300. Effect.gen(function* () {
  301. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  302. expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
  303. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  304. headers: { "content-type": "text/event-stream" },
  305. })
  306. }),
  307. ),
  308. ),
  309. ),
  310. )
  311. it.effect("prepares function call and function output input items", () =>
  312. Effect.gen(function* () {
  313. const prepared = yield* LLMClient.prepare(
  314. LLM.request({
  315. id: "req_tool_result",
  316. model,
  317. messages: [
  318. Message.user("What is the weather?"),
  319. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  320. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  321. ],
  322. }),
  323. )
  324. expect(prepared.body).toEqual({
  325. model: "gpt-4.1-mini",
  326. input: [
  327. { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
  328. { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
  329. { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
  330. ],
  331. stream: true,
  332. })
  333. }),
  334. )
  335. it.effect("preserves structured tool errors for the model", () =>
  336. Effect.gen(function* () {
  337. const error = {
  338. error: { type: "unknown", message: "Tool execution interrupted" },
  339. content: [],
  340. structured: {},
  341. }
  342. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  343. LLM.request({
  344. model,
  345. messages: [
  346. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]),
  347. Message.tool({
  348. id: "call_1",
  349. name: "bash",
  350. resultType: "error",
  351. result: error,
  352. }),
  353. ],
  354. }),
  355. )
  356. expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error))
  357. }),
  358. )
  359. it.effect("keeps primitive tool errors as plain text", () =>
  360. Effect.gen(function* () {
  361. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  362. LLM.request({
  363. model,
  364. messages: [
  365. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
  366. Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }),
  367. ],
  368. }),
  369. )
  370. expect(expectToolOutput(prepared.body).output).toBe("503")
  371. }),
  372. )
  373. it.effect("keeps non-JSON tool errors as plain text", () =>
  374. Effect.gen(function* () {
  375. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  376. LLM.request({
  377. model,
  378. messages: [
  379. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
  380. Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }),
  381. ],
  382. }),
  383. )
  384. expect(expectToolOutput(prepared.body).output).toBe("Error: boom")
  385. }),
  386. )
  387. // Regression: screenshot/read tool results must stay structured so base64
  388. // image data is not JSON-stringified into `function_call_output.output`.
  389. it.effect("lowers image tool-result content as structured input_image items", () =>
  390. Effect.gen(function* () {
  391. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  392. LLM.request({
  393. id: "req_tool_result_image",
  394. model,
  395. messages: [
  396. Message.user("Show me the screenshot."),
  397. Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
  398. Message.tool({
  399. id: "call_1",
  400. name: "read",
  401. resultType: "content",
  402. result: [
  403. { type: "text", text: "Image read successfully" },
  404. { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
  405. ],
  406. }),
  407. ],
  408. }),
  409. )
  410. expect(expectToolOutput(prepared.body).output).toEqual([
  411. { type: "input_text", text: "Image read successfully" },
  412. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  413. ])
  414. }),
  415. )
  416. it.effect("lowers single-image tool-result content as structured input_image array", () =>
  417. Effect.gen(function* () {
  418. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  419. LLM.request({
  420. id: "req_tool_result_image_only",
  421. model,
  422. messages: [
  423. Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
  424. Message.tool({
  425. id: "call_1",
  426. name: "screenshot",
  427. resultType: "content",
  428. result: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
  429. }),
  430. ],
  431. }),
  432. )
  433. expect(expectToolOutput(prepared.body).output).toEqual([
  434. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  435. ])
  436. }),
  437. )
  438. it.effect("rejects non-image media in tool-result content with a clear error", () =>
  439. Effect.gen(function* () {
  440. const error = yield* LLMClient.prepare(
  441. LLM.request({
  442. id: "req_tool_result_unsupported_media",
  443. model,
  444. messages: [
  445. Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
  446. Message.tool({
  447. id: "call_1",
  448. name: "fetch",
  449. resultType: "content",
  450. result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }],
  451. }),
  452. ],
  453. }),
  454. ).pipe(Effect.flip)
  455. expect(error.message).toContain("OpenAI Responses")
  456. expect(error.message).toContain("audio/mpeg")
  457. }),
  458. )
  459. it.effect("prepares the composed native continuation request", () =>
  460. Effect.gen(function* () {
  461. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  462. continuationRequest({
  463. id: "req_native_continuation_openai",
  464. model,
  465. features: nativeOpenAIResponsesContinuation,
  466. }),
  467. )
  468. expect(prepared.body).toMatchObject({
  469. input: [
  470. { role: "system", content: "You are concise. Continue from the provided history." },
  471. {
  472. role: "user",
  473. content: [
  474. { type: "input_text", text: "What is shown here?" },
  475. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  476. ],
  477. },
  478. {
  479. type: "reasoning",
  480. id: "rs_continuation_1",
  481. encrypted_content: "encrypted-continuation-state",
  482. summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
  483. },
  484. { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
  485. { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
  486. { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
  487. { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
  488. { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
  489. {
  490. role: "user",
  491. content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
  492. },
  493. ],
  494. include: ["reasoning.encrypted_content"],
  495. store: false,
  496. })
  497. expect(prepared.body.tools).toEqual([expect.objectContaining({ type: "function", name: "get_weather" })])
  498. }),
  499. )
  500. it.effect("maps OpenAI provider options to Responses options", () =>
  501. Effect.gen(function* () {
  502. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  503. LLM.request({
  504. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
  505. prompt: "think",
  506. providerOptions: {
  507. openai: {
  508. promptCacheKey: "session_123",
  509. reasoningEffort: "high",
  510. reasoningSummary: "auto",
  511. include: ["reasoning.encrypted_content"],
  512. },
  513. },
  514. }),
  515. )
  516. expect(prepared.body.store).toBe(false)
  517. expect(prepared.body.prompt_cache_key).toBe("session_123")
  518. expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
  519. expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
  520. expect(prepared.body.text).toEqual({ verbosity: "low" })
  521. }),
  522. )
  523. it.effect("accepts the full ResponseIncludable union", () =>
  524. Effect.gen(function* () {
  525. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  526. LLM.request({
  527. model,
  528. prompt: "hi",
  529. providerOptions: {
  530. openai: {
  531. include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
  532. },
  533. },
  534. }),
  535. )
  536. expect(prepared.body.include).toEqual([
  537. "reasoning.encrypted_content",
  538. "code_interpreter_call.outputs",
  539. "web_search_call.results",
  540. ])
  541. }),
  542. )
  543. it.effect("filters unknown includable values out of the include array", () =>
  544. Effect.gen(function* () {
  545. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  546. LLM.request({
  547. model,
  548. prompt: "hi",
  549. // The user passed one invalid entry alongside a valid one. Keep the
  550. // valid one so the request still succeeds rather than failing on a
  551. // typo from upstream config.
  552. providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
  553. }),
  554. )
  555. expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
  556. }),
  557. )
  558. it.effect("treats an explicit empty include as no include at all", () =>
  559. Effect.gen(function* () {
  560. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  561. LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
  562. )
  563. expect(prepared.body.include).toBeUndefined()
  564. }),
  565. )
  566. it.effect("treats an all-invalid include as no include at all", () =>
  567. Effect.gen(function* () {
  568. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  569. LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
  570. )
  571. expect(prepared.body.include).toBeUndefined()
  572. }),
  573. )
  574. it.effect("omits include when no include is set", () =>
  575. Effect.gen(function* () {
  576. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  577. LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
  578. )
  579. expect(prepared.body.include).toBeUndefined()
  580. }),
  581. )
  582. it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
  583. Effect.gen(function* () {
  584. // The native OpenAI facade configures GPT-5 stateless (store: false) with
  585. // reasoningSummary: "auto" by default. Without `include`, a follow-up
  586. // turn cannot replay reasoning state, so the facade also opts into
  587. // `reasoning.encrypted_content` automatically.
  588. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  589. LLM.request({
  590. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
  591. prompt: "hi",
  592. }),
  593. )
  594. expect(prepared.body.store).toBe(false)
  595. expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
  596. expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
  597. }),
  598. )
  599. it.effect("lets callers opt out of the GPT-5 default include", () =>
  600. Effect.gen(function* () {
  601. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  602. LLM.request({
  603. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
  604. prompt: "hi",
  605. providerOptions: { openai: { include: [] } },
  606. }),
  607. )
  608. expect(prepared.body.include).toBeUndefined()
  609. }),
  610. )
  611. it.effect("request OpenAI provider options override route defaults", () =>
  612. Effect.gen(function* () {
  613. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  614. LLM.request({
  615. model: OpenAI.configure({
  616. baseURL: "https://api.openai.test/v1/",
  617. apiKey: "test",
  618. providerOptions: { openai: { promptCacheKey: "model_cache" } },
  619. }).model("gpt-4.1-mini"),
  620. prompt: "no cache",
  621. providerOptions: { openai: { promptCacheKey: "request_cache" } },
  622. }),
  623. )
  624. expect(prepared.body.prompt_cache_key).toBe("request_cache")
  625. }),
  626. )
  627. it.effect("parses text and usage stream fixtures", () =>
  628. Effect.gen(function* () {
  629. const body = sseEvents(
  630. { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
  631. { type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
  632. {
  633. type: "response.completed",
  634. response: {
  635. id: "resp_1",
  636. service_tier: "default",
  637. usage: {
  638. input_tokens: 5,
  639. output_tokens: 2,
  640. total_tokens: 7,
  641. input_tokens_details: { cached_tokens: 1 },
  642. output_tokens_details: { reasoning_tokens: 0 },
  643. },
  644. },
  645. },
  646. )
  647. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  648. const usage = new Usage({
  649. inputTokens: 5,
  650. outputTokens: 2,
  651. nonCachedInputTokens: 4,
  652. cacheReadInputTokens: 1,
  653. reasoningTokens: 0,
  654. totalTokens: 7,
  655. providerMetadata: {
  656. openai: {
  657. input_tokens: 5,
  658. output_tokens: 2,
  659. total_tokens: 7,
  660. input_tokens_details: { cached_tokens: 1 },
  661. output_tokens_details: { reasoning_tokens: 0 },
  662. },
  663. },
  664. })
  665. expect(response.text).toBe("Hello!")
  666. expect(response.events).toEqual([
  667. { type: "step-start", index: 0 },
  668. { type: "text-start", id: "msg_1" },
  669. { type: "text-delta", id: "msg_1", text: "Hello" },
  670. { type: "text-delta", id: "msg_1", text: "!" },
  671. { type: "text-end", id: "msg_1" },
  672. {
  673. type: "step-finish",
  674. index: 0,
  675. reason: "stop",
  676. providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
  677. usage,
  678. },
  679. {
  680. type: "finish",
  681. reason: "stop",
  682. providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
  683. usage,
  684. },
  685. ])
  686. }),
  687. )
  688. it.effect("parses reasoning summary stream fixtures", () =>
  689. Effect.gen(function* () {
  690. const body = sseEvents(
  691. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
  692. { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
  693. { type: "response.reasoning_summary_text.done", item_id: "rs_1" },
  694. { type: "response.completed", response: { id: "resp_1" } },
  695. )
  696. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  697. expect(response.reasoning).toBe("thinking")
  698. expect(response.text).toBe("Hello")
  699. expect(response.events).toMatchObject([
  700. { type: "step-start", index: 0 },
  701. { type: "reasoning-start", id: "rs_1" },
  702. { type: "reasoning-delta", id: "rs_1", text: "thinking" },
  703. { type: "text-start", id: "msg_1" },
  704. { type: "text-delta", id: "msg_1", text: "Hello" },
  705. { type: "reasoning-end", id: "rs_1" },
  706. { type: "text-end", id: "msg_1" },
  707. { type: "step-finish", index: 0, reason: "stop" },
  708. { type: "finish", reason: "stop" },
  709. ])
  710. }),
  711. )
  712. it.effect("preserves encrypted reasoning metadata for continuation", () =>
  713. Effect.gen(function* () {
  714. const response = yield* LLMClient.generate(request).pipe(
  715. Effect.provide(
  716. fixedResponse(
  717. sseEvents(
  718. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
  719. {
  720. type: "response.output_item.done",
  721. item: {
  722. type: "reasoning",
  723. id: "rs_1",
  724. encrypted_content: "encrypted-state",
  725. summary: [{ type: "summary_text", text: "thinking" }],
  726. },
  727. },
  728. { type: "response.completed", response: { id: "resp_1" } },
  729. ),
  730. ),
  731. ),
  732. )
  733. expect(response.events).toContainEqual(
  734. expect.objectContaining({
  735. type: "reasoning-end",
  736. id: "rs_1",
  737. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  738. }),
  739. )
  740. }),
  741. )
  742. it.effect("streams each reasoning summary part as a separate block", () =>
  743. Effect.gen(function* () {
  744. const response = yield* LLMClient.generate(
  745. LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
  746. ).pipe(
  747. Effect.provide(
  748. fixedResponse(
  749. sseEvents(
  750. {
  751. type: "response.output_item.added",
  752. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  753. },
  754. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  755. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
  756. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  757. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
  758. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
  759. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
  760. {
  761. type: "response.output_item.done",
  762. item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
  763. },
  764. { type: "response.completed", response: { id: "resp_1" } },
  765. ),
  766. ),
  767. ),
  768. )
  769. expect(response.reasoning).toBe("FirstSecond")
  770. expect(response.events).toMatchObject([
  771. { type: "step-start", index: 0 },
  772. {
  773. type: "reasoning-start",
  774. id: "rs_1:0",
  775. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
  776. },
  777. { type: "reasoning-delta", id: "rs_1:0", text: "First" },
  778. { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
  779. {
  780. type: "reasoning-start",
  781. id: "rs_1:1",
  782. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
  783. },
  784. { type: "reasoning-delta", id: "rs_1:1", text: "Second" },
  785. {
  786. type: "reasoning-end",
  787. id: "rs_1:1",
  788. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  789. },
  790. { type: "step-finish", index: 0, reason: "stop" },
  791. { type: "finish", reason: "stop" },
  792. ])
  793. }),
  794. )
  795. it.effect("closes reasoning summary parts when storage is not disabled", () =>
  796. Effect.gen(function* () {
  797. const response = yield* LLMClient.generate(request).pipe(
  798. Effect.provide(
  799. fixedResponse(
  800. sseEvents(
  801. {
  802. type: "response.output_item.added",
  803. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  804. },
  805. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  806. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
  807. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  808. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
  809. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
  810. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
  811. {
  812. type: "response.output_item.done",
  813. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  814. },
  815. { type: "response.completed", response: { id: "resp_1" } },
  816. ),
  817. ),
  818. ),
  819. )
  820. expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
  821. { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
  822. { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
  823. ])
  824. }),
  825. )
  826. it.effect("continues a stateless reasoning conversation", () =>
  827. Effect.gen(function* () {
  828. const response = yield* LLMClient.generate(
  829. LLM.request({
  830. id: "req_reasoning_continue",
  831. model,
  832. messages: [
  833. Message.user("What changed?"),
  834. Message.assistant([
  835. {
  836. type: "reasoning",
  837. text: "Checked the previous diff.",
  838. providerMetadata: {
  839. openai: {
  840. itemId: "rs_1",
  841. reasoningEncryptedContent: "encrypted-state",
  842. },
  843. },
  844. },
  845. { type: "text", text: "The parser changed." },
  846. ]),
  847. Message.user("Summarize it."),
  848. ],
  849. providerOptions: { openai: { store: false } },
  850. }),
  851. ).pipe(
  852. Effect.provide(
  853. dynamicResponse((input) =>
  854. Effect.gen(function* () {
  855. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  856. expect(yield* Effect.promise(() => web.json())).toMatchObject({
  857. input: [
  858. { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
  859. {
  860. type: "reasoning",
  861. id: "rs_1",
  862. encrypted_content: "encrypted-state",
  863. summary: [{ type: "summary_text", text: "Checked the previous diff." }],
  864. },
  865. { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
  866. { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
  867. ],
  868. })
  869. return input.respond(
  870. sseEvents(
  871. { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
  872. { type: "response.completed", response: { id: "resp_1" } },
  873. ),
  874. { headers: { "content-type": "text/event-stream" } },
  875. )
  876. }),
  877. ),
  878. ),
  879. )
  880. expect(response.text).toBe("Parser now round-trips reasoning.")
  881. }),
  882. )
  883. it.effect("preserves assistant content order around reasoning items", () =>
  884. Effect.gen(function* () {
  885. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  886. LLM.request({
  887. id: "req_reasoning_order",
  888. model,
  889. messages: [
  890. Message.assistant([
  891. { type: "text", text: "Before." },
  892. {
  893. type: "reasoning",
  894. text: "Checked order.",
  895. providerMetadata: {
  896. openai: {
  897. itemId: "rs_1",
  898. reasoningEncryptedContent: "encrypted-state",
  899. },
  900. },
  901. },
  902. { type: "text", text: "After." },
  903. ]),
  904. ],
  905. providerOptions: { openai: { store: false } },
  906. }),
  907. )
  908. expect(prepared.body.input).toEqual([
  909. { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
  910. {
  911. type: "reasoning",
  912. id: "rs_1",
  913. encrypted_content: "encrypted-state",
  914. summary: [{ type: "summary_text", text: "Checked order." }],
  915. },
  916. { role: "assistant", content: [{ type: "output_text", text: "After." }] },
  917. ])
  918. }),
  919. )
  920. it.effect("references stored reasoning items by id", () =>
  921. Effect.gen(function* () {
  922. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  923. LLM.request({
  924. model,
  925. messages: [
  926. Message.assistant([
  927. {
  928. type: "reasoning",
  929. text: "Checked the previous diff.",
  930. providerMetadata: { openai: { itemId: "rs_1" } },
  931. },
  932. ]),
  933. ],
  934. providerOptions: { openai: { store: true } },
  935. }),
  936. )
  937. expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
  938. }),
  939. )
  940. it.effect("references stored provider-executed hosted tool results by id", () =>
  941. Effect.gen(function* () {
  942. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  943. LLM.request({
  944. model,
  945. messages: [
  946. Message.assistant([
  947. ToolCallPart.make({
  948. id: "ws_1",
  949. name: "web_search",
  950. input: { query: "effect 4" },
  951. providerExecuted: true,
  952. providerMetadata: { openai: { itemId: "ws_1" } },
  953. }),
  954. {
  955. type: "tool-result",
  956. id: "ws_1",
  957. name: "web_search",
  958. result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
  959. providerExecuted: true,
  960. providerMetadata: { openai: { itemId: "ws_1" } },
  961. },
  962. ]),
  963. Message.user("Continue."),
  964. ],
  965. providerOptions: { openai: { store: true } },
  966. }),
  967. )
  968. expect(prepared.body.input).toEqual([
  969. { type: "item_reference", id: "ws_1" },
  970. { role: "user", content: [{ type: "input_text", text: "Continue." }] },
  971. ])
  972. }),
  973. )
  974. it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
  975. Effect.gen(function* () {
  976. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  977. LLM.request({
  978. id: "req_multi_summary_continuation",
  979. model,
  980. messages: [
  981. Message.assistant([
  982. {
  983. type: "reasoning",
  984. text: "First",
  985. providerMetadata: { openai: { itemId: "rs_1" } },
  986. },
  987. {
  988. type: "reasoning",
  989. text: "Second",
  990. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  991. },
  992. ]),
  993. ],
  994. providerOptions: { openai: { store: false } },
  995. }),
  996. )
  997. expect(prepared.body.input).toEqual([
  998. {
  999. type: "reasoning",
  1000. id: "rs_1",
  1001. encrypted_content: "encrypted-state",
  1002. summary: [
  1003. { type: "summary_text", text: "First" },
  1004. { type: "summary_text", text: "Second" },
  1005. ],
  1006. },
  1007. ])
  1008. }),
  1009. )
  1010. it.effect("skips non-persisted reasoning ids without encrypted state", () =>
  1011. Effect.gen(function* () {
  1012. const prepared = yield* LLMClient.prepare(
  1013. LLM.request({
  1014. id: "req_reasoning_without_encrypted_state",
  1015. model,
  1016. messages: [
  1017. Message.user("What changed?"),
  1018. Message.assistant([
  1019. {
  1020. type: "reasoning",
  1021. text: "Checked the previous diff.",
  1022. providerMetadata: {
  1023. openai: {
  1024. itemId: "rs_1",
  1025. reasoningEncryptedContent: null,
  1026. },
  1027. },
  1028. },
  1029. { type: "text", text: "The parser changed." },
  1030. ]),
  1031. Message.user("Summarize it."),
  1032. ],
  1033. providerOptions: { openai: { store: false } },
  1034. }),
  1035. )
  1036. expect(prepared.body).toMatchObject({
  1037. input: [
  1038. { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
  1039. { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
  1040. { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
  1041. ],
  1042. store: false,
  1043. })
  1044. }),
  1045. )
  1046. it.effect("assembles streamed function call input", () =>
  1047. Effect.gen(function* () {
  1048. const body = sseEvents(
  1049. {
  1050. type: "response.output_item.added",
  1051. item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
  1052. },
  1053. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
  1054. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
  1055. {
  1056. type: "response.output_item.done",
  1057. item: {
  1058. type: "function_call",
  1059. id: "item_1",
  1060. call_id: "call_1",
  1061. name: "lookup",
  1062. arguments: '{"query":"weather"}',
  1063. },
  1064. },
  1065. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  1066. )
  1067. const response = yield* LLMClient.generate(
  1068. LLM.updateRequest(request, {
  1069. tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
  1070. }),
  1071. ).pipe(Effect.provide(fixedResponse(body)))
  1072. const usage = new Usage({
  1073. inputTokens: 5,
  1074. outputTokens: 1,
  1075. nonCachedInputTokens: 5,
  1076. cacheReadInputTokens: undefined,
  1077. reasoningTokens: undefined,
  1078. totalTokens: 6,
  1079. providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
  1080. })
  1081. expect(response.events).toEqual([
  1082. { type: "step-start", index: 0 },
  1083. {
  1084. type: "tool-input-start",
  1085. id: "call_1",
  1086. name: "lookup",
  1087. providerMetadata: { openai: { itemId: "item_1" } },
  1088. },
  1089. {
  1090. type: "tool-input-delta",
  1091. id: "call_1",
  1092. name: "lookup",
  1093. text: '{"query"',
  1094. },
  1095. {
  1096. type: "tool-input-delta",
  1097. id: "call_1",
  1098. name: "lookup",
  1099. text: ':"weather"}',
  1100. },
  1101. {
  1102. type: "tool-input-end",
  1103. id: "call_1",
  1104. name: "lookup",
  1105. providerMetadata: { openai: { itemId: "item_1" } },
  1106. },
  1107. {
  1108. type: "tool-call",
  1109. id: "call_1",
  1110. name: "lookup",
  1111. input: { query: "weather" },
  1112. providerExecuted: undefined,
  1113. providerMetadata: { openai: { itemId: "item_1" } },
  1114. },
  1115. { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
  1116. {
  1117. type: "finish",
  1118. reason: "tool-calls",
  1119. providerMetadata: undefined,
  1120. usage,
  1121. },
  1122. ])
  1123. }),
  1124. )
  1125. it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
  1126. Effect.gen(function* () {
  1127. const item = {
  1128. type: "web_search_call",
  1129. id: "ws_1",
  1130. status: "completed",
  1131. action: { type: "search", query: "effect 4" },
  1132. }
  1133. const body = sseEvents(
  1134. { type: "response.output_item.added", item },
  1135. { type: "response.output_item.done", item },
  1136. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  1137. )
  1138. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  1139. const callsAndResults = response.events.filter(
  1140. (event) => event.type === "tool-call" || event.type === "tool-result",
  1141. )
  1142. expect(callsAndResults).toEqual([
  1143. {
  1144. type: "tool-call",
  1145. id: "ws_1",
  1146. name: "web_search",
  1147. input: { type: "search", query: "effect 4" },
  1148. providerExecuted: true,
  1149. providerMetadata: { openai: { itemId: "ws_1" } },
  1150. },
  1151. {
  1152. type: "tool-result",
  1153. id: "ws_1",
  1154. name: "web_search",
  1155. result: { type: "json", value: item },
  1156. providerExecuted: true,
  1157. providerMetadata: { openai: { itemId: "ws_1" } },
  1158. },
  1159. ])
  1160. }),
  1161. )
  1162. it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
  1163. Effect.gen(function* () {
  1164. const item = {
  1165. type: "code_interpreter_call",
  1166. id: "ci_1",
  1167. status: "completed",
  1168. code: "print(1+1)",
  1169. container_id: "cnt_xyz",
  1170. outputs: [{ type: "logs", logs: "2\n" }],
  1171. }
  1172. const body = sseEvents(
  1173. { type: "response.output_item.done", item },
  1174. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  1175. )
  1176. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  1177. const toolCall = response.events.find((event) => event.type === "tool-call")
  1178. expect(toolCall).toEqual({
  1179. type: "tool-call",
  1180. id: "ci_1",
  1181. name: "code_interpreter",
  1182. input: { code: "print(1+1)", container_id: "cnt_xyz" },
  1183. providerExecuted: true,
  1184. providerMetadata: { openai: { itemId: "ci_1" } },
  1185. })
  1186. const toolResult = response.events.find((event) => event.type === "tool-result")
  1187. expect(toolResult).toEqual({
  1188. type: "tool-result",
  1189. id: "ci_1",
  1190. name: "code_interpreter",
  1191. result: { type: "json", value: item },
  1192. providerExecuted: true,
  1193. providerMetadata: { openai: { itemId: "ci_1" } },
  1194. })
  1195. }),
  1196. )
  1197. it.effect("lowers user image content", () =>
  1198. Effect.gen(function* () {
  1199. const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
  1200. LLM.request({
  1201. id: "req_media",
  1202. model,
  1203. messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
  1204. }),
  1205. )
  1206. expect(prepared.body.input).toEqual([
  1207. {
  1208. role: "user",
  1209. content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
  1210. },
  1211. ])
  1212. }),
  1213. )
  1214. it.effect("rejects unsupported user media content", () =>
  1215. Effect.gen(function* () {
  1216. const error = yield* LLMClient.prepare(
  1217. LLM.request({
  1218. id: "req_media",
  1219. model,
  1220. messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
  1221. }),
  1222. ).pipe(Effect.flip)
  1223. expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
  1224. }),
  1225. )
  1226. it.effect("emits provider-error events for mid-stream provider errors", () =>
  1227. Effect.gen(function* () {
  1228. const response = yield* LLMClient.generate(request).pipe(
  1229. Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
  1230. )
  1231. // Prefix the code so consumers see the failure mode, not just the
  1232. // sometimes-generic provider message. The bare message alone meant
  1233. // production errors like rate limits were indistinguishable from
  1234. // unrelated stream failures.
  1235. expect(response.events).toEqual([{ type: "provider-error", message: "rate_limit_exceeded: Slow down" }])
  1236. }),
  1237. )
  1238. it.effect("falls back to error code when no message is present", () =>
  1239. Effect.gen(function* () {
  1240. const response = yield* LLMClient.generate(request).pipe(
  1241. Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
  1242. )
  1243. expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
  1244. }),
  1245. )
  1246. it.effect("falls back to error code when message is empty", () =>
  1247. Effect.gen(function* () {
  1248. const response = yield* LLMClient.generate(request).pipe(
  1249. Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
  1250. )
  1251. expect(response.events).toEqual([{ type: "provider-error", message: "internal_error" }])
  1252. }),
  1253. )
  1254. // Regression: `response.failed` carries the failure details under
  1255. // `response.error`, not at the top level. The previous handler only
  1256. // checked top-level `message`/`code` and so always emitted the bare
  1257. // "OpenAI Responses response failed" string, hiding the real cause.
  1258. it.effect("surfaces response.failed details from response.error", () =>
  1259. Effect.gen(function* () {
  1260. const response = yield* LLMClient.generate(request).pipe(
  1261. Effect.provide(
  1262. fixedResponse(
  1263. sseEvents({
  1264. type: "response.failed",
  1265. response: {
  1266. id: "resp_failed_1",
  1267. error: { code: "server_error", message: "Upstream model unavailable" },
  1268. },
  1269. }),
  1270. ),
  1271. ),
  1272. )
  1273. expect(response.events).toEqual([{ type: "provider-error", message: "server_error: Upstream model unavailable" }])
  1274. }),
  1275. )
  1276. it.effect("surfaces response.failed code when no nested message is present", () =>
  1277. Effect.gen(function* () {
  1278. const response = yield* LLMClient.generate(request).pipe(
  1279. Effect.provide(
  1280. fixedResponse(
  1281. sseEvents({
  1282. type: "response.failed",
  1283. response: { id: "resp_failed_2", error: { code: "invalid_prompt" } },
  1284. }),
  1285. ),
  1286. ),
  1287. )
  1288. expect(response.events).toEqual([{ type: "provider-error", message: "invalid_prompt" }])
  1289. }),
  1290. )
  1291. it.effect("surfaces error event details even when they arrive nested under response.error", () =>
  1292. Effect.gen(function* () {
  1293. // Some OpenAI-compatible proxies and older SDK versions wrap the
  1294. // top-level error fields into a nested `response.error` payload
  1295. // when they bubble up an HTTP error as an SSE `error` event. Honour
  1296. // both shapes so the user still sees the underlying cause instead
  1297. // of the catch-all string.
  1298. const response = yield* LLMClient.generate(request).pipe(
  1299. Effect.provide(
  1300. fixedResponse(
  1301. sseEvents({
  1302. type: "error",
  1303. response: { error: { code: "context_length_exceeded", message: "prompt too long" } },
  1304. }),
  1305. ),
  1306. ),
  1307. )
  1308. expect(response.events).toEqual([
  1309. {
  1310. type: "provider-error",
  1311. message: "context_length_exceeded: prompt too long",
  1312. classification: "context-overflow",
  1313. },
  1314. ])
  1315. }),
  1316. )
  1317. it.effect("falls back to a stable default when both error and response are absent", () =>
  1318. Effect.gen(function* () {
  1319. const response = yield* LLMClient.generate(request).pipe(
  1320. Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
  1321. )
  1322. expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses stream error" }])
  1323. }),
  1324. )
  1325. it.effect("falls back to a stable default when response.failed has no error payload", () =>
  1326. Effect.gen(function* () {
  1327. const response = yield* LLMClient.generate(request).pipe(
  1328. Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
  1329. )
  1330. expect(response.events).toEqual([{ type: "provider-error", message: "OpenAI Responses response failed" }])
  1331. }),
  1332. )
  1333. it.effect("fails HTTP provider errors before stream parsing", () =>
  1334. Effect.gen(function* () {
  1335. const error = yield* LLMClient.generate(request).pipe(
  1336. Effect.provide(
  1337. fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', {
  1338. status: 400,
  1339. headers: { "content-type": "application/json" },
  1340. }),
  1341. ),
  1342. Effect.flip,
  1343. )
  1344. expect(error).toBeInstanceOf(LLMError)
  1345. expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
  1346. expect(error.message).toContain("HTTP 400")
  1347. }),
  1348. )
  1349. })