openai-responses.test.ts 91 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616
  1. import { describe, expect } from "bun:test"
  2. import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
  3. import { Headers, HttpClientRequest } from "effect/unstable/http"
  4. import {
  5. LLM,
  6. AIError,
  7. HttpOptions,
  8. LLMEvent,
  9. LLMRequest,
  10. Message,
  11. LanguageModel,
  12. ToolCallPart,
  13. ToolDefinition,
  14. ToolResultPart,
  15. TransportReason,
  16. Usage,
  17. } from "../../src/index.js"
  18. import {
  19. Auth,
  20. LLMClient,
  21. RequestExecutor,
  22. WebSocketTransport,
  23. type ChannelObservation,
  24. type WebSocketChannelDriver,
  25. } from "../../src/route.js"
  26. import { compileRequest } from "../../src/route/client.js"
  27. import * as Azure from "../../src/providers/azure.js"
  28. import * as OpenAI from "../../src/providers/openai.js"
  29. import * as XAI from "../../src/providers/xai.js"
  30. import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
  31. import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
  32. import * as ProviderShared from "../../src/protocols/shared.js"
  33. import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
  34. import { it } from "../lib/effect.js"
  35. import { dynamicResponse, fixedResponse } from "../lib/http.js"
  36. import { sseEvents } from "../lib/sse.js"
  37. const model = OpenAIResponses.route
  38. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  39. .model({ id: "gpt-4.1-mini" })
  40. const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
  41. const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
  42. create: () => Effect.succeed({ message, mode: "full" }),
  43. observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> => {
  44. const event = ProviderShared.decodeJson(frame)
  45. if (!ProviderShared.isRecord(event)) return Effect.die("Expected event")
  46. if (event.type === "response.completed") return Effect.succeed({ type: "completed", frame })
  47. if (event.type === "response.incomplete") return Effect.succeed({ type: "incomplete", frame })
  48. if (event.type === "error" || event.type === "response.failed")
  49. return Effect.succeed({
  50. type: "provider-failure",
  51. error: new AIError({
  52. module: "test",
  53. method: "stream",
  54. reason: new TransportReason({
  55. message: "provider rejected request",
  56. transport: "websocket",
  57. operation: "read",
  58. phase: "receive",
  59. }),
  60. }),
  61. })
  62. return Effect.succeed({ type: "frame", frame })
  63. },
  64. })
  65. const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
  66. const message = ProviderShared.encodeJson(request)
  67. return OpenAIResponsesChannel.driver({
  68. id: "openai-responses",
  69. name: "OpenAI Responses",
  70. request,
  71. message,
  72. base: baseChannelDriver(message),
  73. })
  74. }
  75. const checkpoint = (observation: ChannelObservation) => {
  76. if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected checkpoint")
  77. return observation.checkpoint
  78. }
  79. const request = LLM.request({
  80. id: "req_1",
  81. model,
  82. system: "You are concise.",
  83. prompt: "Say hello.",
  84. generation: { maxTokens: 20, temperature: 0 },
  85. })
  86. const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
  87. type OpenAIToolOutput = Extract<
  88. OpenAIResponses.OpenAIResponsesBody["input"][number],
  89. { readonly type: "function_call_output" }
  90. >
  91. const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAIToolOutput => {
  92. const output = body.input.find(
  93. (item): item is OpenAIToolOutput => "type" in item && item.type === "function_call_output",
  94. )
  95. expect(output).toBeDefined()
  96. return output!
  97. }
  98. describe("OpenAI Responses route", () => {
  99. it.effect("prepares OpenAI Responses target", () =>
  100. Effect.gen(function* () {
  101. const prepared = yield* compileRequest(request)
  102. expect(prepared.body).toEqual({
  103. model: "gpt-4.1-mini",
  104. input: [
  105. { role: "system", content: "You are concise." },
  106. { role: "user", content: [{ type: "input_text", text: "Say hello." }] },
  107. ],
  108. store: false,
  109. stream: true,
  110. max_output_tokens: 20,
  111. temperature: 0,
  112. })
  113. }),
  114. )
  115. it.effect("lowers the hosted OpenAI image generation tool", () =>
  116. Effect.gen(function* () {
  117. const prepared = yield* compileRequest(
  118. LLM.request({
  119. model,
  120. prompt: "Show me a rooftop garden.",
  121. tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
  122. toolChoice: "image_generation",
  123. }),
  124. )
  125. expect(prepared.body.tools).toEqual([
  126. { type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
  127. ])
  128. expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
  129. }),
  130. )
  131. it.effect("rejects invalid hosted image generation options locally", () =>
  132. Effect.gen(function* () {
  133. const error = yield* compileRequest(
  134. LLM.request({
  135. model,
  136. prompt: "Show me a rooftop garden.",
  137. tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
  138. }),
  139. ).pipe(Effect.flip)
  140. expect(error.reason._tag).toBe("InvalidRequest")
  141. expect(error.message).toContain("image generation tool options are invalid")
  142. }),
  143. )
  144. it.effect("lowers semantic service tier options", () =>
  145. Effect.gen(function* () {
  146. const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
  147. expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
  148. const prepared = yield* compileRequest(input)
  149. expect(prepared.body).toMatchObject({ service_tier: "priority" })
  150. expect(prepared.body).not.toHaveProperty("serviceTier")
  151. }),
  152. )
  153. it.effect("passes through custom OpenAI reasoning effort strings", () =>
  154. Effect.gen(function* () {
  155. const prepared = yield* compileRequest(
  156. LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
  157. )
  158. expect(prepared.body.reasoning).toEqual({ effort: "experimental" })
  159. }),
  160. )
  161. it.effect("omits unsupported semantic service tiers", () =>
  162. Effect.gen(function* () {
  163. const prepared = yield* compileRequest(
  164. LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
  165. )
  166. expect(prepared.body).not.toHaveProperty("service_tier")
  167. }),
  168. )
  169. it.effect("flattens top-level object unions in function schemas", () =>
  170. Effect.gen(function* () {
  171. const prepared = yield* compileRequest(
  172. LLMRequest.update(request, {
  173. tools: [
  174. ToolDefinition.make({
  175. name: "read",
  176. description: "Read a path or resource.",
  177. inputSchema: {
  178. type: "object",
  179. anyOf: [
  180. {
  181. type: "object",
  182. properties: {
  183. path: { type: "string" },
  184. reference: { anyOf: [{ type: "string" }, { type: "null" }] },
  185. limit: { type: "integer", maximum: 2000 },
  186. },
  187. required: ["path"],
  188. },
  189. {
  190. type: "object",
  191. properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
  192. required: ["resource"],
  193. },
  194. ],
  195. },
  196. }),
  197. ],
  198. }),
  199. )
  200. expect(prepared.body.tools).toEqual([
  201. {
  202. type: "function",
  203. name: "read",
  204. description: "Read a path or resource.",
  205. strict: false,
  206. parameters: {
  207. type: "object",
  208. properties: {
  209. path: { type: "string" },
  210. reference: { type: "string" },
  211. limit: { type: "integer", maximum: 2000 },
  212. resource: { type: "string" },
  213. },
  214. additionalProperties: false,
  215. },
  216. },
  217. ])
  218. }),
  219. )
  220. it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
  221. Effect.gen(function* () {
  222. const prepared = yield* compileRequest(
  223. LLM.request({
  224. model,
  225. messages: [
  226. Message.user("Before."),
  227. Message.system("Treat </system-update> literally."),
  228. Message.assistant("After."),
  229. ],
  230. }),
  231. )
  232. expect(prepared.body.input).toEqual([
  233. {
  234. role: "user",
  235. content: [
  236. { type: "input_text", text: "Before." },
  237. { type: "input_text", text: "<system-update>\nTreat &lt;/system-update&gt; literally.\n</system-update>" },
  238. ],
  239. },
  240. { role: "assistant", content: [{ type: "output_text", text: "After." }] },
  241. ])
  242. }),
  243. )
  244. it.effect("prepares one OpenAI Responses route for either transport", () =>
  245. Effect.gen(function* () {
  246. const prepared = yield* compileRequest(
  247. LLMRequest.update(request, {
  248. model: OpenAIResponses.route
  249. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  250. .model({ id: "gpt-4.1-mini" }),
  251. }),
  252. )
  253. expect(prepared.route).toBe("openai-responses")
  254. expect(prepared.protocol).toBe("openai-responses")
  255. expect(prepared.metadata).toEqual({ transport: "http-json" })
  256. expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
  257. }),
  258. )
  259. it.effect("streams OpenAI Responses over WebSocket", () =>
  260. Effect.gen(function* () {
  261. const sent: string[] = []
  262. const opened: Array<{
  263. readonly url: string
  264. readonly authorization: string | undefined
  265. readonly protocol: string | undefined
  266. }> = []
  267. let closed = false
  268. const deps = Layer.succeed(
  269. RequestExecutor.Service,
  270. RequestExecutor.Service.of({
  271. execute: () => Effect.die("unexpected HTTP request"),
  272. }),
  273. )
  274. const webSocket = WebSocketTransport.makeDirect({
  275. open: (input) =>
  276. Effect.succeed({
  277. sendText: (message) =>
  278. Effect.sync(() => {
  279. opened.push({
  280. url: input.url,
  281. authorization: input.headers.authorization,
  282. protocol: input.headers["openai-beta"],
  283. })
  284. sent.push(message)
  285. }),
  286. messages: Stream.fromArray([
  287. ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
  288. ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
  289. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
  290. ]),
  291. close: Effect.sync(() => {
  292. closed = true
  293. }),
  294. }),
  295. })
  296. const response = yield* LLMClient.generate(
  297. LLM.request({
  298. model: OpenAI.configure({
  299. baseURL: "https://api.openai.test/v1/",
  300. apiKey: "test",
  301. headers: { "openai-beta": "custom-protocol" },
  302. }).responses("gpt-4.1-mini"),
  303. prompt: "Say hello.",
  304. }),
  305. { webSocket },
  306. ).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
  307. expect(response.text).toBe("Hi")
  308. expect(opened).toEqual([
  309. {
  310. url: "wss://api.openai.test/v1/responses",
  311. authorization: "Bearer test",
  312. protocol: "custom-protocol",
  313. },
  314. ])
  315. expect(closed).toBe(true)
  316. expect(sent).toHaveLength(1)
  317. expect(JSON.parse(sent[0])).toEqual({
  318. type: "response.create",
  319. model: "gpt-4.1-mini",
  320. input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
  321. store: false,
  322. })
  323. }),
  324. )
  325. it.effect("rejects out-of-order and mismatched WebSocket response events", () =>
  326. Effect.gen(function* () {
  327. const streams = [
  328. Stream.fromArray([
  329. ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "late", delta: "Late" }),
  330. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
  331. ]),
  332. Stream.fromArray([
  333. ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_new" } }),
  334. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
  335. ]),
  336. ]
  337. const webSocket = WebSocketTransport.makeDirect({
  338. open: () =>
  339. Effect.succeed({
  340. sendText: () => Effect.void,
  341. messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
  342. close: Effect.void,
  343. }),
  344. })
  345. const deps = Layer.succeed(
  346. RequestExecutor.Service,
  347. RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
  348. )
  349. const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
  350. "gpt-4.1-mini",
  351. )
  352. const errors = yield* Effect.forEach(["late", "mismatch"], (prompt) =>
  353. LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
  354. Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
  355. Effect.flip,
  356. ),
  357. )
  358. expect(errors.map((error) => error.reason._tag)).toEqual(["InvalidProviderOutput", "InvalidProviderOutput"])
  359. expect(errors[0]?.message).toContain("before response.created")
  360. expect(errors[1]?.message).toContain("response ID changed")
  361. }),
  362. )
  363. it.effect("continues a tool call with only the new tool output", () =>
  364. Effect.gen(function* () {
  365. const firstRequest = {
  366. type: "response.create",
  367. model: "gpt-5.2",
  368. store: false,
  369. input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
  370. }
  371. const first = continuationDriver(firstRequest)
  372. const firstCreate = yield* first.create(undefined)
  373. yield* first.observe(
  374. firstCreate,
  375. ProviderShared.encodeJson({
  376. type: "response.output_item.done",
  377. item: {
  378. type: "function_call",
  379. id: "fc_1",
  380. status: "completed",
  381. call_id: "call_1",
  382. name: "weather",
  383. arguments: '{ "city": "Paris" }',
  384. },
  385. }),
  386. )
  387. const saved = checkpoint(
  388. yield* first.observe(
  389. firstCreate,
  390. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
  391. ),
  392. )
  393. const second = continuationDriver({
  394. ...firstRequest,
  395. input: [
  396. ...firstRequest.input,
  397. { type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
  398. { type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
  399. ],
  400. })
  401. const create = yield* second.create(saved)
  402. expect(create.mode).toBe("incremental")
  403. expect(ProviderShared.decodeJson(create.message)).toMatchObject({
  404. previous_response_id: "resp_1",
  405. input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
  406. })
  407. }),
  408. )
  409. it.effect("continues a promoted steer after the completed assistant output", () =>
  410. Effect.gen(function* () {
  411. const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
  412. const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
  413. const create = yield* first.create(undefined)
  414. yield* first.observe(
  415. create,
  416. ProviderShared.encodeJson({
  417. type: "response.output_item.done",
  418. item: {
  419. type: "message",
  420. id: "msg_1",
  421. status: "completed",
  422. role: "assistant",
  423. content: [{ type: "output_text", text: "Hello" }],
  424. },
  425. }),
  426. )
  427. const saved = checkpoint(
  428. yield* first.observe(
  429. create,
  430. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
  431. ),
  432. )
  433. const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
  434. const next = continuationDriver({
  435. type: "response.create",
  436. model: "gpt-5.2",
  437. store: false,
  438. input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
  439. })
  440. const continued = yield* next.create(saved)
  441. expect(continued.mode).toBe("incremental")
  442. expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
  443. previous_response_id: "resp_1",
  444. input: [steer],
  445. })
  446. }),
  447. )
  448. it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
  449. Effect.gen(function* () {
  450. const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
  451. const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
  452. const first = continuationDriver(request)
  453. const create = yield* first.create(undefined)
  454. yield* first.observe(
  455. create,
  456. ProviderShared.encodeJson({
  457. type: "response.output_item.done",
  458. item: {
  459. type: "reasoning",
  460. id: "rs_1",
  461. summary: [{ type: "summary_text", text: "Thought" }],
  462. encrypted_content: "encrypted",
  463. },
  464. }),
  465. )
  466. const saved = checkpoint(
  467. yield* first.observe(
  468. create,
  469. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
  470. ),
  471. )
  472. const next = continuationDriver({
  473. ...request,
  474. input: [
  475. ...firstInput,
  476. {
  477. type: "reasoning",
  478. summary: [{ type: "summary_text", text: "Thought" }],
  479. encrypted_content: "encrypted",
  480. },
  481. { role: "user", content: [{ type: "input_text", text: "Continue" }] },
  482. ],
  483. })
  484. const continued = yield* next.create(saved)
  485. expect(continued.mode).toBe("incremental")
  486. expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
  487. previous_response_id: "resp_1",
  488. input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
  489. })
  490. }),
  491. )
  492. it.effect("uses a full request when any non-input invariant changes", () =>
  493. Effect.gen(function* () {
  494. const request = {
  495. type: "response.create",
  496. model: "gpt-5.2",
  497. store: false,
  498. metadata: { source: "one" },
  499. input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
  500. }
  501. const first = continuationDriver(request)
  502. const create = yield* first.create(undefined)
  503. const saved = checkpoint(
  504. yield* first.observe(
  505. create,
  506. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
  507. ),
  508. )
  509. const appended = [...request.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }]
  510. const changes = [
  511. { ...request, model: "gpt-5.3", input: appended },
  512. { ...request, instructions: "Changed", input: appended },
  513. { ...request, tools: [{ type: "function", name: "other" }], input: appended },
  514. { ...request, temperature: 0.5, input: appended },
  515. { ...request, metadata: { source: "two" }, input: appended },
  516. {
  517. ...request,
  518. input: [{ role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
  519. },
  520. ]
  521. const creates = yield* Effect.forEach(changes, (changed) => continuationDriver(changed).create(saved))
  522. expect(creates.map((item) => item.mode)).toEqual(changes.map(() => "full"))
  523. expect(
  524. creates
  525. .map((item) => ProviderShared.decodeJson(item.message))
  526. .every((item) => ProviderShared.isRecord(item) && !("previous_response_id" in item)),
  527. ).toBe(true)
  528. }),
  529. )
  530. it.effect("stages no checkpoint for incomplete or ID-less completion", () =>
  531. Effect.gen(function* () {
  532. const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
  533. const create = yield* driver.create(undefined)
  534. const completed = yield* driver.observe(
  535. create,
  536. ProviderShared.encodeJson({ type: "response.completed", response: {} }),
  537. )
  538. expect(completed).toMatchObject({ type: "completed" })
  539. expect(completed).not.toHaveProperty("checkpoint")
  540. expect(
  541. yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.incomplete", response: {} })),
  542. ).toMatchObject({ type: "incomplete" })
  543. }),
  544. )
  545. it.effect("classifies explicit continuation rejection for runner-owned recovery", () =>
  546. Effect.gen(function* () {
  547. const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
  548. const create = yield* driver.create(undefined)
  549. const missing = yield* driver.observe(
  550. create,
  551. ProviderShared.encodeJson({
  552. type: "error",
  553. error: { code: "previous_response_not_found", message: "Missing response" },
  554. }),
  555. )
  556. const limit = yield* driver.observe(
  557. create,
  558. ProviderShared.encodeJson({
  559. type: "error",
  560. error: { code: "websocket_connection_limit_reached", message: "Rotate" },
  561. }),
  562. )
  563. expect(missing).toMatchObject({
  564. type: "rejected",
  565. recovery: "retry-full",
  566. error: { reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" } },
  567. })
  568. expect(limit).toMatchObject({
  569. type: "rejected",
  570. recovery: "rotate-and-retry-full",
  571. error: {
  572. reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
  573. },
  574. })
  575. }),
  576. )
  577. it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
  578. Effect.gen(function* () {
  579. const attempts = yield* Ref.make(0)
  580. const message = yield* Ref.make("")
  581. const body = yield* Ref.make("")
  582. const response = yield* LLMClient.generate(
  583. LLM.request({
  584. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
  585. prompt: "Say hello.",
  586. http: {
  587. body: {
  588. model: "overlaid-model",
  589. metadata: { source: "overlay" },
  590. stream_options: { include_usage: true },
  591. background: true,
  592. },
  593. headers: { "x-request": "request" },
  594. query: { mode: "test" },
  595. },
  596. }),
  597. {
  598. webSocket: {
  599. execute: (exchange) =>
  600. Effect.gen(function* () {
  601. expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
  602. expect(exchange.connect.headers["openai-beta"]).toBe("responses_websockets=2026-02-06")
  603. expect(exchange.connect.headers["content-length"]).toBeUndefined()
  604. yield* exchange.driver
  605. .create(undefined)
  606. .pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
  607. return { frames: exchange.fallback(), complete: Effect.void }
  608. }),
  609. },
  610. },
  611. ).pipe(
  612. Effect.provide(
  613. dynamicResponse((input) =>
  614. Effect.gen(function* () {
  615. yield* Ref.update(attempts, (value) => value + 1)
  616. yield* Ref.set(body, input.text)
  617. expect(input.request.url).toBe("https://api.openai.test/v1/responses?mode=test")
  618. expect(input.request.headers.authorization).toBe("Bearer test")
  619. expect(input.request.headers["x-request"]).toBe("request")
  620. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  621. headers: { "content-type": "text/event-stream" },
  622. })
  623. }),
  624. ),
  625. ),
  626. )
  627. const httpBody = JSON.parse(yield* Ref.get(body))
  628. const { stream: _stream, stream_options: _streamOptions, background: _background, ...shared } = httpBody
  629. expect(response.finishReason?.normalized).toBe("stop")
  630. expect(yield* Ref.get(attempts)).toBe(1)
  631. expect(JSON.parse(yield* Ref.get(message))).toEqual({ type: "response.create", ...shared })
  632. expect(httpBody).toMatchObject({
  633. model: "overlaid-model",
  634. metadata: { source: "overlay" },
  635. stream: true,
  636. stream_options: { include_usage: true },
  637. background: true,
  638. })
  639. }),
  640. )
  641. it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
  642. Effect.gen(function* () {
  643. const attempts = yield* Ref.make(0)
  644. yield* LLMClient.generate(
  645. LLMRequest.update(request, { http: new HttpOptions({ body: { input: "raw-http-input" } }) }),
  646. ).pipe(
  647. Effect.provide(
  648. dynamicResponse((input) =>
  649. Effect.gen(function* () {
  650. yield* Ref.update(attempts, (value) => value + 1)
  651. expect(JSON.parse(input.text).input).toBe("raw-http-input")
  652. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  653. headers: { "content-type": "text/event-stream" },
  654. })
  655. }),
  656. ),
  657. ),
  658. )
  659. expect(yield* Ref.get(attempts)).toBe(1)
  660. }),
  661. )
  662. it.effect("closes a direct WebSocket execution after partial consumption", () =>
  663. Effect.gen(function* () {
  664. const closed = yield* Ref.make(false)
  665. const webSocket = WebSocketTransport.makeDirect({
  666. open: () =>
  667. Effect.succeed({
  668. sendText: () => Effect.void,
  669. messages: Stream.fromArray([
  670. ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
  671. ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
  672. ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
  673. ]),
  674. close: Ref.set(closed, true),
  675. }),
  676. })
  677. yield* LLMClient.stream(
  678. LLM.request({
  679. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
  680. prompt: "Say hello.",
  681. }),
  682. { webSocket },
  683. ).pipe(
  684. Stream.take(1),
  685. Stream.runDrain,
  686. Effect.provide(
  687. LLMClient.layer.pipe(
  688. Layer.provide(
  689. Layer.succeed(
  690. RequestExecutor.Service,
  691. RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
  692. ),
  693. ),
  694. ),
  695. ),
  696. )
  697. expect(yield* Ref.get(closed)).toBe(true)
  698. }),
  699. )
  700. it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
  701. Effect.gen(function* () {
  702. const events = [
  703. { type: "error", error: { code: "slow_down", message: "Try later" } },
  704. {
  705. type: "error",
  706. status_code: 429,
  707. message: "Rate limited",
  708. headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
  709. },
  710. {
  711. type: "response.failed",
  712. response: { error: { code: "server_error", message: "Unavailable" } },
  713. },
  714. { type: "error", status: "not-a-status", message: "Malformed status" },
  715. ]
  716. const errors = yield* Effect.forEach(events, (event) =>
  717. LLMClient.generate(
  718. LLM.request({
  719. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
  720. "gpt-4.1-mini",
  721. ),
  722. prompt: "Say hello.",
  723. }),
  724. {
  725. webSocket: WebSocketTransport.makeDirect({
  726. open: () =>
  727. Effect.succeed({
  728. sendText: () => Effect.void,
  729. messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
  730. close: Effect.void,
  731. }),
  732. }),
  733. },
  734. ).pipe(
  735. Effect.provide(
  736. LLMClient.layer.pipe(
  737. Layer.provide(
  738. Layer.succeed(
  739. RequestExecutor.Service,
  740. RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
  741. ),
  742. ),
  743. ),
  744. ),
  745. Effect.flip,
  746. ),
  747. )
  748. expect(errors.map((error) => error.reason._tag)).toEqual([
  749. "ProviderInternal",
  750. "RateLimit",
  751. "ProviderInternal",
  752. "UnknownProvider",
  753. ])
  754. }),
  755. )
  756. it.effect("marks post-send WebSocket failures with delivery state", () =>
  757. Effect.gen(function* () {
  758. const failure = new AIError({
  759. module: "test",
  760. method: "receive",
  761. reason: new TransportReason({
  762. message: "socket closed",
  763. transport: "websocket",
  764. operation: "read",
  765. phase: "close",
  766. }),
  767. })
  768. const streams = [
  769. Stream.fail(failure),
  770. Stream.make(ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_observed" } })).pipe(
  771. Stream.concat(Stream.fail(failure)),
  772. ),
  773. ]
  774. const deps = Layer.succeed(
  775. RequestExecutor.Service,
  776. RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
  777. )
  778. const webSocket = WebSocketTransport.makeDirect({
  779. open: () =>
  780. Effect.succeed({
  781. sendText: () => Effect.void,
  782. messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
  783. close: Effect.void,
  784. }),
  785. })
  786. const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
  787. "gpt-4.1-mini",
  788. )
  789. const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
  790. LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
  791. Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
  792. Effect.flip,
  793. ),
  794. )
  795. expect(errors.map((error) => error.reason)).toEqual([
  796. expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
  797. expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
  798. ])
  799. }),
  800. )
  801. it.effect("fails immediately when WebSocket is already closed", () =>
  802. Effect.gen(function* () {
  803. const error = yield* WebSocketTransport.fromWebSocket(
  804. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
  805. { readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
  806. { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
  807. ).pipe(Effect.flip)
  808. expect(error.message).toContain("closed before opening")
  809. expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
  810. }),
  811. )
  812. it.effect("adds native query params to the Responses URL", () =>
  813. Effect.gen(function* () {
  814. yield* LLMClient.generate(
  815. LLMRequest.update(request, {
  816. model: LanguageModel.update(model, {
  817. route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
  818. }),
  819. }),
  820. ).pipe(
  821. Effect.provide(
  822. dynamicResponse((input) =>
  823. Effect.gen(function* () {
  824. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  825. expect(web.url).toBe("https://api.openai.test/v1/responses?api-version=v1")
  826. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  827. headers: { "content-type": "text/event-stream" },
  828. })
  829. }),
  830. ),
  831. ),
  832. )
  833. }),
  834. )
  835. it.effect("uses Azure api-key header for static OpenAI Responses keys", () =>
  836. Effect.gen(function* () {
  837. yield* LLMClient.generate(
  838. LLMRequest.update(request, {
  839. model: Azure.configure({
  840. baseURL: "https://opencode-test.openai.azure.com/openai/",
  841. apiKey: "azure-key",
  842. headers: { authorization: "Bearer stale" },
  843. }).responses("gpt-4.1-mini"),
  844. }),
  845. ).pipe(
  846. Effect.provide(
  847. dynamicResponse((input) =>
  848. Effect.gen(function* () {
  849. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  850. expect(web.url).toBe("https://opencode-test.openai.azure.com/openai/v1/responses?api-version=v1")
  851. expect(web.headers.get("api-key")).toBe("azure-key")
  852. expect(web.headers.get("authorization")).toBeNull()
  853. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  854. headers: { "content-type": "text/event-stream" },
  855. })
  856. }),
  857. ),
  858. ),
  859. )
  860. }),
  861. )
  862. it.effect("loads OpenAI default auth from Effect Config", () =>
  863. LLMClient.generate(
  864. LLMRequest.update(request, {
  865. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/" }).responses("gpt-4.1-mini"),
  866. }),
  867. ).pipe(
  868. configEnv({ OPENAI_API_KEY: "env-key" }),
  869. Effect.provide(
  870. dynamicResponse((input) =>
  871. Effect.gen(function* () {
  872. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  873. expect(web.headers.get("authorization")).toBe("Bearer env-key")
  874. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  875. headers: { "content-type": "text/event-stream" },
  876. })
  877. }),
  878. ),
  879. ),
  880. ),
  881. )
  882. it.effect("lets explicit auth override OpenAI default API key auth", () =>
  883. LLMClient.generate(
  884. LLMRequest.update(request, {
  885. model: OpenAI.configure({
  886. baseURL: "https://api.openai.test/v1/",
  887. auth: Auth.bearer("oauth-token"),
  888. }).responses("gpt-4.1-mini"),
  889. }),
  890. ).pipe(
  891. Effect.provide(
  892. dynamicResponse((input) =>
  893. Effect.gen(function* () {
  894. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  895. expect(web.headers.get("authorization")).toBe("Bearer oauth-token")
  896. return input.respond(sseEvents({ type: "response.completed", response: {} }), {
  897. headers: { "content-type": "text/event-stream" },
  898. })
  899. }),
  900. ),
  901. ),
  902. ),
  903. )
  904. it.effect("prepares function call and function output input items", () =>
  905. Effect.gen(function* () {
  906. const prepared = yield* compileRequest(
  907. LLM.request({
  908. id: "req_tool_result",
  909. model,
  910. messages: [
  911. Message.user("What is the weather?"),
  912. Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
  913. Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
  914. ],
  915. }),
  916. )
  917. expect(prepared.body).toEqual({
  918. model: "gpt-4.1-mini",
  919. input: [
  920. { role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
  921. { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
  922. { type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
  923. ],
  924. store: false,
  925. stream: true,
  926. max_output_tokens: undefined,
  927. temperature: undefined,
  928. tool_choice: undefined,
  929. tools: undefined,
  930. top_p: undefined,
  931. })
  932. }),
  933. )
  934. it.effect("preserves structured tool errors for the model", () =>
  935. Effect.gen(function* () {
  936. const error = {
  937. error: { type: "unknown", message: "Tool execution interrupted" },
  938. content: [],
  939. structured: {},
  940. }
  941. const prepared = yield* compileRequest(
  942. LLM.request({
  943. model,
  944. messages: [
  945. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: { command: "sleep 10" } })]),
  946. Message.tool({
  947. id: "call_1",
  948. name: "bash",
  949. resultType: "error",
  950. result: error,
  951. }),
  952. ],
  953. }),
  954. )
  955. expect(expectToolOutput(prepared.body).output).toBe(ProviderShared.encodeJson(error))
  956. }),
  957. )
  958. it.effect("keeps primitive tool errors as plain text", () =>
  959. Effect.gen(function* () {
  960. const prepared = yield* compileRequest(
  961. LLM.request({
  962. model,
  963. messages: [
  964. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
  965. Message.tool({ id: "call_1", name: "bash", resultType: "error", result: 503 }),
  966. ],
  967. }),
  968. )
  969. expect(expectToolOutput(prepared.body).output).toBe("503")
  970. }),
  971. )
  972. it.effect("keeps non-JSON tool errors as plain text", () =>
  973. Effect.gen(function* () {
  974. const prepared = yield* compileRequest(
  975. LLM.request({
  976. model,
  977. messages: [
  978. Message.assistant([ToolCallPart.make({ id: "call_1", name: "bash", input: {} })]),
  979. Message.tool({ id: "call_1", name: "bash", resultType: "error", result: new Error("boom") }),
  980. ],
  981. }),
  982. )
  983. expect(expectToolOutput(prepared.body).output).toBe("Error: boom")
  984. }),
  985. )
  986. // Regression: screenshot/read tool results must stay structured so base64
  987. // image data is not JSON-stringified into `function_call_output.output`.
  988. it.effect("lowers image tool-result content as structured input_image items", () =>
  989. Effect.gen(function* () {
  990. const prepared = yield* compileRequest(
  991. LLM.request({
  992. id: "req_tool_result_image",
  993. model,
  994. messages: [
  995. Message.user("Show me the screenshot."),
  996. Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { filePath: "shot.png" } })]),
  997. Message.tool({
  998. id: "call_1",
  999. name: "read",
  1000. resultType: "content",
  1001. result: [
  1002. { type: "text", text: "Image read successfully" },
  1003. { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
  1004. ],
  1005. }),
  1006. ],
  1007. }),
  1008. )
  1009. expect(expectToolOutput(prepared.body).output).toEqual([
  1010. { type: "input_text", text: "Image read successfully" },
  1011. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  1012. ])
  1013. }),
  1014. )
  1015. it.effect("lowers single-image tool-result content as structured input_image array", () =>
  1016. Effect.gen(function* () {
  1017. const prepared = yield* compileRequest(
  1018. LLM.request({
  1019. id: "req_tool_result_image_only",
  1020. model,
  1021. messages: [
  1022. Message.assistant([ToolCallPart.make({ id: "call_1", name: "screenshot", input: {} })]),
  1023. Message.tool({
  1024. id: "call_1",
  1025. name: "screenshot",
  1026. resultType: "content",
  1027. result: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" }],
  1028. }),
  1029. ],
  1030. }),
  1031. )
  1032. expect(expectToolOutput(prepared.body).output).toEqual([
  1033. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  1034. ])
  1035. }),
  1036. )
  1037. it.effect("lowers PDF tool-result content as structured input_file array", () =>
  1038. Effect.gen(function* () {
  1039. const prepared = yield* compileRequest(
  1040. LLM.request({
  1041. id: "req_tool_result_pdf",
  1042. model,
  1043. messages: [
  1044. Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
  1045. Message.tool({
  1046. id: "call_1",
  1047. name: "read",
  1048. resultType: "content",
  1049. result: [
  1050. {
  1051. type: "file",
  1052. uri: "data:application/pdf;base64,JVBERi0xLjQ=",
  1053. mime: "application/pdf",
  1054. name: "report.pdf",
  1055. },
  1056. ],
  1057. }),
  1058. ],
  1059. }),
  1060. )
  1061. expect(expectToolOutput(prepared.body).output).toEqual([
  1062. {
  1063. type: "input_file",
  1064. filename: "report.pdf",
  1065. file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
  1066. },
  1067. ])
  1068. }),
  1069. )
  1070. it.effect("uses xAI inline file encoding for PDF tool results", () =>
  1071. Effect.gen(function* () {
  1072. const prepared = yield* compileRequest(
  1073. LLM.request({
  1074. model: xaiModel,
  1075. messages: [
  1076. Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
  1077. Message.tool({
  1078. id: "call_1",
  1079. name: "read",
  1080. resultType: "content",
  1081. result: [
  1082. {
  1083. type: "file",
  1084. uri: "data:application/pdf;base64,JVBERi0xLjQ=",
  1085. mime: "application/pdf",
  1086. name: "report.pdf",
  1087. },
  1088. ],
  1089. }),
  1090. ],
  1091. }),
  1092. )
  1093. expect(expectToolOutput(prepared.body).output).toEqual([
  1094. {
  1095. type: "input_file",
  1096. filename: "report.pdf",
  1097. file_data: "JVBERi0xLjQ=",
  1098. mime_type: "application/pdf",
  1099. },
  1100. ])
  1101. }),
  1102. )
  1103. it.effect("rejects unsupported media in tool-result content with a clear error", () =>
  1104. Effect.gen(function* () {
  1105. const error = yield* compileRequest(
  1106. LLM.request({
  1107. id: "req_tool_result_unsupported_media",
  1108. model,
  1109. messages: [
  1110. Message.assistant([ToolCallPart.make({ id: "call_1", name: "fetch", input: {} })]),
  1111. Message.tool({
  1112. id: "call_1",
  1113. name: "fetch",
  1114. resultType: "content",
  1115. result: [{ type: "file", uri: "data:audio/mpeg;base64,AAECAw==", mime: "audio/mpeg" }],
  1116. }),
  1117. ],
  1118. }),
  1119. ).pipe(Effect.flip)
  1120. expect(error.message).toContain("OpenAI Responses")
  1121. expect(error.message).toContain("audio/mpeg")
  1122. }),
  1123. )
  1124. it.effect("prepares the composed native continuation request", () =>
  1125. Effect.gen(function* () {
  1126. const prepared = yield* compileRequest(
  1127. continuationRequest({
  1128. id: "req_native_continuation_openai",
  1129. model,
  1130. features: nativeOpenAIResponsesContinuation,
  1131. }),
  1132. )
  1133. expect(prepared.body).toMatchObject({
  1134. input: [
  1135. { role: "system", content: "You are concise. Continue from the provided history." },
  1136. {
  1137. role: "user",
  1138. content: [
  1139. { type: "input_text", text: "What is shown here?" },
  1140. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  1141. ],
  1142. },
  1143. {
  1144. type: "reasoning",
  1145. encrypted_content: "encrypted-continuation-state",
  1146. summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
  1147. },
  1148. { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
  1149. { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
  1150. { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
  1151. { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
  1152. { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
  1153. {
  1154. role: "user",
  1155. content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
  1156. },
  1157. ],
  1158. include: ["reasoning.encrypted_content"],
  1159. store: false,
  1160. })
  1161. expect(prepared.body.tools).toEqual([expect.objectContaining({ type: "function", name: "get_weather" })])
  1162. }),
  1163. )
  1164. it.effect("maps OpenAI provider options to Responses options", () =>
  1165. Effect.gen(function* () {
  1166. const prepared = yield* compileRequest(
  1167. LLM.request({
  1168. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
  1169. prompt: "think",
  1170. promptCacheKey: "session_123",
  1171. providerOptions: {
  1172. openai: {
  1173. reasoningEffort: "high",
  1174. reasoningSummary: "auto",
  1175. include: ["reasoning.encrypted_content"],
  1176. },
  1177. },
  1178. }),
  1179. )
  1180. expect(prepared.body.store).toBe(false)
  1181. expect(prepared.body.prompt_cache_key).toBe("session_123")
  1182. expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
  1183. expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
  1184. expect(prepared.body.text).toEqual({ verbosity: "low" })
  1185. }),
  1186. )
  1187. it.effect("accepts the full ResponseIncludable union", () =>
  1188. Effect.gen(function* () {
  1189. const prepared = yield* compileRequest(
  1190. LLM.request({
  1191. model,
  1192. prompt: "hi",
  1193. providerOptions: {
  1194. openai: {
  1195. include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
  1196. },
  1197. },
  1198. }),
  1199. )
  1200. expect(prepared.body.include).toEqual([
  1201. "reasoning.encrypted_content",
  1202. "code_interpreter_call.outputs",
  1203. "web_search_call.results",
  1204. ])
  1205. }),
  1206. )
  1207. it.effect("filters unknown includable values out of the include array", () =>
  1208. Effect.gen(function* () {
  1209. const prepared = yield* compileRequest(
  1210. LLM.request({
  1211. model,
  1212. prompt: "hi",
  1213. // The user passed one invalid entry alongside a valid one. Keep the
  1214. // valid one so the request still succeeds rather than failing on a
  1215. // typo from upstream config.
  1216. providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
  1217. }),
  1218. )
  1219. expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
  1220. }),
  1221. )
  1222. it.effect("treats an explicit empty include as no include at all", () =>
  1223. Effect.gen(function* () {
  1224. const prepared = yield* compileRequest(
  1225. LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
  1226. )
  1227. expect(prepared.body.include).toBeUndefined()
  1228. }),
  1229. )
  1230. it.effect("treats an all-invalid include as no include at all", () =>
  1231. Effect.gen(function* () {
  1232. const prepared = yield* compileRequest(
  1233. LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
  1234. )
  1235. expect(prepared.body.include).toBeUndefined()
  1236. }),
  1237. )
  1238. it.effect("omits include when no include is set", () =>
  1239. Effect.gen(function* () {
  1240. const prepared = yield* compileRequest(
  1241. LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
  1242. )
  1243. expect(prepared.body.include).toBeUndefined()
  1244. }),
  1245. )
  1246. it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
  1247. Effect.gen(function* () {
  1248. // The native OpenAI facade configures GPT-5 stateless (store: false) with
  1249. // reasoningSummary: "auto" by default. Without `include`, a follow-up
  1250. // turn cannot replay reasoning state, so the facade also opts into
  1251. // `reasoning.encrypted_content` automatically.
  1252. const prepared = yield* compileRequest(
  1253. LLM.request({
  1254. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
  1255. prompt: "hi",
  1256. }),
  1257. )
  1258. expect(prepared.body.store).toBe(false)
  1259. expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
  1260. expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
  1261. }),
  1262. )
  1263. it.effect("lets callers opt out of the GPT-5 default include", () =>
  1264. Effect.gen(function* () {
  1265. const prepared = yield* compileRequest(
  1266. LLM.request({
  1267. model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
  1268. prompt: "hi",
  1269. providerOptions: { openai: { include: [] } },
  1270. }),
  1271. )
  1272. expect(prepared.body.include).toBeUndefined()
  1273. }),
  1274. )
  1275. it.effect("maps the request prompt cache key", () =>
  1276. Effect.gen(function* () {
  1277. const prepared = yield* compileRequest(
  1278. LLM.request({
  1279. model: OpenAI.configure({
  1280. baseURL: "https://api.openai.test/v1/",
  1281. apiKey: "test",
  1282. }).model("gpt-4.1-mini"),
  1283. prompt: "no cache",
  1284. promptCacheKey: "request_cache",
  1285. }),
  1286. )
  1287. expect(prepared.body.prompt_cache_key).toBe("request_cache")
  1288. }),
  1289. )
  1290. it.effect("parses text and usage stream fixtures", () =>
  1291. Effect.gen(function* () {
  1292. const body = sseEvents(
  1293. { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
  1294. { type: "response.output_text.delta", item_id: "msg_1", delta: "!" },
  1295. {
  1296. type: "response.completed",
  1297. response: {
  1298. id: "resp_1",
  1299. service_tier: "default",
  1300. usage: {
  1301. input_tokens: 5,
  1302. output_tokens: 2,
  1303. total_tokens: 7,
  1304. input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
  1305. output_tokens_details: { reasoning_tokens: 0 },
  1306. },
  1307. },
  1308. },
  1309. )
  1310. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  1311. const usage = new Usage({
  1312. inputTokens: 5,
  1313. outputTokens: 2,
  1314. nonCachedInputTokens: 2,
  1315. cacheReadInputTokens: 1,
  1316. cacheWriteInputTokens: 2,
  1317. reasoningTokens: 0,
  1318. totalTokens: 7,
  1319. providerMetadata: {
  1320. openai: {
  1321. input_tokens: 5,
  1322. output_tokens: 2,
  1323. total_tokens: 7,
  1324. input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
  1325. output_tokens_details: { reasoning_tokens: 0 },
  1326. },
  1327. },
  1328. })
  1329. expect(response.text).toBe("Hello!")
  1330. expect(response.events).toEqual([
  1331. { type: "step-start", index: 0 },
  1332. { type: "text-start", id: "msg_1" },
  1333. { type: "text-delta", id: "msg_1", text: "Hello" },
  1334. { type: "text-delta", id: "msg_1", text: "!" },
  1335. { type: "text-end", id: "msg_1" },
  1336. {
  1337. type: "step-finish",
  1338. index: 0,
  1339. reason: { normalized: "stop", raw: undefined },
  1340. providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
  1341. usage,
  1342. },
  1343. {
  1344. type: "finish",
  1345. reason: { normalized: "stop", raw: undefined },
  1346. providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
  1347. usage,
  1348. },
  1349. ])
  1350. }),
  1351. )
  1352. it.effect("preserves and replays assistant message phases", () =>
  1353. Effect.gen(function* () {
  1354. const response = yield* LLMClient.generate(request).pipe(
  1355. Effect.provide(
  1356. fixedResponse(
  1357. sseEvents(
  1358. {
  1359. type: "response.output_item.added",
  1360. item: { type: "message", id: "msg_commentary" },
  1361. },
  1362. { type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
  1363. { type: "response.output_text.done", item_id: "msg_commentary" },
  1364. {
  1365. type: "response.output_item.done",
  1366. item: { type: "message", id: "msg_commentary", phase: "commentary" },
  1367. },
  1368. {
  1369. type: "response.output_item.added",
  1370. item: { type: "message", id: "msg_final", phase: "final_answer" },
  1371. },
  1372. { type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
  1373. {
  1374. type: "response.output_item.done",
  1375. item: { type: "message", id: "msg_final", phase: "final_answer" },
  1376. },
  1377. { type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
  1378. { type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
  1379. { type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
  1380. { type: "response.completed", response: { id: "resp_1" } },
  1381. ),
  1382. ),
  1383. ),
  1384. )
  1385. expect(response.message.content).toEqual([
  1386. {
  1387. type: "text",
  1388. text: "Checking.",
  1389. providerMetadata: { openai: { phase: "commentary" } },
  1390. },
  1391. {
  1392. type: "text",
  1393. text: "Finished.",
  1394. providerMetadata: { openai: { phase: "final_answer" } },
  1395. },
  1396. {
  1397. type: "text",
  1398. text: "Unclassified.",
  1399. providerMetadata: { openai: { phase: null } },
  1400. },
  1401. ])
  1402. const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
  1403. expect(prepared.body.input).toEqual([
  1404. {
  1405. role: "assistant",
  1406. content: [{ type: "output_text", text: "Checking." }],
  1407. phase: "commentary",
  1408. },
  1409. {
  1410. role: "assistant",
  1411. content: [{ type: "output_text", text: "Finished." }],
  1412. phase: "final_answer",
  1413. },
  1414. {
  1415. role: "assistant",
  1416. content: [{ type: "output_text", text: "Unclassified." }],
  1417. phase: null,
  1418. },
  1419. ])
  1420. }),
  1421. )
  1422. it.effect("rejects output text events without the spec-required item id", () =>
  1423. Effect.gen(function* () {
  1424. const error = yield* LLMClient.generate(request).pipe(
  1425. Effect.provide(
  1426. fixedResponse(
  1427. sseEvents(
  1428. { type: "response.output_text.delta", delta: "orphaned" },
  1429. { type: "response.completed", response: { id: "resp_1" } },
  1430. ),
  1431. ),
  1432. ),
  1433. Effect.flip,
  1434. )
  1435. expect(error.reason._tag).toBe("InvalidProviderOutput")
  1436. expect(error.message).toContain("response.output_text.delta is missing item_id")
  1437. }),
  1438. )
  1439. it.effect("rejects reasoning events without the spec-required item id", () =>
  1440. Effect.gen(function* () {
  1441. const events = [
  1442. { type: "response.reasoning_summary_part.added", summary_index: 0 },
  1443. { type: "response.reasoning_summary_part.done", summary_index: 0 },
  1444. { type: "response.reasoning_text.done" },
  1445. ]
  1446. for (const event of events) {
  1447. const error = yield* LLMClient.generate(request).pipe(
  1448. Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
  1449. Effect.flip,
  1450. )
  1451. expect(error.reason._tag).toBe("InvalidProviderOutput")
  1452. expect(error.message).toContain(`${event.type} is missing item_id`)
  1453. }
  1454. }),
  1455. )
  1456. it.effect("maps incomplete response reasons", () =>
  1457. Effect.gen(function* () {
  1458. const generate = (incompleteDetails: object) =>
  1459. LLMClient.generate(request).pipe(
  1460. Effect.provide(
  1461. fixedResponse(
  1462. sseEvents({
  1463. type: "response.incomplete",
  1464. response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
  1465. }),
  1466. ),
  1467. ),
  1468. )
  1469. const length = yield* generate({ reason: "max_output_tokens" })
  1470. const contentFilter = yield* generate({ reason: "content_filter" })
  1471. const unknown = yield* generate({})
  1472. const custom = yield* generate({ reason: "provider_limit" })
  1473. expect([length.finishReason, contentFilter.finishReason, unknown.finishReason, custom.finishReason]).toEqual([
  1474. { normalized: "length", raw: "max_output_tokens" },
  1475. { normalized: "content-filter", raw: "content_filter" },
  1476. { normalized: "unknown", raw: undefined },
  1477. { normalized: "unknown", raw: "provider_limit" },
  1478. ])
  1479. }),
  1480. )
  1481. // OpenAI's documented stream orders output text within one message item; no
  1482. // provider-valid same-kind overlap is evidenced, so done boundaries close it.
  1483. it.effect("closes sequential output messages before starting the next", () =>
  1484. Effect.gen(function* () {
  1485. const response = yield* LLMClient.generate(request).pipe(
  1486. Effect.provide(
  1487. fixedResponse(
  1488. sseEvents(
  1489. { type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
  1490. { type: "response.output_text.done", item_id: "msg_1" },
  1491. { type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
  1492. { type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
  1493. { type: "response.completed", response: { id: "resp_1" } },
  1494. ),
  1495. ),
  1496. ),
  1497. )
  1498. expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
  1499. { type: "text-start", id: "msg_1" },
  1500. { type: "text-delta", id: "msg_1", text: "First" },
  1501. { type: "text-end", id: "msg_1" },
  1502. { type: "text-start", id: "msg_2" },
  1503. { type: "text-delta", id: "msg_2", text: "Second" },
  1504. { type: "text-end", id: "msg_2" },
  1505. ])
  1506. }),
  1507. )
  1508. it.effect("parses reasoning summary stream fixtures", () =>
  1509. Effect.gen(function* () {
  1510. const body = sseEvents(
  1511. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
  1512. { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
  1513. { type: "response.reasoning_summary_text.done", item_id: "rs_1" },
  1514. { type: "response.completed", response: { id: "resp_1" } },
  1515. )
  1516. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  1517. expect(response.reasoning).toBe("thinking")
  1518. expect(response.text).toBe("Hello")
  1519. expect(response.events).toMatchObject([
  1520. { type: "step-start", index: 0 },
  1521. { type: "reasoning-start", id: "rs_1" },
  1522. { type: "reasoning-delta", id: "rs_1", text: "thinking" },
  1523. { type: "text-start", id: "msg_1" },
  1524. { type: "text-delta", id: "msg_1", text: "Hello" },
  1525. { type: "reasoning-end", id: "rs_1" },
  1526. { type: "text-end", id: "msg_1" },
  1527. { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
  1528. { type: "finish", reason: { normalized: "stop", raw: undefined } },
  1529. ])
  1530. expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
  1531. expect(response.message.content).toEqual([
  1532. { type: "reasoning", text: "thinking" },
  1533. { type: "text", text: "Hello" },
  1534. ])
  1535. }),
  1536. )
  1537. it.effect("preserves encrypted reasoning metadata for continuation", () =>
  1538. Effect.gen(function* () {
  1539. const response = yield* LLMClient.generate(request).pipe(
  1540. Effect.provide(
  1541. fixedResponse(
  1542. sseEvents(
  1543. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
  1544. {
  1545. type: "response.output_item.done",
  1546. item: {
  1547. type: "reasoning",
  1548. id: "rs_1",
  1549. encrypted_content: "encrypted-state",
  1550. summary: [{ type: "summary_text", text: "thinking" }],
  1551. },
  1552. },
  1553. { type: "response.completed", response: { id: "resp_1" } },
  1554. ),
  1555. ),
  1556. ),
  1557. )
  1558. expect(response.events).toContainEqual(
  1559. expect.objectContaining({
  1560. type: "reasoning-end",
  1561. id: "rs_1",
  1562. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  1563. }),
  1564. )
  1565. }),
  1566. )
  1567. it.effect("streams each reasoning summary part as a separate block", () =>
  1568. Effect.gen(function* () {
  1569. const response = yield* LLMClient.generate(
  1570. LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
  1571. ).pipe(
  1572. Effect.provide(
  1573. fixedResponse(
  1574. sseEvents(
  1575. {
  1576. type: "response.output_item.added",
  1577. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  1578. },
  1579. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  1580. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
  1581. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  1582. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
  1583. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
  1584. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
  1585. {
  1586. type: "response.output_item.done",
  1587. item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
  1588. },
  1589. { type: "response.completed", response: { id: "resp_1" } },
  1590. ),
  1591. ),
  1592. ),
  1593. )
  1594. expect(response.reasoning).toBe("FirstSecond")
  1595. expect(response.events).toMatchObject([
  1596. { type: "step-start", index: 0 },
  1597. {
  1598. type: "reasoning-start",
  1599. id: "rs_1:0",
  1600. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
  1601. },
  1602. { type: "reasoning-delta", id: "rs_1:0", text: "First" },
  1603. { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
  1604. {
  1605. type: "reasoning-start",
  1606. id: "rs_1:1",
  1607. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
  1608. },
  1609. { type: "reasoning-delta", id: "rs_1:1", text: "Second" },
  1610. {
  1611. type: "reasoning-end",
  1612. id: "rs_1:1",
  1613. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  1614. },
  1615. { type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
  1616. { type: "finish", reason: { normalized: "stop", raw: undefined } },
  1617. ])
  1618. }),
  1619. )
  1620. it.effect("closes reasoning summary parts when storage is not disabled", () =>
  1621. Effect.gen(function* () {
  1622. const response = yield* LLMClient.generate(
  1623. LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
  1624. ).pipe(
  1625. Effect.provide(
  1626. fixedResponse(
  1627. sseEvents(
  1628. {
  1629. type: "response.output_item.added",
  1630. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  1631. },
  1632. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  1633. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
  1634. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  1635. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
  1636. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
  1637. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
  1638. {
  1639. type: "response.output_item.done",
  1640. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  1641. },
  1642. { type: "response.completed", response: { id: "resp_1" } },
  1643. ),
  1644. ),
  1645. ),
  1646. )
  1647. expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
  1648. { type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
  1649. { type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
  1650. ])
  1651. }),
  1652. )
  1653. it.effect("continues a stateless reasoning conversation", () =>
  1654. Effect.gen(function* () {
  1655. const response = yield* LLMClient.generate(
  1656. LLM.request({
  1657. id: "req_reasoning_continue",
  1658. model,
  1659. messages: [
  1660. Message.user("What changed?"),
  1661. Message.assistant([
  1662. {
  1663. type: "reasoning",
  1664. text: "Checked the previous diff.",
  1665. providerMetadata: {
  1666. openai: {
  1667. itemId: "rs_1",
  1668. reasoningEncryptedContent: "encrypted-state",
  1669. },
  1670. },
  1671. },
  1672. { type: "text", text: "The parser changed." },
  1673. ]),
  1674. Message.user("Summarize it."),
  1675. ],
  1676. providerOptions: { openai: { store: false } },
  1677. }),
  1678. ).pipe(
  1679. Effect.provide(
  1680. dynamicResponse((input) =>
  1681. Effect.gen(function* () {
  1682. const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  1683. const body = yield* Effect.promise(() => web.json())
  1684. expect(body).toMatchObject({
  1685. input: [
  1686. { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
  1687. {
  1688. type: "reasoning",
  1689. encrypted_content: "encrypted-state",
  1690. summary: [{ type: "summary_text", text: "Checked the previous diff." }],
  1691. },
  1692. { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
  1693. { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
  1694. ],
  1695. })
  1696. expect(body.input[1]).not.toHaveProperty("id")
  1697. return input.respond(
  1698. sseEvents(
  1699. { type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
  1700. { type: "response.completed", response: { id: "resp_1" } },
  1701. ),
  1702. { headers: { "content-type": "text/event-stream" } },
  1703. )
  1704. }),
  1705. ),
  1706. ),
  1707. )
  1708. expect(response.text).toBe("Parser now round-trips reasoning.")
  1709. }),
  1710. )
  1711. it.effect("preserves assistant content order around reasoning items", () =>
  1712. Effect.gen(function* () {
  1713. const prepared = yield* compileRequest(
  1714. LLM.request({
  1715. id: "req_reasoning_order",
  1716. model,
  1717. messages: [
  1718. Message.assistant([
  1719. { type: "text", text: "Before." },
  1720. {
  1721. type: "reasoning",
  1722. text: "Checked order.",
  1723. providerMetadata: {
  1724. openai: {
  1725. itemId: "rs_1",
  1726. reasoningEncryptedContent: "encrypted-state",
  1727. },
  1728. },
  1729. },
  1730. { type: "text", text: "After." },
  1731. ]),
  1732. ],
  1733. providerOptions: { openai: { store: false } },
  1734. }),
  1735. )
  1736. expect(prepared.body.input).toEqual([
  1737. { role: "assistant", content: [{ type: "output_text", text: "Before." }] },
  1738. {
  1739. type: "reasoning",
  1740. encrypted_content: "encrypted-state",
  1741. summary: [{ type: "summary_text", text: "Checked order." }],
  1742. },
  1743. { role: "assistant", content: [{ type: "output_text", text: "After." }] },
  1744. ])
  1745. }),
  1746. )
  1747. it.effect("references stored reasoning items by id", () =>
  1748. Effect.gen(function* () {
  1749. const prepared = yield* compileRequest(
  1750. LLM.request({
  1751. model,
  1752. messages: [
  1753. Message.assistant([
  1754. {
  1755. type: "reasoning",
  1756. text: "Checked the previous diff.",
  1757. providerMetadata: { openai: { itemId: "rs_1" } },
  1758. },
  1759. ]),
  1760. ],
  1761. providerOptions: { openai: { store: true } },
  1762. }),
  1763. )
  1764. expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
  1765. }),
  1766. )
  1767. it.effect("references stored provider-executed hosted tool results by id", () =>
  1768. Effect.gen(function* () {
  1769. const prepared = yield* compileRequest(
  1770. LLM.request({
  1771. model,
  1772. messages: [
  1773. Message.assistant([
  1774. ToolCallPart.make({
  1775. id: "ws_1",
  1776. name: "web_search",
  1777. input: { query: "effect 4" },
  1778. providerExecuted: true,
  1779. providerMetadata: { openai: { itemId: "ws_1" } },
  1780. }),
  1781. {
  1782. type: "tool-result",
  1783. id: "ws_1",
  1784. name: "web_search",
  1785. result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
  1786. providerExecuted: true,
  1787. providerMetadata: { openai: { itemId: "ws_1" } },
  1788. },
  1789. ]),
  1790. Message.user("Continue."),
  1791. ],
  1792. providerOptions: { openai: { store: true } },
  1793. }),
  1794. )
  1795. expect(prepared.body.input).toEqual([
  1796. { type: "item_reference", id: "ws_1" },
  1797. { role: "user", content: [{ type: "input_text", text: "Continue." }] },
  1798. ])
  1799. }),
  1800. )
  1801. it.effect("continues stateless hosted image generation with the generated image", () =>
  1802. Effect.gen(function* () {
  1803. const imageTool = OpenAI.imageGeneration({ action: "edit" })
  1804. const prepared = yield* compileRequest(
  1805. LLM.request({
  1806. model,
  1807. messages: [
  1808. Message.user("Generate a black triangle."),
  1809. Message.assistant([
  1810. ToolCallPart.make({
  1811. id: "ig_1",
  1812. name: "image_generation",
  1813. input: {},
  1814. providerExecuted: true,
  1815. providerMetadata: { openai: { itemId: "ig_1" } },
  1816. }),
  1817. ToolResultPart.make({
  1818. id: "ig_1",
  1819. name: "image_generation",
  1820. result: {
  1821. type: "content",
  1822. value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
  1823. },
  1824. providerExecuted: true,
  1825. providerMetadata: { openai: { itemId: "ig_1" } },
  1826. }),
  1827. ]),
  1828. Message.user("Make it blue."),
  1829. ],
  1830. tools: [imageTool],
  1831. }),
  1832. )
  1833. expect(prepared.body.store).toBe(false)
  1834. expect(prepared.body.input).toEqual([
  1835. { role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
  1836. { role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
  1837. { role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
  1838. ])
  1839. }),
  1840. )
  1841. it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
  1842. Effect.gen(function* () {
  1843. const prepared = yield* compileRequest(
  1844. LLM.request({
  1845. id: "req_multi_summary_continuation",
  1846. model,
  1847. messages: [
  1848. Message.assistant([
  1849. {
  1850. type: "reasoning",
  1851. text: "First",
  1852. providerMetadata: { openai: { itemId: "rs_1" } },
  1853. },
  1854. {
  1855. type: "reasoning",
  1856. text: "Second",
  1857. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  1858. },
  1859. ]),
  1860. ],
  1861. providerOptions: { openai: { store: false } },
  1862. }),
  1863. )
  1864. expect(prepared.body.input).toEqual([
  1865. {
  1866. type: "reasoning",
  1867. encrypted_content: "encrypted-state",
  1868. summary: [
  1869. { type: "summary_text", text: "First" },
  1870. { type: "summary_text", text: "Second" },
  1871. ],
  1872. },
  1873. ])
  1874. }),
  1875. )
  1876. it.effect("skips non-persisted reasoning ids without encrypted state", () =>
  1877. Effect.gen(function* () {
  1878. const prepared = yield* compileRequest(
  1879. LLM.request({
  1880. id: "req_reasoning_without_encrypted_state",
  1881. model,
  1882. messages: [
  1883. Message.user("What changed?"),
  1884. Message.assistant([
  1885. {
  1886. type: "reasoning",
  1887. text: "Checked the previous diff.",
  1888. providerMetadata: {
  1889. openai: {
  1890. itemId: "rs_1",
  1891. reasoningEncryptedContent: null,
  1892. },
  1893. },
  1894. },
  1895. { type: "text", text: "The parser changed." },
  1896. ]),
  1897. Message.user("Summarize it."),
  1898. ],
  1899. providerOptions: { openai: { store: false } },
  1900. }),
  1901. )
  1902. expect(prepared.body).toMatchObject({
  1903. input: [
  1904. { role: "user", content: [{ type: "input_text", text: "What changed?" }] },
  1905. { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
  1906. { role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
  1907. ],
  1908. store: false,
  1909. })
  1910. }),
  1911. )
  1912. it.effect("assembles streamed function call input", () =>
  1913. Effect.gen(function* () {
  1914. const body = sseEvents(
  1915. {
  1916. type: "response.output_item.added",
  1917. item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
  1918. },
  1919. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query"' },
  1920. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: ':"weather"}' },
  1921. {
  1922. type: "response.output_item.done",
  1923. item: {
  1924. type: "function_call",
  1925. id: "item_1",
  1926. call_id: "call_1",
  1927. name: "lookup",
  1928. arguments: '{"query":"weather"}',
  1929. },
  1930. },
  1931. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  1932. )
  1933. const response = yield* LLMClient.generate(
  1934. LLMRequest.update(request, {
  1935. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  1936. }),
  1937. ).pipe(Effect.provide(fixedResponse(body)))
  1938. const usage = new Usage({
  1939. inputTokens: 5,
  1940. outputTokens: 1,
  1941. nonCachedInputTokens: 5,
  1942. cacheReadInputTokens: undefined,
  1943. reasoningTokens: undefined,
  1944. totalTokens: 6,
  1945. providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
  1946. })
  1947. expect(response.events).toEqual([
  1948. { type: "step-start", index: 0 },
  1949. {
  1950. type: "tool-input-start",
  1951. id: "call_1",
  1952. name: "lookup",
  1953. providerMetadata: { openai: { itemId: "item_1" } },
  1954. },
  1955. {
  1956. type: "tool-input-delta",
  1957. id: "call_1",
  1958. name: "lookup",
  1959. text: '{"query"',
  1960. },
  1961. {
  1962. type: "tool-input-delta",
  1963. id: "call_1",
  1964. name: "lookup",
  1965. text: ':"weather"}',
  1966. },
  1967. {
  1968. type: "tool-input-end",
  1969. id: "call_1",
  1970. name: "lookup",
  1971. providerMetadata: { openai: { itemId: "item_1" } },
  1972. },
  1973. {
  1974. type: "tool-call",
  1975. id: "call_1",
  1976. name: "lookup",
  1977. input: { query: "weather" },
  1978. providerExecuted: undefined,
  1979. providerMetadata: { openai: { itemId: "item_1" } },
  1980. },
  1981. {
  1982. type: "step-finish",
  1983. index: 0,
  1984. reason: { normalized: "tool-calls", raw: undefined },
  1985. usage,
  1986. providerMetadata: undefined,
  1987. },
  1988. {
  1989. type: "finish",
  1990. reason: { normalized: "tool-calls", raw: undefined },
  1991. providerMetadata: undefined,
  1992. usage,
  1993. },
  1994. ])
  1995. }),
  1996. )
  1997. it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
  1998. Effect.gen(function* () {
  1999. const body = sseEvents(
  2000. {
  2001. type: "response.output_item.added",
  2002. item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
  2003. },
  2004. { type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
  2005. {
  2006. type: "response.output_item.done",
  2007. item: {
  2008. type: "function_call",
  2009. id: "item_1",
  2010. call_id: "call_1",
  2011. name: "lookup",
  2012. arguments: '{"query":"partial',
  2013. },
  2014. },
  2015. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  2016. )
  2017. const response = yield* LLMClient.generate(
  2018. LLMRequest.update(request, {
  2019. tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
  2020. }),
  2021. ).pipe(Effect.provide(fixedResponse(body)))
  2022. expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
  2023. type: "tool-input-error",
  2024. id: "call_1",
  2025. name: "lookup",
  2026. raw: '{"query":"partial',
  2027. })
  2028. expect(response.finishReason.normalized).toBe("tool-calls")
  2029. expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
  2030. }),
  2031. )
  2032. it.effect("settles malformed function arguments when output_item.added is absent", () =>
  2033. Effect.gen(function* () {
  2034. const body = sseEvents(
  2035. {
  2036. type: "response.output_item.done",
  2037. item: {
  2038. type: "function_call",
  2039. id: "item_1",
  2040. call_id: "call_1",
  2041. name: "lookup",
  2042. arguments: '{"query":"partial',
  2043. },
  2044. },
  2045. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  2046. )
  2047. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  2048. expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
  2049. id: "call_1",
  2050. name: "lookup",
  2051. raw: '{"query":"partial',
  2052. })
  2053. expect(response.finishReason.normalized).toBe("tool-calls")
  2054. }),
  2055. )
  2056. it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
  2057. Effect.gen(function* () {
  2058. const item = {
  2059. type: "web_search_call",
  2060. id: "ws_1",
  2061. status: "completed",
  2062. action: { type: "search", query: "effect 4" },
  2063. }
  2064. const body = sseEvents(
  2065. { type: "response.output_item.added", item },
  2066. { type: "response.output_item.done", item },
  2067. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  2068. )
  2069. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  2070. const callsAndResults = response.events.filter(
  2071. (event) => event.type === "tool-call" || event.type === "tool-result",
  2072. )
  2073. expect(callsAndResults).toEqual([
  2074. {
  2075. type: "tool-call",
  2076. id: "ws_1",
  2077. name: "web_search",
  2078. input: { type: "search", query: "effect 4" },
  2079. providerExecuted: true,
  2080. providerMetadata: { openai: { itemId: "ws_1" } },
  2081. },
  2082. {
  2083. type: "tool-result",
  2084. id: "ws_1",
  2085. name: "web_search",
  2086. result: { type: "json", value: item },
  2087. providerExecuted: true,
  2088. providerMetadata: { openai: { itemId: "ws_1" } },
  2089. },
  2090. ])
  2091. }),
  2092. )
  2093. it.effect("decodes image generation output as image content", () =>
  2094. Effect.gen(function* () {
  2095. const item = {
  2096. type: "image_generation_call",
  2097. id: "ig_1",
  2098. status: "completed",
  2099. result: "AQID",
  2100. }
  2101. const response = yield* LLMClient.generate(request).pipe(
  2102. Effect.provide(
  2103. fixedResponse(
  2104. sseEvents(
  2105. { type: "response.output_item.done", item },
  2106. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  2107. ),
  2108. ),
  2109. ),
  2110. )
  2111. expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
  2112. id: "ig_1",
  2113. name: "image_generation",
  2114. providerExecuted: true,
  2115. result: {
  2116. type: "content",
  2117. value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
  2118. },
  2119. })
  2120. }),
  2121. )
  2122. it.effect("rejects malformed image generation base64", () =>
  2123. Effect.gen(function* () {
  2124. const error = yield* LLMClient.generate(request).pipe(
  2125. Effect.provide(
  2126. fixedResponse(
  2127. sseEvents(
  2128. {
  2129. type: "response.output_item.done",
  2130. item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
  2131. },
  2132. { type: "response.completed", response: {} },
  2133. ),
  2134. ),
  2135. ),
  2136. Effect.flip,
  2137. )
  2138. expect(error.reason._tag).toBe("InvalidProviderOutput")
  2139. expect(error.message).toContain("invalid image base64")
  2140. }),
  2141. )
  2142. it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
  2143. Effect.gen(function* () {
  2144. const item = {
  2145. type: "code_interpreter_call",
  2146. id: "ci_1",
  2147. status: "completed",
  2148. code: "print(1+1)",
  2149. container_id: "cnt_xyz",
  2150. outputs: [{ type: "logs", logs: "2\n" }],
  2151. }
  2152. const body = sseEvents(
  2153. { type: "response.output_item.done", item },
  2154. { type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
  2155. )
  2156. const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
  2157. const toolCall = response.events.find((event) => event.type === "tool-call")
  2158. expect(toolCall).toEqual({
  2159. type: "tool-call",
  2160. id: "ci_1",
  2161. name: "code_interpreter",
  2162. input: { code: "print(1+1)", container_id: "cnt_xyz" },
  2163. providerExecuted: true,
  2164. providerMetadata: { openai: { itemId: "ci_1" } },
  2165. })
  2166. const toolResult = response.events.find((event) => event.type === "tool-result")
  2167. expect(toolResult).toEqual({
  2168. type: "tool-result",
  2169. id: "ci_1",
  2170. name: "code_interpreter",
  2171. result: { type: "json", value: item },
  2172. providerExecuted: true,
  2173. providerMetadata: { openai: { itemId: "ci_1" } },
  2174. })
  2175. }),
  2176. )
  2177. it.effect("lowers user image and PDF content", () =>
  2178. Effect.gen(function* () {
  2179. const prepared = yield* compileRequest(
  2180. LLM.request({
  2181. id: "req_media",
  2182. model,
  2183. messages: [
  2184. Message.user([
  2185. { type: "media", mediaType: "image/png", data: "AAECAw==" },
  2186. { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
  2187. ]),
  2188. ],
  2189. }),
  2190. )
  2191. expect(prepared.body.input).toEqual([
  2192. {
  2193. role: "user",
  2194. content: [
  2195. { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
  2196. {
  2197. type: "input_file",
  2198. filename: "report.pdf",
  2199. file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
  2200. },
  2201. ],
  2202. },
  2203. ])
  2204. }),
  2205. )
  2206. it.effect("uses xAI inline file encoding for user PDFs", () =>
  2207. Effect.gen(function* () {
  2208. const prepared = yield* compileRequest(
  2209. LLM.request({
  2210. model: xaiModel,
  2211. messages: [
  2212. Message.user({
  2213. type: "media",
  2214. mediaType: "application/pdf",
  2215. data: "data:application/pdf;base64,JVBERi0xLjQ=",
  2216. filename: "report.pdf",
  2217. }),
  2218. ],
  2219. }),
  2220. )
  2221. expect(prepared.body.input).toEqual([
  2222. {
  2223. role: "user",
  2224. content: [
  2225. {
  2226. type: "input_file",
  2227. filename: "report.pdf",
  2228. file_data: "JVBERi0xLjQ=",
  2229. mime_type: "application/pdf",
  2230. },
  2231. ],
  2232. },
  2233. ])
  2234. }),
  2235. )
  2236. it.effect("rejects unsupported user media content", () =>
  2237. Effect.gen(function* () {
  2238. const error = yield* compileRequest(
  2239. LLM.request({
  2240. id: "req_media",
  2241. model,
  2242. messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
  2243. }),
  2244. ).pipe(Effect.flip)
  2245. expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
  2246. }),
  2247. )
  2248. it.effect("fails with a typed rate limit for provider error frames", () =>
  2249. Effect.gen(function* () {
  2250. const error = yield* LLMClient.generate(request).pipe(
  2251. Effect.provide(fixedResponse(sseEvents({ type: "error", code: "rate_limit_exceeded", message: "Slow down" }))),
  2252. Effect.flip,
  2253. )
  2254. expect(error).toBeInstanceOf(AIError)
  2255. expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" })
  2256. }),
  2257. )
  2258. it.effect("falls back to error code when no message is present", () =>
  2259. Effect.gen(function* () {
  2260. const error = yield* LLMClient.generate(request).pipe(
  2261. Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error" }))),
  2262. Effect.flip,
  2263. )
  2264. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
  2265. }),
  2266. )
  2267. it.effect("falls back to error code when message is empty", () =>
  2268. Effect.gen(function* () {
  2269. const error = yield* LLMClient.generate(request).pipe(
  2270. Effect.provide(fixedResponse(sseEvents({ type: "error", code: "internal_error", message: "" }))),
  2271. Effect.flip,
  2272. )
  2273. expect(error.reason).toMatchObject({ _tag: "ProviderInternal", message: "internal_error" })
  2274. }),
  2275. )
  2276. // Regression: `response.failed` carries the failure details under
  2277. // `response.error`, not at the top level. The previous handler only
  2278. // checked top-level `message`/`code` and so always emitted the bare
  2279. // "OpenAI Responses response failed" string, hiding the real cause.
  2280. it.effect("surfaces response.failed details from response.error", () =>
  2281. Effect.gen(function* () {
  2282. const error = yield* LLMClient.generate(request).pipe(
  2283. Effect.provide(
  2284. fixedResponse(
  2285. sseEvents({
  2286. type: "response.failed",
  2287. response: {
  2288. id: "resp_failed_1",
  2289. error: { code: "server_error", message: "Upstream model unavailable" },
  2290. },
  2291. }),
  2292. ),
  2293. ),
  2294. Effect.flip,
  2295. )
  2296. expect(error.reason).toMatchObject({
  2297. _tag: "ProviderInternal",
  2298. message: "server_error: Upstream model unavailable",
  2299. })
  2300. }),
  2301. )
  2302. it.effect("surfaces response.failed code when no nested message is present", () =>
  2303. Effect.gen(function* () {
  2304. const error = yield* LLMClient.generate(request).pipe(
  2305. Effect.provide(
  2306. fixedResponse(
  2307. sseEvents({
  2308. type: "response.failed",
  2309. response: { id: "resp_failed_2", error: { code: "invalid_prompt" } },
  2310. }),
  2311. ),
  2312. ),
  2313. Effect.flip,
  2314. )
  2315. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "invalid_prompt" })
  2316. }),
  2317. )
  2318. it.effect("surfaces error event details nested under response.error", () =>
  2319. Effect.gen(function* () {
  2320. // Some OpenAI-compatible proxies and older SDK versions wrap the
  2321. // top-level error fields into a nested `response.error` payload
  2322. // when they bubble up an HTTP error as an SSE `error` event. Honour
  2323. // both shapes so the user still sees the underlying cause instead
  2324. // of the catch-all string.
  2325. const error = yield* LLMClient.generate(request).pipe(
  2326. Effect.provide(
  2327. fixedResponse(
  2328. sseEvents({
  2329. type: "error",
  2330. response: { error: { code: "context_length_exceeded", message: "prompt too long" } },
  2331. }),
  2332. ),
  2333. ),
  2334. Effect.flip,
  2335. )
  2336. expect(error.reason).toMatchObject({
  2337. _tag: "InvalidRequest",
  2338. message: "context_length_exceeded: prompt too long",
  2339. classification: "context-overflow",
  2340. })
  2341. }),
  2342. )
  2343. it.effect("surfaces error event details nested under error", () =>
  2344. Effect.gen(function* () {
  2345. const error = yield* LLMClient.generate(request).pipe(
  2346. Effect.provide(
  2347. fixedResponse(
  2348. sseEvents({
  2349. type: "error",
  2350. sequence_number: 2,
  2351. error: {
  2352. type: "invalid_request_error",
  2353. code: "context_length_exceeded",
  2354. message: "prompt too long",
  2355. param: "input",
  2356. },
  2357. }),
  2358. ),
  2359. ),
  2360. Effect.flip,
  2361. )
  2362. expect(error.reason).toMatchObject({
  2363. _tag: "InvalidRequest",
  2364. message: "context_length_exceeded: prompt too long",
  2365. classification: "context-overflow",
  2366. })
  2367. }),
  2368. )
  2369. it.effect("accepts nullable fields in spec-compliant error events", () =>
  2370. Effect.gen(function* () {
  2371. const error = yield* LLMClient.generate(request).pipe(
  2372. Effect.provide(
  2373. fixedResponse(
  2374. sseEvents({
  2375. type: "error",
  2376. code: null,
  2377. message: "Something went wrong",
  2378. param: null,
  2379. sequence_number: 1,
  2380. }),
  2381. ),
  2382. ),
  2383. Effect.flip,
  2384. )
  2385. expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Something went wrong" })
  2386. }),
  2387. )
  2388. it.effect("falls back to a stable default when error is null", () =>
  2389. Effect.gen(function* () {
  2390. const error = yield* LLMClient.generate(request).pipe(
  2391. Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
  2392. Effect.flip,
  2393. )
  2394. expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
  2395. }),
  2396. )
  2397. it.effect("falls back to a stable default when both error and response are absent", () =>
  2398. Effect.gen(function* () {
  2399. const error = yield* LLMClient.generate(request).pipe(
  2400. Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
  2401. Effect.flip,
  2402. )
  2403. expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
  2404. }),
  2405. )
  2406. it.effect("falls back to a stable default when response.failed has no error payload", () =>
  2407. Effect.gen(function* () {
  2408. const error = yield* LLMClient.generate(request).pipe(
  2409. Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
  2410. Effect.flip,
  2411. )
  2412. expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
  2413. }),
  2414. )
  2415. it.effect("fails HTTP provider errors before stream parsing", () =>
  2416. Effect.gen(function* () {
  2417. const error = yield* LLMClient.generate(request).pipe(
  2418. Effect.provide(
  2419. fixedResponse('{"error":{"type":"invalid_request_error","message":"Bad request"}}', {
  2420. status: 400,
  2421. headers: { "content-type": "application/json" },
  2422. }),
  2423. ),
  2424. Effect.flip,
  2425. )
  2426. expect(error).toBeInstanceOf(AIError)
  2427. expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
  2428. }),
  2429. )
  2430. })