llm-native.test.ts 25 KB

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