llm.test.ts 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003
  1. import { PermissionV1 } from "@kirincode-ai/core/v1/permission"
  2. import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
  3. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
  4. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  5. import path from "path"
  6. import { tool, type ModelMessage } from "ai"
  7. import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
  8. import { InstanceRef } from "../../src/effect/instance-ref"
  9. import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
  10. import z from "zod"
  11. import { LLM } from "../../src/session/llm"
  12. import { LLMClient, RequestExecutor } from "@kirincode-ai/llm/route"
  13. import { Provider } from "@/provider/provider"
  14. import { ProviderTransform } from "@/provider/transform"
  15. import { ModelsDev } from "@kirincode-ai/core/models-dev"
  16. import { testEffect } from "../lib/effect"
  17. import type { Agent } from "../../src/agent/agent"
  18. import { MessageV2 } from "../../src/session/message-v2"
  19. import { SessionID, MessageID } from "../../src/session/schema"
  20. import { RuntimeFlags } from "@/effect/runtime-flags"
  21. import { Permission } from "@/permission"
  22. import { LLMAISDK } from "@/session/llm/ai-sdk"
  23. import { Session as SessionNs } from "@/session/session"
  24. import { ProviderV2 } from "@kirincode-ai/core/provider"
  25. import { ModelV2 } from "@kirincode-ai/core/model"
  26. import { AppNodeBuilder } from "@kirincode-ai/core/effect/app-node-builder"
  27. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  28. import { LayerNodePlatform } from "@kirincode-ai/core/effect/app-node-platform"
  29. type ConfigModel = NonNullable<NonNullable<ConfigV1.Info["provider"]>[string]["models"]>[string]
  30. const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<ConfigV1.Info> => {
  31. const { experimental: _experimental, ...configModel } = model
  32. return {
  33. enabled_providers: ["openai"],
  34. provider: {
  35. openai: {
  36. name: "OpenAI",
  37. env: ["OPENAI_API_KEY"],
  38. npm: "@ai-sdk/openai",
  39. api: "https://api.openai.com/v1",
  40. models: {
  41. [model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel,
  42. },
  43. options: {
  44. apiKey: "test-openai-key",
  45. baseURL,
  46. },
  47. },
  48. },
  49. }
  50. }
  51. const it = testEffect(AppNodeBuilder.build(LayerNode.group([LLM.node, Provider.node])))
  52. // LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy.
  53. const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain))
  54. // drainWith builds an isolated runtime so custom replacements fully own LLM and
  55. // its transitive deps.
  56. const drainWith = (layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput) =>
  57. Effect.gen(function* () {
  58. const ctx = yield* InstanceRef
  59. if (!ctx) return yield* Effect.die("InstanceRef not provided")
  60. return yield* Effect.promise(() =>
  61. Effect.runPromise(
  62. LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)).pipe(
  63. Effect.provide(layer),
  64. Effect.provideService(InstanceRef, ctx),
  65. ),
  66. ),
  67. )
  68. })
  69. function llmLayerWithExecutor(
  70. options: {
  71. executor?: Layer.Layer<RequestExecutor.Service>
  72. flags?: Partial<RuntimeFlags.Info>
  73. } = {},
  74. ) {
  75. return AppNodeBuilder.build(LLM.node, [
  76. [RuntimeFlags.node, RuntimeFlags.layer(options.flags)],
  77. ...(options.executor ? ([[LayerNodePlatform.requestExecutor, options.executor]] as const) : []),
  78. ])
  79. }
  80. describe("session.llm.hasToolCalls", () => {
  81. test("returns false for empty messages array", () => {
  82. expect(LLM.hasToolCalls([])).toBe(false)
  83. })
  84. test("returns false for messages with only text content", () => {
  85. const messages: ModelMessage[] = [
  86. {
  87. role: "user",
  88. content: [{ type: "text", text: "Hello" }],
  89. },
  90. {
  91. role: "assistant",
  92. content: [{ type: "text", text: "Hi there" }],
  93. },
  94. ]
  95. expect(LLM.hasToolCalls(messages)).toBe(false)
  96. })
  97. test("returns true when messages contain tool-call", () => {
  98. const messages = [
  99. {
  100. role: "user",
  101. content: [{ type: "text", text: "Run a command" }],
  102. },
  103. {
  104. role: "assistant",
  105. content: [
  106. {
  107. type: "tool-call",
  108. toolCallId: "call-123",
  109. toolName: "bash",
  110. },
  111. ],
  112. },
  113. ] as ModelMessage[]
  114. expect(LLM.hasToolCalls(messages)).toBe(true)
  115. })
  116. test("returns true when messages contain tool-result", () => {
  117. const messages = [
  118. {
  119. role: "tool",
  120. content: [
  121. {
  122. type: "tool-result",
  123. toolCallId: "call-123",
  124. toolName: "bash",
  125. },
  126. ],
  127. },
  128. ] as ModelMessage[]
  129. expect(LLM.hasToolCalls(messages)).toBe(true)
  130. })
  131. test("returns false for messages with string content", () => {
  132. const messages: ModelMessage[] = [
  133. {
  134. role: "user",
  135. content: "Hello world",
  136. },
  137. {
  138. role: "assistant",
  139. content: "Hi there",
  140. },
  141. ]
  142. expect(LLM.hasToolCalls(messages)).toBe(false)
  143. })
  144. test("returns true when tool-call is mixed with text content", () => {
  145. const messages = [
  146. {
  147. role: "assistant",
  148. content: [
  149. { type: "text", text: "Let me run that command" },
  150. {
  151. type: "tool-call",
  152. toolCallId: "call-456",
  153. toolName: "read",
  154. },
  155. ],
  156. },
  157. ] as ModelMessage[]
  158. expect(LLM.hasToolCalls(messages)).toBe(true)
  159. })
  160. })
  161. describe("session.llm.ai-sdk adapter", () => {
  162. type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
  163. const adapt = (events: ReadonlyArray<AISDKAdapterEvent>) => {
  164. const state = LLMAISDK.adapterState()
  165. return Effect.runPromise(
  166. Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
  167. )
  168. }
  169. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tests defensive adapter branches outside AI SDK's current typed surface
  170. const uncheckedAdapterEvent = (input: unknown) => input as AISDKAdapterEvent
  171. test("maps AI SDK stream chunks without losing session-visible fields", async () => {
  172. const metadata = { openai: { itemID: "item-1" } }
  173. const events = await adapt([
  174. { type: "start" },
  175. { type: "start-step", request: {}, warnings: [] },
  176. { type: "text-start", id: "text-1", providerMetadata: metadata },
  177. { type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
  178. { type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
  179. { type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
  180. { type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
  181. { type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
  182. { type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
  183. { type: "tool-input-start", id: "call-1", toolName: "lookup", providerMetadata: metadata },
  184. { type: "tool-input-delta", id: "call-1", delta: '{"query":' },
  185. { type: "tool-input-delta", id: "call-1", delta: '"weather"}' },
  186. { type: "tool-input-end", id: "call-1", providerMetadata: { openai: { inputDone: true } } },
  187. {
  188. type: "tool-call",
  189. toolCallId: "call-1",
  190. toolName: "lookup",
  191. input: { query: "weather" },
  192. providerExecuted: true,
  193. providerMetadata: { openai: { called: true } },
  194. },
  195. {
  196. type: "tool-result",
  197. toolCallId: "call-1",
  198. toolName: "lookup",
  199. input: { query: "weather" },
  200. output: { title: "Lookup", output: "sunny", metadata: { ok: true } },
  201. providerExecuted: true,
  202. providerMetadata: { openai: { result: true } },
  203. },
  204. {
  205. type: "finish-step",
  206. response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
  207. finishReason: "other",
  208. rawFinishReason: "other",
  209. usage: {
  210. inputTokens: 10,
  211. outputTokens: 5,
  212. totalTokens: 15,
  213. inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 },
  214. outputTokenDetails: { textTokens: 4, reasoningTokens: 1 },
  215. },
  216. providerMetadata: { openai: { step: true } },
  217. },
  218. {
  219. type: "finish",
  220. finishReason: "other",
  221. rawFinishReason: "other",
  222. totalUsage: {
  223. inputTokens: 11,
  224. outputTokens: 6,
  225. totalTokens: 17,
  226. cachedInputTokens: 4,
  227. reasoningTokens: 2,
  228. inputTokenDetails: { noCacheTokens: 7, cacheReadTokens: 4, cacheWriteTokens: undefined },
  229. outputTokenDetails: { textTokens: 4, reasoningTokens: 2 },
  230. },
  231. },
  232. ])
  233. expect(events).toMatchObject([
  234. { type: "step-start", index: 0 },
  235. { type: "text-start", id: "text-1", providerMetadata: metadata },
  236. { type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
  237. { type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
  238. { type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
  239. { type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
  240. { type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
  241. { type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
  242. { type: "tool-input-start", id: "call-1", name: "lookup", providerMetadata: metadata },
  243. { type: "tool-input-delta", id: "call-1", name: "lookup", text: '{"query":' },
  244. { type: "tool-input-delta", id: "call-1", name: "lookup", text: '"weather"}' },
  245. { type: "tool-input-end", id: "call-1", name: "lookup", providerMetadata: { openai: { inputDone: true } } },
  246. {
  247. type: "tool-call",
  248. id: "call-1",
  249. name: "lookup",
  250. input: { query: "weather" },
  251. providerExecuted: true,
  252. providerMetadata: { openai: { called: true } },
  253. },
  254. {
  255. type: "tool-result",
  256. id: "call-1",
  257. name: "lookup",
  258. result: { type: "json", value: { title: "Lookup", output: "sunny", metadata: { ok: true } } },
  259. providerExecuted: true,
  260. providerMetadata: { openai: { result: true } },
  261. },
  262. {
  263. type: "step-finish",
  264. index: 0,
  265. reason: "unknown",
  266. usage: {
  267. inputTokens: 10,
  268. outputTokens: 5,
  269. totalTokens: 15,
  270. reasoningTokens: 1,
  271. cacheReadInputTokens: 3,
  272. cacheWriteInputTokens: 2,
  273. },
  274. providerMetadata: { openai: { step: true } },
  275. },
  276. {
  277. type: "finish",
  278. reason: "unknown",
  279. usage: {
  280. inputTokens: 11,
  281. outputTokens: 6,
  282. totalTokens: 17,
  283. reasoningTokens: 2,
  284. cacheReadInputTokens: 4,
  285. },
  286. },
  287. ])
  288. })
  289. test("creates stable block ids when AI SDK omits them", async () => {
  290. const events = await adapt([
  291. uncheckedAdapterEvent({ type: "text-delta", text: "implicit text" }),
  292. uncheckedAdapterEvent({ type: "text-end" }),
  293. uncheckedAdapterEvent({ type: "reasoning-delta", text: "implicit reasoning" }),
  294. uncheckedAdapterEvent({ type: "reasoning-end" }),
  295. ])
  296. expect(events).toMatchObject([
  297. { type: "text-delta", id: "text-0", text: "implicit text" },
  298. { type: "text-end", id: "text-0" },
  299. { type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" },
  300. { type: "reasoning-end", id: "reasoning-0" },
  301. ])
  302. })
  303. test("explicitly ignores non-session-visible AI SDK chunks", async () => {
  304. expect(
  305. await adapt([
  306. uncheckedAdapterEvent({ type: "abort" }),
  307. uncheckedAdapterEvent({ type: "source" }),
  308. uncheckedAdapterEvent({ type: "file" }),
  309. uncheckedAdapterEvent({ type: "raw" }),
  310. uncheckedAdapterEvent({ type: "tool-output-denied" }),
  311. uncheckedAdapterEvent({ type: "tool-approval-request" }),
  312. ]),
  313. ).toEqual([])
  314. })
  315. test("preserves tool-error cause", async () => {
  316. const error = new PermissionV1.RejectedError()
  317. const events = await Effect.runPromise(
  318. LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
  319. type: "tool-error",
  320. toolCallId: "call_123",
  321. toolName: "bash",
  322. input: {},
  323. error,
  324. }),
  325. )
  326. expect(events).toHaveLength(1)
  327. expect(events[0]).toMatchObject({
  328. type: "tool-error",
  329. id: "call_123",
  330. name: "bash",
  331. message: error.message,
  332. error,
  333. })
  334. })
  335. test("emits undefined usage when every AI SDK usage field is missing", async () => {
  336. // If every numeric field is undefined the translator should signal "no usage info"
  337. // by emitting undefined, not by polluting the event with usage: {}. Downstream cost
  338. // telemetry distinguishes "missing" from "zero," so emitting an empty object causes
  339. // false positives ("usage was tracked, just empty") instead of correct nulls.
  340. const events = await adapt([
  341. {
  342. type: "finish-step",
  343. response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
  344. finishReason: "stop",
  345. rawFinishReason: "stop",
  346. providerMetadata: undefined,
  347. usage: {
  348. inputTokens: undefined,
  349. outputTokens: undefined,
  350. totalTokens: undefined,
  351. reasoningTokens: undefined,
  352. cachedInputTokens: undefined,
  353. inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
  354. outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
  355. },
  356. },
  357. ])
  358. expect(events).toHaveLength(1)
  359. const stepFinish = events[0]
  360. if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
  361. expect(stepFinish.usage).toBeUndefined()
  362. })
  363. test("reuses adapter state cleanly across streams once finish has fired", async () => {
  364. // adapterState() is meant to be per-stream, but the only thing finish currently clears
  365. // is toolNames — step, text counters, and the current text/reasoning IDs all leak
  366. // forward. A caller that reuses a state across two streams sees text-1/reasoning-1/
  367. // step index 1 on the second stream's first events. The test pins the intended
  368. // contract: after finish, the same state can be reused and starts fresh.
  369. const state = LLMAISDK.adapterState()
  370. const run = (events: ReadonlyArray<AISDKAdapterEvent>) =>
  371. Effect.runPromise(
  372. Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
  373. )
  374. await run([
  375. { type: "start-step", request: {}, warnings: [] },
  376. uncheckedAdapterEvent({ type: "text-delta", text: "first" }),
  377. uncheckedAdapterEvent({ type: "text-end" }),
  378. uncheckedAdapterEvent({ type: "reasoning-delta", text: "first reasoning" }),
  379. uncheckedAdapterEvent({ type: "reasoning-end" }),
  380. {
  381. type: "finish-step",
  382. response: { id: "r1", timestamp: new Date(0), modelId: "gpt-test" },
  383. finishReason: "stop",
  384. rawFinishReason: "stop",
  385. providerMetadata: undefined,
  386. usage: {
  387. inputTokens: 1,
  388. outputTokens: 1,
  389. totalTokens: 2,
  390. inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
  391. outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
  392. },
  393. },
  394. {
  395. type: "finish",
  396. finishReason: "stop",
  397. rawFinishReason: "stop",
  398. totalUsage: {
  399. inputTokens: 1,
  400. outputTokens: 1,
  401. totalTokens: 2,
  402. inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
  403. outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
  404. },
  405. },
  406. ])
  407. const secondStream = await run([
  408. { type: "start-step", request: {}, warnings: [] },
  409. uncheckedAdapterEvent({ type: "text-delta", text: "second" }),
  410. uncheckedAdapterEvent({ type: "text-end" }),
  411. uncheckedAdapterEvent({ type: "reasoning-delta", text: "second reasoning" }),
  412. uncheckedAdapterEvent({ type: "reasoning-end" }),
  413. ])
  414. expect(secondStream).toMatchObject([
  415. { type: "step-start", index: 0 },
  416. { type: "text-delta", id: "text-0", text: "second" },
  417. { type: "text-end", id: "text-0" },
  418. { type: "reasoning-delta", id: "reasoning-0", text: "second reasoning" },
  419. { type: "reasoning-end", id: "reasoning-0" },
  420. ])
  421. })
  422. // Anthropic emits cache write counts in providerMetadata.anthropic.cacheCreationInputTokens
  423. // rather than usage.inputTokenDetails.cacheWriteTokens. Session.getUsage falls back to the
  424. // metadata path — but only if the adapter preserves providerMetadata on step-finish.
  425. test("preserves providerMetadata on step-finish so Anthropic cache writes survive getUsage", async () => {
  426. const events = await adapt([
  427. {
  428. type: "finish-step",
  429. response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-3-5-sonnet" },
  430. finishReason: "stop",
  431. rawFinishReason: "stop",
  432. // Anthropic's AI SDK shape: cacheWriteTokens is NOT in usage, it arrives via providerMetadata.
  433. usage: {
  434. inputTokens: 1000,
  435. outputTokens: 500,
  436. totalTokens: 1500,
  437. inputTokenDetails: { noCacheTokens: 800, cacheReadTokens: 200, cacheWriteTokens: undefined },
  438. outputTokenDetails: { textTokens: 500, reasoningTokens: undefined },
  439. },
  440. providerMetadata: { anthropic: { cacheCreationInputTokens: 300 } },
  441. },
  442. ])
  443. expect(events).toHaveLength(1)
  444. const stepFinish = events[0]
  445. if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
  446. expect(stepFinish.providerMetadata).toEqual({ anthropic: { cacheCreationInputTokens: 300 } })
  447. expect(stepFinish.usage?.cacheWriteInputTokens).toBeUndefined()
  448. expect(stepFinish.usage?.cacheReadInputTokens).toBe(200)
  449. // End-to-end: with the metadata preserved, getUsage extracts cache.write from the fallback path.
  450. const result = SessionNs.getUsage({
  451. model: {
  452. id: "claude-3-5-sonnet",
  453. providerID: "anthropic",
  454. name: "Claude",
  455. limit: { context: 200_000, output: 8_000 },
  456. cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
  457. capabilities: {
  458. toolcall: true,
  459. attachment: false,
  460. reasoning: false,
  461. temperature: true,
  462. input: { text: true, image: false, audio: false, video: false },
  463. output: { text: true, image: false, audio: false, video: false },
  464. },
  465. api: { npm: "@ai-sdk/anthropic" },
  466. options: {},
  467. } as never,
  468. usage: stepFinish.usage!,
  469. metadata: stepFinish.providerMetadata,
  470. })
  471. expect(result.tokens.cache.write).toBe(300)
  472. expect(result.tokens.cache.read).toBe(200)
  473. })
  474. test("captures Copilot billed usage from raw Anthropic message deltas per step", async () => {
  475. const events = await adapt([
  476. uncheckedAdapterEvent({
  477. type: "raw",
  478. rawValue: {
  479. type: "message_delta",
  480. copilot_usage: { total_nano_aiu: 4_473_525_000 },
  481. },
  482. }),
  483. {
  484. type: "finish-step",
  485. response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-sonnet-4.6" },
  486. finishReason: "stop",
  487. rawFinishReason: "end_turn",
  488. usage: {
  489. inputTokens: 11_774,
  490. outputTokens: 39,
  491. totalTokens: 11_813,
  492. inputTokenDetails: { noCacheTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 11_771 },
  493. outputTokenDetails: { textTokens: 39, reasoningTokens: undefined },
  494. },
  495. providerMetadata: { anthropic: { cacheCreationInputTokens: 11_771 } },
  496. },
  497. {
  498. type: "finish-step",
  499. response: { id: "msg_follow_up", timestamp: new Date(0), modelId: "claude-sonnet-4.6" },
  500. finishReason: "stop",
  501. rawFinishReason: "end_turn",
  502. usage: {
  503. inputTokens: 1,
  504. outputTokens: 1,
  505. totalTokens: 2,
  506. inputTokenDetails: { noCacheTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 },
  507. outputTokenDetails: { textTokens: 1, reasoningTokens: undefined },
  508. },
  509. providerMetadata: { anthropic: {} },
  510. },
  511. ])
  512. expect(events[0]).toMatchObject({
  513. type: "step-finish",
  514. providerMetadata: {
  515. anthropic: { cacheCreationInputTokens: 11_771 },
  516. copilot: { totalNanoAiu: 4_473_525_000 },
  517. },
  518. })
  519. expect(events[1]).toMatchObject({ type: "step-finish", providerMetadata: { anthropic: {} } })
  520. if (events[1].type !== "step-finish") throw new Error("expected step-finish")
  521. expect(events[1].providerMetadata?.copilot).toBeUndefined()
  522. })
  523. })
  524. type Capture = {
  525. url: URL
  526. headers: Headers
  527. body: Record<string, unknown>
  528. }
  529. const state = {
  530. server: null as ReturnType<typeof Bun.serve> | null,
  531. queue: [] as Array<{
  532. path: string
  533. response: Response | ((req: Request, capture: Capture) => Response)
  534. resolve: (value: Capture) => void
  535. }>,
  536. }
  537. function deferred<T>() {
  538. const result = {} as { promise: Promise<T>; resolve: (value: T) => void }
  539. result.promise = new Promise((resolve) => {
  540. result.resolve = resolve
  541. })
  542. return result
  543. }
  544. function waitRequest(pathname: string, response: Response) {
  545. const pending = deferred<Capture>()
  546. state.queue.push({ path: pathname, response, resolve: pending.resolve })
  547. return pending.promise
  548. }
  549. function timeout(ms: number) {
  550. return new Promise<never>((_, reject) => {
  551. setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
  552. })
  553. }
  554. function waitStreamingRequest(pathname: string) {
  555. const request = deferred<Capture>()
  556. const requestAborted = deferred<void>()
  557. const responseCanceled = deferred<void>()
  558. const encoder = new TextEncoder()
  559. state.queue.push({
  560. path: pathname,
  561. resolve: request.resolve,
  562. response(req: Request) {
  563. req.signal.addEventListener("abort", () => requestAborted.resolve(), { once: true })
  564. return new Response(
  565. new ReadableStream<Uint8Array>({
  566. start(controller) {
  567. controller.enqueue(
  568. encoder.encode(
  569. [
  570. `data: ${JSON.stringify({
  571. id: "chatcmpl-abort",
  572. object: "chat.completion.chunk",
  573. choices: [{ delta: { role: "assistant" } }],
  574. })}`,
  575. ].join("\n\n") + "\n\n",
  576. ),
  577. )
  578. },
  579. cancel() {
  580. responseCanceled.resolve()
  581. },
  582. }),
  583. {
  584. status: 200,
  585. headers: { "Content-Type": "text/event-stream" },
  586. },
  587. )
  588. },
  589. })
  590. return {
  591. request: request.promise,
  592. requestAborted: requestAborted.promise,
  593. responseCanceled: responseCanceled.promise,
  594. }
  595. }
  596. beforeAll(() => {
  597. state.server = Bun.serve({
  598. port: 0,
  599. async fetch(req) {
  600. const next = state.queue.shift()
  601. if (!next) {
  602. return new Response("unexpected request", { status: 500 })
  603. }
  604. const url = new URL(req.url)
  605. const body = (await req.json()) as Record<string, unknown>
  606. next.resolve({ url, headers: req.headers, body })
  607. if (!url.pathname.endsWith(next.path)) {
  608. return new Response("not found", { status: 404 })
  609. }
  610. return typeof next.response === "function"
  611. ? next.response(req, { url, headers: req.headers, body })
  612. : next.response
  613. },
  614. })
  615. })
  616. beforeEach(() => {
  617. state.queue.length = 0
  618. })
  619. afterAll(() => {
  620. void state.server?.stop()
  621. })
  622. function createChatStream(text: string) {
  623. const payload =
  624. [
  625. `data: ${JSON.stringify({
  626. id: "chatcmpl-1",
  627. object: "chat.completion.chunk",
  628. choices: [{ delta: { role: "assistant" } }],
  629. })}`,
  630. `data: ${JSON.stringify({
  631. id: "chatcmpl-1",
  632. object: "chat.completion.chunk",
  633. choices: [{ delta: { content: text } }],
  634. })}`,
  635. `data: ${JSON.stringify({
  636. id: "chatcmpl-1",
  637. object: "chat.completion.chunk",
  638. choices: [{ delta: {}, finish_reason: "stop" }],
  639. })}`,
  640. "data: [DONE]",
  641. ].join("\n\n") + "\n\n"
  642. const encoder = new TextEncoder()
  643. return new ReadableStream<Uint8Array>({
  644. start(controller) {
  645. controller.enqueue(encoder.encode(payload))
  646. controller.close()
  647. },
  648. })
  649. }
  650. const MODELS_FIXTURE = JSON.parse(
  651. await Bun.file(path.join(import.meta.dir, "../tool/fixtures/models-api.json")).text(),
  652. ) as Record<string, ModelsDev.Provider>
  653. function loadFixture(providerID: string, modelID: string) {
  654. const provider = MODELS_FIXTURE[providerID]
  655. if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
  656. const model = provider.models[modelID]
  657. if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
  658. return { provider, model }
  659. }
  660. function configModel(model: ModelsDev.Model) {
  661. return {
  662. id: model.id,
  663. name: model.name,
  664. family: model.family,
  665. release_date: model.release_date,
  666. attachment: model.attachment,
  667. reasoning: model.reasoning,
  668. temperature: model.temperature,
  669. tool_call: model.tool_call,
  670. interleaved: model.interleaved,
  671. cost: model.cost ? { ...model.cost, tiers: undefined } : undefined,
  672. limit: model.limit,
  673. modalities: model.modalities,
  674. status: model.status,
  675. provider: model.provider,
  676. }
  677. }
  678. function createEventStream(chunks: unknown[], includeDone = false) {
  679. const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
  680. if (includeDone) {
  681. lines.push("data: [DONE]")
  682. }
  683. const payload = lines.join("\n\n") + "\n\n"
  684. const encoder = new TextEncoder()
  685. return new ReadableStream<Uint8Array>({
  686. start(controller) {
  687. controller.enqueue(encoder.encode(payload))
  688. controller.close()
  689. },
  690. })
  691. }
  692. function createEventResponse(chunks: unknown[], includeDone = false) {
  693. return new Response(createEventStream(chunks, includeDone), {
  694. status: 200,
  695. headers: { "Content-Type": "text/event-stream" },
  696. })
  697. }
  698. describe("session.llm.stream", () => {
  699. const vivgridFixture = { providerID: "vivgrid", modelID: "gemini-3.1-pro-preview" }
  700. it.instance(
  701. "sends temperature, tokens, and reasoning options for openai-compatible models",
  702. () =>
  703. Effect.gen(function* () {
  704. const fixture = loadFixture(vivgridFixture.providerID, vivgridFixture.modelID)
  705. const request = waitRequest(
  706. "/chat/completions",
  707. new Response(createChatStream("Hello"), {
  708. status: 200,
  709. headers: { "Content-Type": "text/event-stream" },
  710. }),
  711. )
  712. const resolved = yield* Provider.use.getModel(
  713. ProviderV2.ID.make(vivgridFixture.providerID),
  714. ModelV2.ID.make(fixture.model.id),
  715. )
  716. const sessionID = SessionID.make("session-test-1")
  717. const agent = {
  718. name: "test",
  719. mode: "primary",
  720. options: {},
  721. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  722. temperature: 0.4,
  723. topP: 0.8,
  724. } satisfies Agent.Info
  725. const user = {
  726. id: MessageID.make("msg_user-1"),
  727. sessionID,
  728. role: "user",
  729. time: { created: Date.now() },
  730. agent: agent.name,
  731. model: { providerID: ProviderV2.ID.make(vivgridFixture.providerID), modelID: resolved.id, variant: "high" },
  732. } satisfies SessionV1.User
  733. yield* drain({
  734. user,
  735. sessionID,
  736. model: resolved,
  737. agent,
  738. system: ["You are a helpful assistant."],
  739. messages: [{ role: "user", content: "Hello" }],
  740. tools: {},
  741. })
  742. const capture = yield* Effect.promise(() => request)
  743. const body = capture.body
  744. const headers = capture.headers
  745. const url = capture.url
  746. expect(url.pathname.startsWith("/v1/")).toBe(true)
  747. expect(url.pathname.endsWith("/chat/completions")).toBe(true)
  748. expect(headers.get("Authorization")).toBe("Bearer test-key")
  749. expect(body.model).toBe(resolved.api.id)
  750. expect(body.temperature).toBe(0.4)
  751. expect(body.top_p).toBe(0.8)
  752. expect(body.stream).toBe(true)
  753. const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
  754. const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
  755. expect(maxTokens).toBe(expectedMaxTokens)
  756. const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
  757. expect(reasoning).toBe("high")
  758. }),
  759. {
  760. config: () => ({
  761. enabled_providers: [vivgridFixture.providerID],
  762. provider: {
  763. [vivgridFixture.providerID]: {
  764. options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
  765. },
  766. },
  767. }),
  768. },
  769. )
  770. const cerebrasFixture = { providerID: "cerebras", modelID: "gpt-oss-120b" }
  771. it.instance(
  772. "replays Cerebras assistant reasoning using the provider-supported field",
  773. () =>
  774. Effect.gen(function* () {
  775. const fixture = loadFixture(cerebrasFixture.providerID, cerebrasFixture.modelID)
  776. const request = waitRequest(
  777. "/chat/completions",
  778. new Response(createChatStream("Hello"), {
  779. status: 200,
  780. headers: { "Content-Type": "text/event-stream" },
  781. }),
  782. )
  783. const resolved = yield* Provider.use.getModel(
  784. ProviderV2.ID.make(cerebrasFixture.providerID),
  785. ModelV2.ID.make(fixture.model.id),
  786. )
  787. const sessionID = SessionID.make("session-test-cerebras-reasoning")
  788. const agent = {
  789. name: "test",
  790. mode: "primary",
  791. options: {},
  792. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  793. } satisfies Agent.Info
  794. const user = {
  795. id: MessageID.make("msg_user-cerebras-reasoning"),
  796. sessionID,
  797. role: "user",
  798. time: { created: Date.now() },
  799. agent: agent.name,
  800. model: { providerID: ProviderV2.ID.make(cerebrasFixture.providerID), modelID: resolved.id },
  801. } satisfies SessionV1.User
  802. yield* drain({
  803. user,
  804. sessionID,
  805. model: resolved,
  806. agent,
  807. system: ["You are a helpful assistant."],
  808. messages: [
  809. { role: "user", content: "Hello" },
  810. {
  811. role: "assistant",
  812. content: [
  813. { type: "reasoning", text: "thinking" },
  814. { type: "text", text: "Previous answer" },
  815. ],
  816. },
  817. { role: "user", content: "Continue" },
  818. ] satisfies ModelMessage[],
  819. tools: {},
  820. })
  821. const capture = yield* Effect.promise(() => request)
  822. const messages = capture.body.messages as Array<Record<string, unknown>>
  823. const assistant = messages.find((msg) => msg.role === "assistant")
  824. expect(assistant?.reasoning).toBe("thinking")
  825. expect(assistant && "reasoning_content" in assistant).toBe(false)
  826. }),
  827. {
  828. config: () => ({
  829. enabled_providers: [cerebrasFixture.providerID],
  830. provider: {
  831. [cerebrasFixture.providerID]: {
  832. options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
  833. },
  834. },
  835. }),
  836. },
  837. )
  838. const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" }
  839. it.instance(
  840. "service stream cancellation cancels provider response body promptly",
  841. () =>
  842. Effect.gen(function* () {
  843. const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID)
  844. const pending = waitStreamingRequest("/chat/completions")
  845. const resolved = yield* Provider.use.getModel(
  846. ProviderV2.ID.make(alibabaQwenFixture.providerID),
  847. ModelV2.ID.make(fixture.model.id),
  848. )
  849. const sessionID = SessionID.make("session-test-service-abort")
  850. const agent = {
  851. name: "test",
  852. mode: "primary",
  853. options: {},
  854. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  855. } satisfies Agent.Info
  856. const user = {
  857. id: MessageID.make("msg_user-service-abort"),
  858. sessionID,
  859. role: "user",
  860. time: { created: Date.now() },
  861. agent: agent.name,
  862. model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
  863. } satisfies SessionV1.User
  864. const fiber = yield* drain({
  865. user,
  866. sessionID,
  867. model: resolved,
  868. agent,
  869. system: ["You are a helpful assistant."],
  870. messages: [{ role: "user", content: "Hello" }],
  871. tools: {},
  872. }).pipe(Effect.exit, Effect.forkScoped)
  873. yield* Effect.promise(() => pending.request)
  874. yield* Fiber.interrupt(fiber)
  875. yield* Effect.promise(() => Promise.race([pending.responseCanceled, timeout(500)]))
  876. const exit = yield* Fiber.await(fiber)
  877. // Fiber.await returns an Exit<Exit<...>>. Unwrap once.
  878. const inner = Exit.isSuccess(exit) ? exit.value : exit
  879. expect(Exit.isFailure(inner)).toBe(true)
  880. if (Exit.isFailure(inner)) {
  881. expect(Cause.hasInterrupts(inner.cause)).toBe(true)
  882. }
  883. yield* Effect.promise(() => Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined))
  884. }),
  885. {
  886. config: () => ({
  887. enabled_providers: [alibabaQwenFixture.providerID],
  888. provider: {
  889. [alibabaQwenFixture.providerID]: {
  890. options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
  891. },
  892. },
  893. }),
  894. },
  895. )
  896. it.instance(
  897. "keeps tools enabled by prompt permissions",
  898. () =>
  899. Effect.gen(function* () {
  900. const fixture = loadFixture(alibabaQwenFixture.providerID, alibabaQwenFixture.modelID)
  901. const request = waitRequest(
  902. "/chat/completions",
  903. new Response(createChatStream("Hello"), {
  904. status: 200,
  905. headers: { "Content-Type": "text/event-stream" },
  906. }),
  907. )
  908. const resolved = yield* Provider.use.getModel(
  909. ProviderV2.ID.make(alibabaQwenFixture.providerID),
  910. ModelV2.ID.make(fixture.model.id),
  911. )
  912. const sessionID = SessionID.make("session-test-tools")
  913. const agent = {
  914. name: "test",
  915. mode: "primary",
  916. options: {},
  917. permission: [{ permission: "question", pattern: "*", action: "deny" }],
  918. } satisfies Agent.Info
  919. const user = {
  920. id: MessageID.make("msg_user-tools"),
  921. sessionID,
  922. role: "user",
  923. time: { created: Date.now() },
  924. agent: agent.name,
  925. model: { providerID: ProviderV2.ID.make(alibabaQwenFixture.providerID), modelID: resolved.id },
  926. tools: { question: true },
  927. } satisfies SessionV1.User
  928. yield* drain({
  929. user,
  930. sessionID,
  931. model: resolved,
  932. agent,
  933. permission: [{ permission: "question", pattern: "*", action: "allow" }],
  934. system: ["You are a helpful assistant."],
  935. messages: [{ role: "user", content: "Hello" }],
  936. tools: {
  937. question: tool({
  938. description: "Ask a question",
  939. inputSchema: z.object({}),
  940. execute: async () => ({ output: "" }),
  941. }),
  942. },
  943. })
  944. const capture = yield* Effect.promise(() => request)
  945. const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
  946. expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
  947. }),
  948. {
  949. config: () => ({
  950. enabled_providers: [alibabaQwenFixture.providerID],
  951. provider: {
  952. [alibabaQwenFixture.providerID]: {
  953. options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` },
  954. },
  955. },
  956. }),
  957. },
  958. )
  959. it.instance(
  960. "sends responses API payload for OpenAI models",
  961. () =>
  962. Effect.gen(function* () {
  963. const model = loadFixture("openai", "gpt-5.2").model
  964. const responseChunks = [
  965. {
  966. type: "response.created",
  967. response: {
  968. id: "resp-1",
  969. created_at: Math.floor(Date.now() / 1000),
  970. model: model.id,
  971. service_tier: null,
  972. },
  973. },
  974. {
  975. type: "response.output_item.added",
  976. output_index: 0,
  977. item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] },
  978. },
  979. {
  980. type: "response.content_part.added",
  981. item_id: "item-1",
  982. output_index: 0,
  983. content_index: 0,
  984. part: { type: "output_text", text: "", annotations: [] },
  985. },
  986. {
  987. type: "response.output_text.delta",
  988. item_id: "item-1",
  989. delta: "Hello",
  990. logprobs: null,
  991. },
  992. {
  993. type: "response.completed",
  994. response: {
  995. incomplete_details: null,
  996. usage: {
  997. input_tokens: 1,
  998. input_tokens_details: null,
  999. output_tokens: 1,
  1000. output_tokens_details: null,
  1001. },
  1002. service_tier: null,
  1003. },
  1004. },
  1005. ]
  1006. const request = waitRequest("/responses", createEventResponse(responseChunks, true))
  1007. const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
  1008. const sessionID = SessionID.make("session-test-2")
  1009. const agent = {
  1010. name: "test",
  1011. mode: "primary",
  1012. options: {},
  1013. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1014. temperature: 0.2,
  1015. } satisfies Agent.Info
  1016. const user = {
  1017. id: MessageID.make("msg_user-2"),
  1018. sessionID,
  1019. role: "user",
  1020. time: { created: Date.now() },
  1021. agent: agent.name,
  1022. model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
  1023. } satisfies SessionV1.User
  1024. yield* drain({
  1025. user,
  1026. sessionID,
  1027. model: resolved,
  1028. agent,
  1029. system: ["You are a helpful assistant."],
  1030. messages: [{ role: "user", content: "Hello" }],
  1031. tools: {},
  1032. })
  1033. const capture = yield* Effect.promise(() => request)
  1034. const body = capture.body
  1035. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  1036. expect(body.model).toBe(resolved.api.id)
  1037. expect(body.stream).toBe(true)
  1038. expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  1039. const maxTokens = body.max_output_tokens as number | undefined
  1040. expect(maxTokens).toBe(undefined) // match codex cli behavior
  1041. }),
  1042. { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
  1043. )
  1044. it.instance(
  1045. "keeps supported OpenAI models on AI SDK path when native flag is off",
  1046. () =>
  1047. Effect.gen(function* () {
  1048. const model = loadFixture("openai", "gpt-5.2").model
  1049. const request = waitRequest(
  1050. "/responses",
  1051. createEventResponse(
  1052. [
  1053. {
  1054. type: "response.created",
  1055. response: {
  1056. id: "resp-flag-off",
  1057. created_at: Math.floor(Date.now() / 1000),
  1058. model: model.id,
  1059. service_tier: null,
  1060. },
  1061. },
  1062. {
  1063. type: "response.output_item.added",
  1064. output_index: 0,
  1065. item: { type: "message", id: "item-flag-off", status: "in_progress", role: "assistant", content: [] },
  1066. },
  1067. {
  1068. type: "response.content_part.added",
  1069. item_id: "item-flag-off",
  1070. output_index: 0,
  1071. content_index: 0,
  1072. part: { type: "output_text", text: "", annotations: [] },
  1073. },
  1074. {
  1075. type: "response.output_text.delta",
  1076. item_id: "item-flag-off",
  1077. delta: "Flag off",
  1078. logprobs: null,
  1079. },
  1080. {
  1081. type: "response.completed",
  1082. response: {
  1083. incomplete_details: null,
  1084. usage: {
  1085. input_tokens: 1,
  1086. input_tokens_details: null,
  1087. output_tokens: 1,
  1088. output_tokens_details: null,
  1089. },
  1090. service_tier: null,
  1091. },
  1092. },
  1093. ],
  1094. true,
  1095. ),
  1096. )
  1097. const failingNativeClient = Layer.succeed(
  1098. LLMClient.Service,
  1099. LLMClient.Service.of({
  1100. prepare: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
  1101. stream: () => Stream.die(new Error("native LLM client should not be used when the flag is off")),
  1102. generate: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
  1103. }),
  1104. )
  1105. const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
  1106. const sessionID = SessionID.make("session-test-native-flag-off")
  1107. const agent = {
  1108. name: "test",
  1109. mode: "primary",
  1110. options: {},
  1111. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1112. } satisfies Agent.Info
  1113. yield* drainWith(
  1114. AppNodeBuilder.build(LLM.node, [
  1115. [LayerNodePlatform.llmClient, failingNativeClient],
  1116. [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: false })],
  1117. ]),
  1118. {
  1119. user: {
  1120. id: MessageID.make("msg_user-native-flag-off"),
  1121. sessionID,
  1122. role: "user",
  1123. time: { created: Date.now() },
  1124. agent: agent.name,
  1125. model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
  1126. } satisfies SessionV1.User,
  1127. sessionID,
  1128. model: resolved,
  1129. agent,
  1130. system: ["You are a helpful assistant."],
  1131. messages: [{ role: "user", content: "Hello" }],
  1132. tools: {},
  1133. },
  1134. )
  1135. const capture = yield* Effect.promise(() => request)
  1136. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  1137. expect(capture.body.model).toBe(resolved.api.id)
  1138. }),
  1139. { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
  1140. )
  1141. it.instance(
  1142. "streams OpenAI through native runtime when opted in",
  1143. () =>
  1144. Effect.gen(function* () {
  1145. const model = loadFixture("openai", "gpt-5.2").model
  1146. const chunks = [
  1147. { type: "response.created", response: { id: "resp-native" } },
  1148. {
  1149. type: "response.output_item.added",
  1150. item: { type: "message", id: "item-native", status: "in_progress" },
  1151. },
  1152. { type: "response.output_text.delta", item_id: "item-native", delta: "Hello native" },
  1153. {
  1154. type: "response.completed",
  1155. response: {
  1156. incomplete_details: null,
  1157. usage: {
  1158. input_tokens: 1,
  1159. input_tokens_details: null,
  1160. output_tokens: 1,
  1161. output_tokens_details: null,
  1162. },
  1163. },
  1164. },
  1165. ]
  1166. const request = waitRequest("/responses", createEventResponse(chunks, true))
  1167. const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
  1168. const sessionID = SessionID.make("session-test-native")
  1169. const agent = {
  1170. name: "test",
  1171. mode: "primary",
  1172. options: {},
  1173. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1174. temperature: 0.2,
  1175. } satisfies Agent.Info
  1176. yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), {
  1177. user: {
  1178. id: MessageID.make("msg_user-native"),
  1179. sessionID,
  1180. role: "user",
  1181. time: { created: Date.now() },
  1182. agent: agent.name,
  1183. model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id, variant: "high" },
  1184. } satisfies SessionV1.User,
  1185. sessionID,
  1186. model: resolved,
  1187. agent,
  1188. system: ["You are a helpful assistant."],
  1189. messages: [{ role: "user", content: "Hello" }],
  1190. tools: {},
  1191. })
  1192. const capture = yield* Effect.promise(() => request)
  1193. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  1194. expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key")
  1195. expect(capture.body.model).toBe(model.id)
  1196. expect(capture.body.stream).toBe(true)
  1197. expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  1198. expect(capture.body.include).toEqual(["reasoning.encrypted_content"])
  1199. expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
  1200. expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
  1201. }),
  1202. { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
  1203. )
  1204. it.instance(
  1205. "uses injected native request executor for tool calls",
  1206. () =>
  1207. Effect.gen(function* () {
  1208. const model = loadFixture("openai", "gpt-5.2").model
  1209. const chunks = [
  1210. {
  1211. type: "response.output_item.added",
  1212. item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" },
  1213. },
  1214. {
  1215. type: "response.function_call_arguments.delta",
  1216. item_id: "item-injected-tool",
  1217. delta: '{"query":"weather"}',
  1218. },
  1219. {
  1220. type: "response.output_item.done",
  1221. item: {
  1222. type: "function_call",
  1223. id: "item-injected-tool",
  1224. call_id: "call-injected-tool",
  1225. name: "lookup",
  1226. arguments: '{"query":"weather"}',
  1227. },
  1228. },
  1229. {
  1230. type: "response.completed",
  1231. response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
  1232. },
  1233. ]
  1234. let captured: Record<string, unknown> | undefined
  1235. let executed: unknown
  1236. const executor = Layer.succeed(
  1237. RequestExecutor.Service,
  1238. RequestExecutor.Service.of({
  1239. execute: (request) =>
  1240. Effect.gen(function* () {
  1241. const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
  1242. captured = (yield* Effect.promise(() => web.json())) as Record<string, unknown>
  1243. return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true))
  1244. }),
  1245. }),
  1246. )
  1247. const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
  1248. const sessionID = SessionID.make("session-test-native-injected-tool")
  1249. const agent = {
  1250. name: "test",
  1251. mode: "primary",
  1252. options: {},
  1253. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1254. } satisfies Agent.Info
  1255. yield* drainWith(llmLayerWithExecutor({ executor, flags: { experimentalNativeLlm: true } }), {
  1256. user: {
  1257. id: MessageID.make("msg_user-native-injected-tool"),
  1258. sessionID,
  1259. role: "user",
  1260. time: { created: Date.now() },
  1261. agent: agent.name,
  1262. model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id },
  1263. } satisfies SessionV1.User,
  1264. sessionID,
  1265. model: resolved,
  1266. agent,
  1267. system: [],
  1268. messages: [{ role: "user", content: "Use lookup" }],
  1269. tools: {
  1270. lookup: tool({
  1271. description: "Lookup data",
  1272. inputSchema: z.object({ query: z.string() }),
  1273. execute: async (args, options) => {
  1274. executed = { args, toolCallId: options.toolCallId }
  1275. return { output: "looked up" }
  1276. },
  1277. }),
  1278. },
  1279. })
  1280. expect(captured?.model).toBe(model.id)
  1281. expect(captured?.tools).toEqual([
  1282. {
  1283. type: "function",
  1284. name: "lookup",
  1285. description: "Lookup data",
  1286. strict: false,
  1287. parameters: {
  1288. type: "object",
  1289. properties: { query: { type: "string" } },
  1290. required: ["query"],
  1291. additionalProperties: false,
  1292. $schema: "http://json-schema.org/draft-07/schema#",
  1293. },
  1294. },
  1295. ])
  1296. expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" })
  1297. }),
  1298. { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, "https://injected-openai.test/v1") },
  1299. )
  1300. it.instance(
  1301. "executes OpenAI tool calls through native runtime",
  1302. () =>
  1303. Effect.gen(function* () {
  1304. const model = loadFixture("openai", "gpt-5.2").model
  1305. const chunks = [
  1306. {
  1307. type: "response.output_item.added",
  1308. item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
  1309. },
  1310. {
  1311. type: "response.function_call_arguments.delta",
  1312. item_id: "item-native-tool",
  1313. delta: '{"query":"weather"}',
  1314. },
  1315. {
  1316. type: "response.output_item.done",
  1317. item: {
  1318. type: "function_call",
  1319. id: "item-native-tool",
  1320. call_id: "call-native-tool",
  1321. name: "lookup",
  1322. arguments: '{"query":"weather"}',
  1323. },
  1324. },
  1325. {
  1326. type: "response.completed",
  1327. response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
  1328. },
  1329. ]
  1330. const request = waitRequest("/responses", createEventResponse(chunks, true))
  1331. let executed: unknown
  1332. const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
  1333. const sessionID = SessionID.make("session-test-native-tool")
  1334. const agent = {
  1335. name: "test",
  1336. mode: "primary",
  1337. options: {},
  1338. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1339. } satisfies Agent.Info
  1340. yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), {
  1341. user: {
  1342. id: MessageID.make("msg_user-native-tool"),
  1343. sessionID,
  1344. role: "user",
  1345. time: { created: Date.now() },
  1346. agent: agent.name,
  1347. model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id },
  1348. } satisfies SessionV1.User,
  1349. sessionID,
  1350. model: resolved,
  1351. agent,
  1352. system: [],
  1353. messages: [{ role: "user", content: "Use lookup" }],
  1354. tools: {
  1355. lookup: tool({
  1356. description: "Lookup data",
  1357. inputSchema: z.object({ query: z.string() }),
  1358. execute: async (args, options) => {
  1359. executed = { args, toolCallId: options.toolCallId }
  1360. return { output: "looked up" }
  1361. },
  1362. }),
  1363. },
  1364. })
  1365. const capture = yield* Effect.promise(() => request)
  1366. expect(capture.body.tools).toEqual([
  1367. {
  1368. type: "function",
  1369. name: "lookup",
  1370. description: "Lookup data",
  1371. strict: false,
  1372. parameters: {
  1373. type: "object",
  1374. properties: { query: { type: "string" } },
  1375. required: ["query"],
  1376. additionalProperties: false,
  1377. $schema: "http://json-schema.org/draft-07/schema#",
  1378. },
  1379. },
  1380. ])
  1381. expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
  1382. }),
  1383. {
  1384. config: () => {
  1385. const model = loadFixture("openai", "gpt-5.2").model
  1386. return {
  1387. enabled_providers: ["openai"],
  1388. provider: {
  1389. openai: {
  1390. name: "OpenAI",
  1391. env: ["OPENAI_API_KEY"],
  1392. npm: "@ai-sdk/openai",
  1393. api: "https://api.openai.com/v1",
  1394. models: { [model.id]: JSON.parse(JSON.stringify(model)) as ConfigModel },
  1395. options: { apiKey: "test-openai-key", baseURL: `${state.server!.url.origin}/v1` },
  1396. },
  1397. },
  1398. }
  1399. },
  1400. },
  1401. )
  1402. it.instance(
  1403. "accepts user image attachments as data URLs for OpenAI models",
  1404. () =>
  1405. Effect.gen(function* () {
  1406. const model = loadFixture("openai", "gpt-5.2").model
  1407. const chunks = [
  1408. {
  1409. type: "response.created",
  1410. response: {
  1411. id: "resp-data-url",
  1412. created_at: Math.floor(Date.now() / 1000),
  1413. model: model.id,
  1414. service_tier: null,
  1415. },
  1416. },
  1417. {
  1418. type: "response.output_item.added",
  1419. output_index: 0,
  1420. item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] },
  1421. },
  1422. {
  1423. type: "response.content_part.added",
  1424. item_id: "item-data-url",
  1425. output_index: 0,
  1426. content_index: 0,
  1427. part: { type: "output_text", text: "", annotations: [] },
  1428. },
  1429. {
  1430. type: "response.output_text.delta",
  1431. item_id: "item-data-url",
  1432. delta: "Looks good",
  1433. logprobs: null,
  1434. },
  1435. {
  1436. type: "response.completed",
  1437. response: {
  1438. incomplete_details: null,
  1439. usage: {
  1440. input_tokens: 1,
  1441. input_tokens_details: null,
  1442. output_tokens: 1,
  1443. output_tokens_details: null,
  1444. },
  1445. service_tier: null,
  1446. },
  1447. },
  1448. ]
  1449. const request = waitRequest("/responses", createEventResponse(chunks, true))
  1450. const image = `data:image/png;base64,${Buffer.from(
  1451. yield* Effect.promise(() =>
  1452. Bun.file(path.join(import.meta.dir, "../tool/fixtures/large-image.png")).arrayBuffer(),
  1453. ),
  1454. ).toString("base64")}`
  1455. const resolved = yield* Provider.use.getModel(ProviderV2.ID.openai, ModelV2.ID.make(model.id))
  1456. const sessionID = SessionID.make("session-test-data-url")
  1457. const agent = {
  1458. name: "test",
  1459. mode: "primary",
  1460. options: {},
  1461. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1462. } satisfies Agent.Info
  1463. const user = {
  1464. id: MessageID.make("msg_user-data-url"),
  1465. sessionID,
  1466. role: "user",
  1467. time: { created: Date.now() },
  1468. agent: agent.name,
  1469. model: { providerID: ProviderV2.ID.make("openai"), modelID: resolved.id },
  1470. } satisfies SessionV1.User
  1471. yield* drain({
  1472. user,
  1473. sessionID,
  1474. model: resolved,
  1475. agent,
  1476. system: ["You are a helpful assistant."],
  1477. messages: [
  1478. {
  1479. role: "user",
  1480. content: [
  1481. { type: "text", text: "Describe this image" },
  1482. { type: "file", mediaType: "image/png", filename: "large-image.png", data: image },
  1483. ],
  1484. },
  1485. ] as ModelMessage[],
  1486. tools: {},
  1487. })
  1488. const capture = yield* Effect.promise(() => request)
  1489. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  1490. }),
  1491. { config: () => openAIConfig(loadFixture("openai", "gpt-5.2").model, `${state.server!.url.origin}/v1`) },
  1492. )
  1493. const minimaxFixture = { providerID: "minimax", modelID: "MiniMax-M2.5" }
  1494. it.instance(
  1495. "sends messages API payload for Anthropic Compatible models",
  1496. () =>
  1497. Effect.gen(function* () {
  1498. const model = loadFixture(minimaxFixture.providerID, minimaxFixture.modelID).model
  1499. const chunks = [
  1500. {
  1501. type: "message_start",
  1502. message: {
  1503. id: "msg-1",
  1504. model: model.id,
  1505. usage: {
  1506. input_tokens: 3,
  1507. cache_creation_input_tokens: null,
  1508. cache_read_input_tokens: null,
  1509. },
  1510. },
  1511. },
  1512. {
  1513. type: "content_block_start",
  1514. index: 0,
  1515. content_block: { type: "text", text: "" },
  1516. },
  1517. {
  1518. type: "content_block_delta",
  1519. index: 0,
  1520. delta: { type: "text_delta", text: "Hello" },
  1521. },
  1522. { type: "content_block_stop", index: 0 },
  1523. {
  1524. type: "message_delta",
  1525. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  1526. usage: {
  1527. input_tokens: 3,
  1528. output_tokens: 2,
  1529. cache_creation_input_tokens: null,
  1530. cache_read_input_tokens: null,
  1531. },
  1532. },
  1533. { type: "message_stop" },
  1534. ]
  1535. const request = waitRequest("/messages", createEventResponse(chunks))
  1536. const resolved = yield* Provider.use.getModel(
  1537. ProviderV2.ID.make(minimaxFixture.providerID),
  1538. ModelV2.ID.make(model.id),
  1539. )
  1540. const sessionID = SessionID.make("session-test-3")
  1541. const agent = {
  1542. name: "test",
  1543. mode: "primary",
  1544. options: {},
  1545. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1546. temperature: 0.4,
  1547. topP: 0.9,
  1548. } satisfies Agent.Info
  1549. const user = {
  1550. id: MessageID.make("msg_user-3"),
  1551. sessionID,
  1552. role: "user",
  1553. time: { created: Date.now() },
  1554. agent: agent.name,
  1555. model: { providerID: ProviderV2.ID.make("minimax"), modelID: ModelV2.ID.make("MiniMax-M2.5") },
  1556. } satisfies SessionV1.User
  1557. yield* drain({
  1558. user,
  1559. sessionID,
  1560. model: resolved,
  1561. agent,
  1562. system: ["You are a helpful assistant."],
  1563. messages: [{ role: "user", content: "Hello" }],
  1564. tools: {},
  1565. })
  1566. const capture = yield* Effect.promise(() => request)
  1567. const body = capture.body
  1568. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  1569. expect(body.model).toBe(resolved.api.id)
  1570. expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  1571. expect(body.temperature).toBe(0.4)
  1572. expect(body.top_p).toBe(0.9)
  1573. }),
  1574. {
  1575. config: () => ({
  1576. enabled_providers: [minimaxFixture.providerID],
  1577. provider: {
  1578. [minimaxFixture.providerID]: {
  1579. options: { apiKey: "test-anthropic-key", baseURL: `${state.server!.url.origin}/v1` },
  1580. },
  1581. },
  1582. }),
  1583. },
  1584. )
  1585. it.instance(
  1586. "sends anthropic tool_use blocks with tool_result immediately after them",
  1587. () =>
  1588. Effect.gen(function* () {
  1589. const model = loadFixture("anthropic", "claude-opus-4-6").model
  1590. const chunks = [
  1591. {
  1592. type: "message_start",
  1593. message: {
  1594. id: "msg-tool-order",
  1595. model: model.id,
  1596. usage: {
  1597. input_tokens: 3,
  1598. cache_creation_input_tokens: null,
  1599. cache_read_input_tokens: null,
  1600. },
  1601. },
  1602. },
  1603. {
  1604. type: "content_block_start",
  1605. index: 0,
  1606. content_block: { type: "text", text: "" },
  1607. },
  1608. {
  1609. type: "content_block_delta",
  1610. index: 0,
  1611. delta: { type: "text_delta", text: "ok" },
  1612. },
  1613. { type: "content_block_stop", index: 0 },
  1614. {
  1615. type: "message_delta",
  1616. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  1617. usage: {
  1618. input_tokens: 3,
  1619. output_tokens: 2,
  1620. cache_creation_input_tokens: null,
  1621. cache_read_input_tokens: null,
  1622. },
  1623. },
  1624. { type: "message_stop" },
  1625. ]
  1626. const request = waitRequest("/messages", createEventResponse(chunks))
  1627. const resolved = yield* Provider.use.getModel(ProviderV2.ID.make("anthropic"), ModelV2.ID.make(model.id))
  1628. const sessionID = SessionID.make("session-test-anthropic-tools")
  1629. const agent = {
  1630. name: "test",
  1631. mode: "primary",
  1632. options: {},
  1633. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1634. } satisfies Agent.Info
  1635. const user = {
  1636. id: MessageID.make("msg_user-anthropic-tools"),
  1637. sessionID,
  1638. role: "user",
  1639. time: { created: Date.now() },
  1640. agent: agent.name,
  1641. model: { providerID: ProviderV2.ID.make("anthropic"), modelID: resolved.id, variant: "max" },
  1642. } satisfies SessionV1.User
  1643. const input = [
  1644. {
  1645. info: {
  1646. id: "msg_user",
  1647. sessionID,
  1648. role: "user",
  1649. time: { created: 1 },
  1650. agent: "gentleman",
  1651. model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
  1652. },
  1653. parts: [
  1654. {
  1655. id: "p_user",
  1656. sessionID,
  1657. messageID: "msg_user",
  1658. type: "text",
  1659. text: "Can you check whether there are any PDF files in my home directory?",
  1660. },
  1661. ],
  1662. },
  1663. {
  1664. info: {
  1665. id: "msg_call",
  1666. sessionID,
  1667. parentID: "msg_user",
  1668. role: "assistant",
  1669. mode: "gentleman",
  1670. agent: "gentleman",
  1671. variant: "max",
  1672. path: { cwd: "/root", root: "/" },
  1673. cost: 0,
  1674. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  1675. modelID: "claude-opus-4-6",
  1676. providerID: "anthropic",
  1677. time: { created: 2, completed: 3 },
  1678. finish: "tool-calls",
  1679. },
  1680. parts: [
  1681. {
  1682. id: "p_step",
  1683. sessionID,
  1684. messageID: "msg_call",
  1685. type: "step-start",
  1686. },
  1687. {
  1688. id: "p_read",
  1689. sessionID,
  1690. messageID: "msg_call",
  1691. type: "tool",
  1692. tool: "read",
  1693. callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
  1694. state: {
  1695. status: "completed",
  1696. input: { filePath: "/root" },
  1697. output: "<path>/root</path>",
  1698. metadata: {},
  1699. title: "root",
  1700. time: { start: 10, end: 11 },
  1701. },
  1702. },
  1703. {
  1704. id: "p_glob",
  1705. sessionID,
  1706. messageID: "msg_call",
  1707. type: "tool",
  1708. tool: "glob",
  1709. callID: "toolu_01APxrADs7VozN8uWzw9WwHr",
  1710. state: {
  1711. status: "completed",
  1712. input: { pattern: "**/*.pdf", path: "/root" },
  1713. output: "No files found",
  1714. metadata: {},
  1715. title: "root",
  1716. time: { start: 12, end: 13 },
  1717. },
  1718. },
  1719. {
  1720. id: "p_text",
  1721. sessionID,
  1722. messageID: "msg_call",
  1723. type: "text",
  1724. text: "I checked your home directory and looked for PDF files.",
  1725. time: { start: 14, end: 15 },
  1726. },
  1727. ],
  1728. },
  1729. ] as any[]
  1730. const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(input as any, resolved))
  1731. yield* drain({
  1732. user,
  1733. sessionID,
  1734. model: resolved,
  1735. agent,
  1736. system: [],
  1737. messages: modelMessages,
  1738. tools: {
  1739. read: tool({
  1740. description: "Stub read tool",
  1741. inputSchema: z.object({ filePath: z.string() }),
  1742. execute: async () => ({ output: "stub" }),
  1743. }),
  1744. glob: tool({
  1745. description: "Stub glob tool",
  1746. inputSchema: z.object({ pattern: z.string(), path: z.string().optional() }),
  1747. execute: async () => ({ output: "stub" }),
  1748. }),
  1749. },
  1750. })
  1751. const capture = yield* Effect.promise(() => request)
  1752. const body = capture.body
  1753. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  1754. const messages = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
  1755. expect(messages[0]?.role).toBe("user")
  1756. expect(messages[0]?.content[0]).toMatchObject({
  1757. type: "text",
  1758. text: "Can you check whether there are any PDF files in my home directory?",
  1759. })
  1760. expect(messages.some((message) => message.content.some((part) => "cache_control" in part))).toBe(true)
  1761. const toolUseIndex = messages.findIndex((message) => message.content.some((part) => part.type === "tool_use"))
  1762. expect(toolUseIndex).toBeGreaterThan(0)
  1763. expect(messages[toolUseIndex].role).toBe("assistant")
  1764. expect(messages[toolUseIndex].content.filter((part) => part.type === "tool_use")).toMatchObject([
  1765. {
  1766. type: "tool_use",
  1767. id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
  1768. name: "read",
  1769. input: { filePath: "/root" },
  1770. },
  1771. {
  1772. type: "tool_use",
  1773. id: "toolu_01APxrADs7VozN8uWzw9WwHr",
  1774. name: "glob",
  1775. input: { pattern: "**/*.pdf", path: "/root" },
  1776. },
  1777. ])
  1778. expect(messages[toolUseIndex + 1]).toMatchObject({
  1779. role: "user",
  1780. content: [
  1781. { type: "tool_result", tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT", content: "<path>/root</path>" },
  1782. { type: "tool_result", tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr", content: "No files found" },
  1783. ],
  1784. })
  1785. }),
  1786. {
  1787. config: () => {
  1788. const model = loadFixture("anthropic", "claude-opus-4-6").model
  1789. return {
  1790. enabled_providers: ["anthropic"],
  1791. provider: {
  1792. anthropic: {
  1793. name: "Anthropic",
  1794. env: ["ANTHROPIC_API_KEY"],
  1795. npm: "@ai-sdk/anthropic",
  1796. api: "https://api.anthropic.com/v1",
  1797. models: { [model.id]: configModel(model) as ConfigModel },
  1798. options: { apiKey: "test-anthropic-key", baseURL: `${state.server!.url.origin}/v1` },
  1799. },
  1800. },
  1801. }
  1802. },
  1803. },
  1804. )
  1805. const geminiFixture = { providerID: "google", modelID: "gemini-2.5-flash" }
  1806. it.instance(
  1807. "sends Google API payload for Gemini models",
  1808. () =>
  1809. Effect.gen(function* () {
  1810. const model = loadFixture(geminiFixture.providerID, geminiFixture.modelID).model
  1811. const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
  1812. const chunks = [
  1813. {
  1814. candidates: [{ content: { parts: [{ text: "Hello" }] }, finishReason: "STOP" }],
  1815. usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1, totalTokenCount: 2 },
  1816. },
  1817. ]
  1818. const request = waitRequest(pathSuffix, createEventResponse(chunks))
  1819. const resolved = yield* Provider.use.getModel(
  1820. ProviderV2.ID.make(geminiFixture.providerID),
  1821. ModelV2.ID.make(model.id),
  1822. )
  1823. const sessionID = SessionID.make("session-test-4")
  1824. const agent = {
  1825. name: "test",
  1826. mode: "primary",
  1827. options: {},
  1828. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  1829. temperature: 0.3,
  1830. topP: 0.8,
  1831. } satisfies Agent.Info
  1832. const user = {
  1833. id: MessageID.make("msg_user-4"),
  1834. sessionID,
  1835. role: "user",
  1836. time: { created: Date.now() },
  1837. agent: agent.name,
  1838. model: { providerID: ProviderV2.ID.make(geminiFixture.providerID), modelID: resolved.id },
  1839. } satisfies SessionV1.User
  1840. yield* drain({
  1841. user,
  1842. sessionID,
  1843. model: resolved,
  1844. agent,
  1845. system: ["You are a helpful assistant."],
  1846. messages: [
  1847. { role: "user", content: "Hello" },
  1848. { role: "assistant", content: [{ type: "reasoning", text: "" }] },
  1849. ],
  1850. tools: {},
  1851. })
  1852. const capture = yield* Effect.promise(() => request)
  1853. const body = capture.body
  1854. const config = body.generationConfig as
  1855. | { temperature?: number; topP?: number; maxOutputTokens?: number }
  1856. | undefined
  1857. expect(capture.url.pathname).toBe(pathSuffix)
  1858. expect(body.contents).toEqual([{ role: "user", parts: [{ text: "Hello" }] }])
  1859. expect(config?.temperature).toBe(0.3)
  1860. expect(config?.topP).toBe(0.8)
  1861. expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  1862. }),
  1863. {
  1864. config: () => ({
  1865. enabled_providers: [geminiFixture.providerID],
  1866. provider: {
  1867. [geminiFixture.providerID]: {
  1868. options: { apiKey: "test-google-key", baseURL: `${state.server!.url.origin}/v1beta` },
  1869. },
  1870. },
  1871. }),
  1872. },
  1873. )
  1874. })