llm-native.test.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. import { describe, expect, test } from "bun:test"
  2. import { ToolFailure } from "@opencode-ai/llm"
  3. import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
  4. import { jsonSchema, tool, type ModelMessage, type Tool } from "ai"
  5. import { Effect, Layer, Stream } from "effect"
  6. import { LLMNative } from "@/session/llm/native-request"
  7. import { LLMNativeRuntime } from "@/session/llm/native-runtime"
  8. import type { Provider } from "@/provider/provider"
  9. import { ModelID, ProviderID } from "@/provider/schema"
  10. import { OAUTH_DUMMY_KEY } from "@/auth"
  11. import { testEffect } from "../lib/effect"
  12. const baseModel: Provider.Model = {
  13. id: ModelID.make("gpt-5-mini"),
  14. providerID: ProviderID.make("openai"),
  15. api: {
  16. id: "gpt-5-mini",
  17. url: "https://api.openai.com/v1",
  18. npm: "@ai-sdk/openai",
  19. },
  20. name: "GPT-5 Mini",
  21. capabilities: {
  22. temperature: true,
  23. reasoning: true,
  24. attachment: true,
  25. toolcall: true,
  26. input: {
  27. text: true,
  28. audio: false,
  29. image: true,
  30. video: false,
  31. pdf: false,
  32. },
  33. output: {
  34. text: true,
  35. audio: false,
  36. image: false,
  37. video: false,
  38. pdf: false,
  39. },
  40. interleaved: false,
  41. },
  42. cost: {
  43. input: 0,
  44. output: 0,
  45. cache: {
  46. read: 0,
  47. write: 0,
  48. },
  49. },
  50. limit: {
  51. context: 128_000,
  52. input: 128_000,
  53. output: 32_000,
  54. },
  55. status: "active",
  56. options: {},
  57. headers: {
  58. "x-model": "model-header",
  59. },
  60. release_date: "2026-01-01",
  61. }
  62. const providerInfo: Provider.Info = {
  63. id: ProviderID.make("openai"),
  64. name: "OpenAI",
  65. source: "config",
  66. env: ["OPENAI_API_KEY"],
  67. options: { apiKey: "test-openai-key" },
  68. models: {},
  69. }
  70. const it = testEffect(
  71. LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))),
  72. )
  73. function responsesStream(chunks: unknown[]) {
  74. return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", {
  75. status: 200,
  76. headers: { "Content-Type": "text/event-stream" },
  77. })
  78. }
  79. type NativeRequestInput = Parameters<typeof LLMNative.request>[0]
  80. const sessionText = (text: string) => ({ type: "text" as const, text })
  81. const sessionOpenAIReasoning = (
  82. text: string,
  83. options: {
  84. readonly storedAs: "providerMetadata" | "providerOptions"
  85. readonly itemId: string
  86. readonly encryptedContent: string | null
  87. },
  88. ) => {
  89. const metadata = {
  90. openai: { itemId: options.itemId, reasoningEncryptedContent: options.encryptedContent },
  91. }
  92. if (options.storedAs === "providerMetadata")
  93. return Object.assign({ type: "reasoning" as const, text }, { providerMetadata: metadata })
  94. return Object.assign({ type: "reasoning" as const, text }, { providerOptions: metadata })
  95. }
  96. type SessionAssistantPart = ReturnType<typeof sessionText> | ReturnType<typeof sessionOpenAIReasoning>
  97. const storedSession = {
  98. user: (content: string): ModelMessage => ({ role: "user", content }),
  99. assistant: (content: SessionAssistantPart[]): ModelMessage => ({ role: "assistant", content }),
  100. text: sessionText,
  101. openaiReasoning: sessionOpenAIReasoning,
  102. }
  103. const openAIResponses = {
  104. user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }),
  105. assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }),
  106. openaiReasoning: (text: string, options: { readonly itemId: string; readonly encryptedContent: string }) => ({
  107. type: "reasoning",
  108. id: options.itemId,
  109. encrypted_content: options.encryptedContent,
  110. summary: [{ type: "summary_text", text }],
  111. }),
  112. }
  113. const prepareNativeRequest = (input: NativeRequestInput) => LLMClient.prepare(LLMNative.request(input))
  114. const expectOpenAIResponsesRequest = (input: {
  115. readonly history: NativeRequestInput["messages"]
  116. readonly providerOptions?: NativeRequestInput["providerOptions"]
  117. readonly maxOutputTokens?: NativeRequestInput["maxOutputTokens"]
  118. readonly headers?: NativeRequestInput["headers"]
  119. readonly expectedBody: unknown
  120. }) =>
  121. Effect.gen(function* () {
  122. expect(
  123. yield* prepareNativeRequest({
  124. model: baseModel,
  125. apiKey: "test-openai-key",
  126. messages: input.history,
  127. providerOptions: input.providerOptions,
  128. maxOutputTokens: input.maxOutputTokens,
  129. headers: input.headers,
  130. }),
  131. ).toMatchObject({
  132. route: "openai-responses",
  133. protocol: "openai-responses",
  134. body: input.expectedBody,
  135. })
  136. })
  137. describe("session.llm-native.request", () => {
  138. test("maps normalized stream inputs to a native LLM request", () => {
  139. const messages: ModelMessage[] = [
  140. {
  141. role: "system",
  142. content: "system from messages",
  143. },
  144. {
  145. role: "user",
  146. content: [
  147. { type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
  148. { type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
  149. ],
  150. },
  151. {
  152. role: "assistant",
  153. content: [
  154. { type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
  155. { type: "text", text: "I'll run it" },
  156. {
  157. type: "tool-call",
  158. toolCallId: "call-1",
  159. toolName: "bash",
  160. input: { command: "ls" },
  161. providerOptions: { openai: { itemId: "item-1" } },
  162. },
  163. ],
  164. },
  165. {
  166. role: "tool",
  167. content: [
  168. {
  169. type: "tool-result",
  170. toolCallId: "call-1",
  171. toolName: "bash",
  172. output: { type: "text", value: "ok" },
  173. providerOptions: { openai: { outputId: "output-1" } },
  174. },
  175. ],
  176. },
  177. ]
  178. const request = LLMNative.request({
  179. model: baseModel,
  180. system: ["agent system"],
  181. messages,
  182. tools: {
  183. bash: tool({
  184. description: "Run a shell command",
  185. inputSchema: jsonSchema({
  186. type: "object",
  187. properties: {
  188. command: { type: "string" },
  189. },
  190. required: ["command"],
  191. }),
  192. }),
  193. },
  194. toolChoice: "required",
  195. temperature: 0.2,
  196. topP: 0.9,
  197. topK: 40,
  198. maxOutputTokens: 1024,
  199. providerOptions: { openai: { store: false } },
  200. headers: { "x-request": "request-header" },
  201. })
  202. expect(request.model).toMatchObject({
  203. id: "gpt-5-mini",
  204. provider: "openai",
  205. route: { id: "openai-responses" },
  206. })
  207. expect(request.model.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
  208. expect(request.model.route.defaults.headers).toEqual({
  209. "x-model": "model-header",
  210. "x-request": "request-header",
  211. })
  212. expect(request.model.route.defaults.limits).toMatchObject({
  213. context: 128_000,
  214. output: 32_000,
  215. })
  216. expect(request.system).toEqual([
  217. { type: "text", text: "agent system" },
  218. { type: "text", text: "system from messages" },
  219. ])
  220. expect(request.generation).toMatchObject({
  221. temperature: 0.2,
  222. topP: 0.9,
  223. topK: 40,
  224. maxTokens: 1024,
  225. })
  226. expect(request.providerOptions).toEqual({ openai: { store: false } })
  227. expect(request.toolChoice).toMatchObject({ type: "required" })
  228. expect(request.tools).toMatchObject([
  229. {
  230. name: "bash",
  231. description: "Run a shell command",
  232. inputSchema: {
  233. type: "object",
  234. properties: {
  235. command: { type: "string" },
  236. },
  237. required: ["command"],
  238. },
  239. },
  240. ])
  241. expect(request.messages).toMatchObject([
  242. {
  243. role: "user",
  244. content: [
  245. { type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
  246. { type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
  247. ],
  248. },
  249. {
  250. role: "assistant",
  251. content: [
  252. { type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
  253. { type: "text", text: "I'll run it" },
  254. {
  255. type: "tool-call",
  256. id: "call-1",
  257. name: "bash",
  258. input: { command: "ls" },
  259. providerMetadata: { openai: { itemId: "item-1" } },
  260. },
  261. ],
  262. },
  263. {
  264. role: "tool",
  265. content: [
  266. {
  267. type: "tool-result",
  268. id: "call-1",
  269. name: "bash",
  270. result: { type: "text", value: "ok" },
  271. providerMetadata: { openai: { outputId: "output-1" } },
  272. },
  273. ],
  274. },
  275. ])
  276. })
  277. test("maps stored provider metadata to native content metadata", () => {
  278. const reasoning = Object.assign(
  279. { type: "reasoning" as const, text: "thinking" },
  280. {
  281. providerMetadata: {
  282. openai: {
  283. itemId: "rs_1",
  284. reasoningEncryptedContent: "encrypted-state",
  285. },
  286. },
  287. },
  288. )
  289. const request = LLMNative.request({
  290. model: baseModel,
  291. messages: [
  292. {
  293. role: "assistant",
  294. content: [reasoning],
  295. },
  296. ],
  297. })
  298. expect(request.messages).toMatchObject([
  299. {
  300. role: "assistant",
  301. content: [
  302. {
  303. type: "reasoning",
  304. text: "thinking",
  305. providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
  306. },
  307. ],
  308. },
  309. ])
  310. })
  311. test("selects native request routes for provider packages", () => {
  312. const openai = LLMNative.model({
  313. model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
  314. apiKey: "test-key",
  315. messages: [],
  316. })
  317. expect(openai.route.id).toBe("openai-responses")
  318. expect(openai.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
  319. const anthropic = LLMNative.model({
  320. model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } },
  321. apiKey: "test-key",
  322. messages: [],
  323. })
  324. expect(anthropic.route.id).toBe("anthropic-messages")
  325. expect(anthropic.route.endpoint.baseURL).toBe("https://api.anthropic.com/v1")
  326. const google = LLMNative.model({
  327. model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } },
  328. apiKey: "test-key",
  329. messages: [],
  330. })
  331. expect(google.route.id).toBe("gemini")
  332. expect(google.route.endpoint.baseURL).toBe("https://generativelanguage.googleapis.com/v1beta")
  333. const compatible = LLMNative.model({
  334. model: {
  335. ...baseModel,
  336. providerID: ProviderID.make("opencode"),
  337. api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
  338. },
  339. apiKey: "test-key",
  340. messages: [],
  341. })
  342. expect(compatible.route.id).toBe("openai-compatible-chat")
  343. expect(compatible.route.endpoint.baseURL).toBe("https://ai.example.test/v1")
  344. const openrouter = LLMNative.model({
  345. model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } },
  346. apiKey: "test-key",
  347. messages: [],
  348. })
  349. expect(openrouter.route.id).toBe("openrouter")
  350. expect(openrouter.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
  351. })
  352. test("fails fast for unsupported provider packages", () => {
  353. expect(() =>
  354. LLMNative.request({
  355. model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
  356. messages: [],
  357. }),
  358. ).toThrow("Native LLM request adapter does not support provider package unknown-provider")
  359. })
  360. test("only enables native runtime for supported OpenAI API-key models", () => {
  361. expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({
  362. type: "supported",
  363. apiKey: "test-openai-key",
  364. })
  365. expect(
  366. LLMNativeRuntime.status({
  367. model: { ...baseModel, providerID: ProviderID.make("opencode") },
  368. provider: { ...providerInfo, id: ProviderID.make("opencode") },
  369. auth: undefined,
  370. }),
  371. ).toMatchObject({
  372. type: "supported",
  373. apiKey: "test-openai-key",
  374. })
  375. expect(
  376. LLMNativeRuntime.status({
  377. model: {
  378. ...baseModel,
  379. providerID: ProviderID.make("opencode"),
  380. api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" },
  381. },
  382. provider: { ...providerInfo, id: ProviderID.make("opencode") },
  383. auth: undefined,
  384. }),
  385. ).toMatchObject({
  386. type: "supported",
  387. apiKey: "test-openai-key",
  388. })
  389. expect(
  390. LLMNativeRuntime.status({
  391. model: { ...baseModel, providerID: ProviderID.make("google") },
  392. provider: { ...providerInfo, id: ProviderID.make("google") },
  393. auth: undefined,
  394. }),
  395. ).toEqual({ type: "unsupported", reason: "provider is not openai, opencode, or anthropic" })
  396. expect(
  397. LLMNativeRuntime.status({
  398. model: baseModel,
  399. provider: providerInfo,
  400. auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
  401. }),
  402. ).toEqual({ type: "unsupported", reason: "OAuth auth requires a provider fetch override" })
  403. expect(
  404. LLMNativeRuntime.status({
  405. model: baseModel,
  406. provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: async () => new Response() } },
  407. auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
  408. }),
  409. ).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY })
  410. expect(
  411. LLMNativeRuntime.status({
  412. model: { ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/google" } },
  413. provider: providerInfo,
  414. auth: undefined,
  415. }),
  416. ).toEqual({ type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" })
  417. expect(
  418. LLMNativeRuntime.status({
  419. model: baseModel,
  420. provider: { ...providerInfo, options: {} },
  421. auth: undefined,
  422. }),
  423. ).toEqual({ type: "unsupported", reason: "API key is not configured" })
  424. })
  425. test("enables native runtime for Anthropic API-key models", () => {
  426. expect(
  427. LLMNativeRuntime.status({
  428. model: {
  429. ...baseModel,
  430. providerID: ProviderID.make("anthropic"),
  431. api: { ...baseModel.api, npm: "@ai-sdk/anthropic", url: "https://api.anthropic.com/v1" },
  432. },
  433. provider: {
  434. ...providerInfo,
  435. id: ProviderID.make("anthropic"),
  436. name: "Anthropic",
  437. env: ["ANTHROPIC_API_KEY"],
  438. options: { apiKey: "test-anthropic-key" },
  439. },
  440. auth: undefined,
  441. }),
  442. ).toMatchObject({ type: "supported", apiKey: "test-anthropic-key" })
  443. })
  444. test("prefers console provider api key over stored opencode auth", () => {
  445. expect(
  446. LLMNativeRuntime.status({
  447. model: { ...baseModel, providerID: ProviderID.make("opencode") },
  448. provider: {
  449. ...providerInfo,
  450. id: ProviderID.make("opencode"),
  451. options: { apiKey: "console-token" },
  452. key: "zen-token",
  453. },
  454. auth: { type: "api", key: "zen-token" },
  455. }),
  456. ).toMatchObject({
  457. type: "supported",
  458. apiKey: "console-token",
  459. })
  460. expect(
  461. LLMNativeRuntime.status({
  462. model: baseModel,
  463. provider: { ...providerInfo, options: {}, key: "provider-key" },
  464. auth: undefined,
  465. }),
  466. ).toMatchObject({
  467. type: "supported",
  468. apiKey: "provider-key",
  469. })
  470. })
  471. it.effect("native tool wrapper converts thrown errors into typed ToolFailure", () =>
  472. Effect.gen(function* () {
  473. const wrapped = LLMNativeRuntime.nativeTools(
  474. {
  475. explode: {
  476. description: "always throws",
  477. inputSchema: jsonSchema({ type: "object" }),
  478. execute: async () => {
  479. throw new Error("boom")
  480. },
  481. } satisfies Tool,
  482. },
  483. { messages: [] as ModelMessage[], abort: new AbortController().signal },
  484. )
  485. const failure = yield* Effect.flip(wrapped.explode.execute({}, { id: "call-1", name: "explode" }))
  486. expect(failure).toBeInstanceOf(ToolFailure)
  487. expect(failure.message).toBe("boom")
  488. }),
  489. )
  490. it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () =>
  491. Effect.gen(function* () {
  492. // The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
  493. // The native runtime owns execution, so encountering such a tool here means upstream
  494. // wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
  495. const wrapped = LLMNativeRuntime.nativeTools(
  496. { incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } satisfies Tool },
  497. { messages: [] as ModelMessage[], abort: new AbortController().signal },
  498. )
  499. const failure = yield* Effect.flip(wrapped.incomplete.execute({}, { id: "call-1", name: "incomplete" }))
  500. expect(failure).toBeInstanceOf(ToolFailure)
  501. expect(failure.message).toContain("incomplete")
  502. }),
  503. )
  504. it.effect("compiles through the native OpenAI Responses route", () =>
  505. expectOpenAIResponsesRequest({
  506. history: [storedSession.user("hello")],
  507. providerOptions: { openai: { store: false, instructions: "You are concise." } },
  508. maxOutputTokens: 512,
  509. headers: { "x-request": "request-header" },
  510. expectedBody: {
  511. model: "gpt-5-mini",
  512. instructions: "You are concise.",
  513. input: [openAIResponses.user("hello")],
  514. max_output_tokens: 512,
  515. store: false,
  516. stream: true,
  517. },
  518. }),
  519. )
  520. it.effect("omits non-persisted OpenAI reasoning ids without encrypted state", () =>
  521. expectOpenAIResponsesRequest({
  522. history: [
  523. storedSession.user("What changed?"),
  524. storedSession.assistant([
  525. storedSession.openaiReasoning("Checked the previous diff.", {
  526. storedAs: "providerOptions",
  527. itemId: "rs_1",
  528. encryptedContent: null,
  529. }),
  530. storedSession.text("The parser changed."),
  531. ]),
  532. storedSession.user("Summarize it."),
  533. ],
  534. providerOptions: { openai: { store: false } },
  535. expectedBody: {
  536. input: [
  537. openAIResponses.user("What changed?"),
  538. openAIResponses.assistant("The parser changed."),
  539. openAIResponses.user("Summarize it."),
  540. ],
  541. store: false,
  542. },
  543. }),
  544. )
  545. it.effect("preserves encrypted OpenAI reasoning state through native request lowering", () =>
  546. expectOpenAIResponsesRequest({
  547. history: [
  548. storedSession.user("What changed?"),
  549. storedSession.assistant([
  550. storedSession.openaiReasoning("Checked the previous diff.", {
  551. storedAs: "providerMetadata",
  552. itemId: "rs_1",
  553. encryptedContent: "encrypted-state",
  554. }),
  555. storedSession.text("The parser changed."),
  556. ]),
  557. storedSession.user("Summarize it."),
  558. ],
  559. providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
  560. expectedBody: {
  561. input: [
  562. openAIResponses.user("What changed?"),
  563. openAIResponses.openaiReasoning("Checked the previous diff.", {
  564. itemId: "rs_1",
  565. encryptedContent: "encrypted-state",
  566. }),
  567. openAIResponses.assistant("The parser changed."),
  568. openAIResponses.user("Summarize it."),
  569. ],
  570. include: ["reasoning.encrypted_content"],
  571. store: false,
  572. },
  573. }),
  574. )
  575. it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
  576. Effect.gen(function* () {
  577. const captures: Array<{ url: string; body: unknown }> = []
  578. const customFetch = Object.assign(
  579. async (input: Parameters<typeof fetch>[0], init: Parameters<typeof fetch>[1]) => {
  580. const request = input instanceof Request ? input : new Request(input, init)
  581. captures.push({ url: request.url, body: await request.clone().json() })
  582. return responsesStream([
  583. { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" },
  584. { type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } },
  585. ])
  586. },
  587. { preconnect: () => undefined },
  588. ) satisfies typeof fetch
  589. const llmClient = yield* LLMClient.Service
  590. const native = LLMNativeRuntime.stream({
  591. model: baseModel,
  592. provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } },
  593. auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 },
  594. llmClient,
  595. messages: [{ role: "user", content: "hello" }],
  596. tools: {},
  597. providerOptions: { instructions: "You are concise." },
  598. headers: {},
  599. abort: new AbortController().signal,
  600. })
  601. expect(native.type).toBe("supported")
  602. if (native.type === "unsupported") throw new Error(native.reason)
  603. const events = Array.from(yield* native.stream.pipe(Stream.runCollect))
  604. expect(captures).toHaveLength(1)
  605. expect(captures[0]).toMatchObject({
  606. url: "https://api.openai.com/v1/responses",
  607. body: {
  608. model: "gpt-5-mini",
  609. instructions: "You are concise.",
  610. input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
  611. },
  612. })
  613. expect(events).toEqual(
  614. expect.arrayContaining([
  615. expect.objectContaining({ type: "text-delta", text: "Hello" }),
  616. expect.objectContaining({ type: "finish" }),
  617. ]),
  618. )
  619. }),
  620. )
  621. })