llm.test.ts 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093
  1. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import { tool, type ModelMessage } from "ai"
  4. import { Cause, Exit, Stream } from "effect"
  5. import z from "zod"
  6. import { makeRuntime } from "../../src/effect/run-service"
  7. import { LLM } from "../../src/session/llm"
  8. import { Instance } from "../../src/project/instance"
  9. import { Provider } from "../../src/provider/provider"
  10. import { ProviderTransform } from "../../src/provider/transform"
  11. import { ModelsDev } from "../../src/provider/models"
  12. import { ProviderID, ModelID } from "../../src/provider/schema"
  13. import { Filesystem } from "../../src/util/filesystem"
  14. import { tmpdir } from "../fixture/fixture"
  15. import type { Agent } from "../../src/agent/agent"
  16. import type { MessageV2 } from "../../src/session/message-v2"
  17. import { SessionID, MessageID } from "../../src/session/schema"
  18. describe("session.llm.hasToolCalls", () => {
  19. test("returns false for empty messages array", () => {
  20. expect(LLM.hasToolCalls([])).toBe(false)
  21. })
  22. test("returns false for messages with only text content", () => {
  23. const messages: ModelMessage[] = [
  24. {
  25. role: "user",
  26. content: [{ type: "text", text: "Hello" }],
  27. },
  28. {
  29. role: "assistant",
  30. content: [{ type: "text", text: "Hi there" }],
  31. },
  32. ]
  33. expect(LLM.hasToolCalls(messages)).toBe(false)
  34. })
  35. test("returns true when messages contain tool-call", () => {
  36. const messages = [
  37. {
  38. role: "user",
  39. content: [{ type: "text", text: "Run a command" }],
  40. },
  41. {
  42. role: "assistant",
  43. content: [
  44. {
  45. type: "tool-call",
  46. toolCallId: "call-123",
  47. toolName: "bash",
  48. },
  49. ],
  50. },
  51. ] as ModelMessage[]
  52. expect(LLM.hasToolCalls(messages)).toBe(true)
  53. })
  54. test("returns true when messages contain tool-result", () => {
  55. const messages = [
  56. {
  57. role: "tool",
  58. content: [
  59. {
  60. type: "tool-result",
  61. toolCallId: "call-123",
  62. toolName: "bash",
  63. },
  64. ],
  65. },
  66. ] as ModelMessage[]
  67. expect(LLM.hasToolCalls(messages)).toBe(true)
  68. })
  69. test("returns false for messages with string content", () => {
  70. const messages: ModelMessage[] = [
  71. {
  72. role: "user",
  73. content: "Hello world",
  74. },
  75. {
  76. role: "assistant",
  77. content: "Hi there",
  78. },
  79. ]
  80. expect(LLM.hasToolCalls(messages)).toBe(false)
  81. })
  82. test("returns true when tool-call is mixed with text content", () => {
  83. const messages = [
  84. {
  85. role: "assistant",
  86. content: [
  87. { type: "text", text: "Let me run that command" },
  88. {
  89. type: "tool-call",
  90. toolCallId: "call-456",
  91. toolName: "read",
  92. },
  93. ],
  94. },
  95. ] as ModelMessage[]
  96. expect(LLM.hasToolCalls(messages)).toBe(true)
  97. })
  98. })
  99. type Capture = {
  100. url: URL
  101. headers: Headers
  102. body: Record<string, unknown>
  103. }
  104. const state = {
  105. server: null as ReturnType<typeof Bun.serve> | null,
  106. queue: [] as Array<{
  107. path: string
  108. response: Response | ((req: Request, capture: Capture) => Response)
  109. resolve: (value: Capture) => void
  110. }>,
  111. }
  112. function deferred<T>() {
  113. const result = {} as { promise: Promise<T>; resolve: (value: T) => void }
  114. result.promise = new Promise((resolve) => {
  115. result.resolve = resolve
  116. })
  117. return result
  118. }
  119. function waitRequest(pathname: string, response: Response) {
  120. const pending = deferred<Capture>()
  121. state.queue.push({ path: pathname, response, resolve: pending.resolve })
  122. return pending.promise
  123. }
  124. function timeout(ms: number) {
  125. return new Promise<never>((_, reject) => {
  126. setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
  127. })
  128. }
  129. function waitStreamingRequest(pathname: string) {
  130. const request = deferred<Capture>()
  131. const requestAborted = deferred<void>()
  132. const responseCanceled = deferred<void>()
  133. const encoder = new TextEncoder()
  134. state.queue.push({
  135. path: pathname,
  136. resolve: request.resolve,
  137. response(req: Request) {
  138. req.signal.addEventListener("abort", () => requestAborted.resolve(), { once: true })
  139. return new Response(
  140. new ReadableStream<Uint8Array>({
  141. start(controller) {
  142. controller.enqueue(
  143. encoder.encode(
  144. [
  145. `data: ${JSON.stringify({
  146. id: "chatcmpl-abort",
  147. object: "chat.completion.chunk",
  148. choices: [{ delta: { role: "assistant" } }],
  149. })}`,
  150. ].join("\n\n") + "\n\n",
  151. ),
  152. )
  153. },
  154. cancel() {
  155. responseCanceled.resolve()
  156. },
  157. }),
  158. {
  159. status: 200,
  160. headers: { "Content-Type": "text/event-stream" },
  161. },
  162. )
  163. },
  164. })
  165. return {
  166. request: request.promise,
  167. requestAborted: requestAborted.promise,
  168. responseCanceled: responseCanceled.promise,
  169. }
  170. }
  171. beforeAll(() => {
  172. state.server = Bun.serve({
  173. port: 0,
  174. async fetch(req) {
  175. const next = state.queue.shift()
  176. if (!next) {
  177. return new Response("unexpected request", { status: 500 })
  178. }
  179. const url = new URL(req.url)
  180. const body = (await req.json()) as Record<string, unknown>
  181. next.resolve({ url, headers: req.headers, body })
  182. if (!url.pathname.endsWith(next.path)) {
  183. return new Response("not found", { status: 404 })
  184. }
  185. return typeof next.response === "function"
  186. ? next.response(req, { url, headers: req.headers, body })
  187. : next.response
  188. },
  189. })
  190. })
  191. beforeEach(() => {
  192. state.queue.length = 0
  193. })
  194. afterAll(() => {
  195. state.server?.stop()
  196. })
  197. function createChatStream(text: string) {
  198. const payload =
  199. [
  200. `data: ${JSON.stringify({
  201. id: "chatcmpl-1",
  202. object: "chat.completion.chunk",
  203. choices: [{ delta: { role: "assistant" } }],
  204. })}`,
  205. `data: ${JSON.stringify({
  206. id: "chatcmpl-1",
  207. object: "chat.completion.chunk",
  208. choices: [{ delta: { content: text } }],
  209. })}`,
  210. `data: ${JSON.stringify({
  211. id: "chatcmpl-1",
  212. object: "chat.completion.chunk",
  213. choices: [{ delta: {}, finish_reason: "stop" }],
  214. })}`,
  215. "data: [DONE]",
  216. ].join("\n\n") + "\n\n"
  217. const encoder = new TextEncoder()
  218. return new ReadableStream<Uint8Array>({
  219. start(controller) {
  220. controller.enqueue(encoder.encode(payload))
  221. controller.close()
  222. },
  223. })
  224. }
  225. async function loadFixture(providerID: string, modelID: string) {
  226. const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json")
  227. const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(fixturePath)
  228. const provider = data[providerID]
  229. if (!provider) {
  230. throw new Error(`Missing provider in fixture: ${providerID}`)
  231. }
  232. const model = provider.models[modelID]
  233. if (!model) {
  234. throw new Error(`Missing model in fixture: ${modelID}`)
  235. }
  236. return { provider, model }
  237. }
  238. function createEventStream(chunks: unknown[], includeDone = false) {
  239. const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
  240. if (includeDone) {
  241. lines.push("data: [DONE]")
  242. }
  243. const payload = lines.join("\n\n") + "\n\n"
  244. const encoder = new TextEncoder()
  245. return new ReadableStream<Uint8Array>({
  246. start(controller) {
  247. controller.enqueue(encoder.encode(payload))
  248. controller.close()
  249. },
  250. })
  251. }
  252. function createEventResponse(chunks: unknown[], includeDone = false) {
  253. return new Response(createEventStream(chunks, includeDone), {
  254. status: 200,
  255. headers: { "Content-Type": "text/event-stream" },
  256. })
  257. }
  258. describe("session.llm.stream", () => {
  259. test("sends temperature, tokens, and reasoning options for openai-compatible models", async () => {
  260. const server = state.server
  261. if (!server) {
  262. throw new Error("Server not initialized")
  263. }
  264. const providerID = "vivgrid"
  265. const modelID = "gemini-3.1-pro-preview"
  266. const fixture = await loadFixture(providerID, modelID)
  267. const model = fixture.model
  268. const request = waitRequest(
  269. "/chat/completions",
  270. new Response(createChatStream("Hello"), {
  271. status: 200,
  272. headers: { "Content-Type": "text/event-stream" },
  273. }),
  274. )
  275. await using tmp = await tmpdir({
  276. init: async (dir) => {
  277. await Bun.write(
  278. path.join(dir, "opencode.json"),
  279. JSON.stringify({
  280. $schema: "https://opencode.ai/config.json",
  281. enabled_providers: [providerID],
  282. provider: {
  283. [providerID]: {
  284. options: {
  285. apiKey: "test-key",
  286. baseURL: `${server.url.origin}/v1`,
  287. },
  288. },
  289. },
  290. }),
  291. )
  292. },
  293. })
  294. await Instance.provide({
  295. directory: tmp.path,
  296. fn: async () => {
  297. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  298. const sessionID = SessionID.make("session-test-1")
  299. const agent = {
  300. name: "test",
  301. mode: "primary",
  302. options: {},
  303. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  304. temperature: 0.4,
  305. topP: 0.8,
  306. } satisfies Agent.Info
  307. const user = {
  308. id: MessageID.make("user-1"),
  309. sessionID,
  310. role: "user",
  311. time: { created: Date.now() },
  312. agent: agent.name,
  313. model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" },
  314. } satisfies MessageV2.User
  315. const stream = await LLM.stream({
  316. user,
  317. sessionID,
  318. model: resolved,
  319. agent,
  320. system: ["You are a helpful assistant."],
  321. abort: new AbortController().signal,
  322. messages: [{ role: "user", content: "Hello" }],
  323. tools: {},
  324. })
  325. for await (const _ of stream.fullStream) {
  326. }
  327. const capture = await request
  328. const body = capture.body
  329. const headers = capture.headers
  330. const url = capture.url
  331. expect(url.pathname.startsWith("/v1/")).toBe(true)
  332. expect(url.pathname.endsWith("/chat/completions")).toBe(true)
  333. expect(headers.get("Authorization")).toBe("Bearer test-key")
  334. expect(body.model).toBe(resolved.api.id)
  335. expect(body.temperature).toBe(0.4)
  336. expect(body.top_p).toBe(0.8)
  337. expect(body.stream).toBe(true)
  338. const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
  339. const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
  340. expect(maxTokens).toBe(expectedMaxTokens)
  341. const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
  342. expect(reasoning).toBe("high")
  343. },
  344. })
  345. })
  346. test("raw stream abort signal cancels provider response body promptly", async () => {
  347. const server = state.server
  348. if (!server) throw new Error("Server not initialized")
  349. const providerID = "alibaba"
  350. const modelID = "qwen-plus"
  351. const fixture = await loadFixture(providerID, modelID)
  352. const model = fixture.model
  353. const pending = waitStreamingRequest("/chat/completions")
  354. await using tmp = await tmpdir({
  355. init: async (dir) => {
  356. await Bun.write(
  357. path.join(dir, "opencode.json"),
  358. JSON.stringify({
  359. $schema: "https://opencode.ai/config.json",
  360. enabled_providers: [providerID],
  361. provider: {
  362. [providerID]: {
  363. options: {
  364. apiKey: "test-key",
  365. baseURL: `${server.url.origin}/v1`,
  366. },
  367. },
  368. },
  369. }),
  370. )
  371. },
  372. })
  373. await Instance.provide({
  374. directory: tmp.path,
  375. fn: async () => {
  376. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  377. const sessionID = SessionID.make("session-test-raw-abort")
  378. const agent = {
  379. name: "test",
  380. mode: "primary",
  381. options: {},
  382. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  383. } satisfies Agent.Info
  384. const user = {
  385. id: MessageID.make("user-raw-abort"),
  386. sessionID,
  387. role: "user",
  388. time: { created: Date.now() },
  389. agent: agent.name,
  390. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  391. } satisfies MessageV2.User
  392. const ctrl = new AbortController()
  393. const result = await LLM.stream({
  394. user,
  395. sessionID,
  396. model: resolved,
  397. agent,
  398. system: ["You are a helpful assistant."],
  399. abort: ctrl.signal,
  400. messages: [{ role: "user", content: "Hello" }],
  401. tools: {},
  402. })
  403. const iter = result.fullStream[Symbol.asyncIterator]()
  404. await pending.request
  405. await iter.next()
  406. ctrl.abort()
  407. await Promise.race([pending.responseCanceled, timeout(500)])
  408. await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
  409. await iter.return?.()
  410. },
  411. })
  412. })
  413. test("service stream cancellation cancels provider response body promptly", async () => {
  414. const server = state.server
  415. if (!server) throw new Error("Server not initialized")
  416. const providerID = "alibaba"
  417. const modelID = "qwen-plus"
  418. const fixture = await loadFixture(providerID, modelID)
  419. const model = fixture.model
  420. const pending = waitStreamingRequest("/chat/completions")
  421. await using tmp = await tmpdir({
  422. init: async (dir) => {
  423. await Bun.write(
  424. path.join(dir, "opencode.json"),
  425. JSON.stringify({
  426. $schema: "https://opencode.ai/config.json",
  427. enabled_providers: [providerID],
  428. provider: {
  429. [providerID]: {
  430. options: {
  431. apiKey: "test-key",
  432. baseURL: `${server.url.origin}/v1`,
  433. },
  434. },
  435. },
  436. }),
  437. )
  438. },
  439. })
  440. await Instance.provide({
  441. directory: tmp.path,
  442. fn: async () => {
  443. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  444. const sessionID = SessionID.make("session-test-service-abort")
  445. const agent = {
  446. name: "test",
  447. mode: "primary",
  448. options: {},
  449. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  450. } satisfies Agent.Info
  451. const user = {
  452. id: MessageID.make("user-service-abort"),
  453. sessionID,
  454. role: "user",
  455. time: { created: Date.now() },
  456. agent: agent.name,
  457. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  458. } satisfies MessageV2.User
  459. const ctrl = new AbortController()
  460. const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer)
  461. const run = runPromiseExit(
  462. (svc) =>
  463. svc
  464. .stream({
  465. user,
  466. sessionID,
  467. model: resolved,
  468. agent,
  469. system: ["You are a helpful assistant."],
  470. messages: [{ role: "user", content: "Hello" }],
  471. tools: {},
  472. })
  473. .pipe(Stream.runDrain),
  474. { signal: ctrl.signal },
  475. )
  476. await pending.request
  477. ctrl.abort()
  478. await Promise.race([pending.responseCanceled, timeout(500)])
  479. const exit = await run
  480. expect(Exit.isFailure(exit)).toBe(true)
  481. if (Exit.isFailure(exit)) {
  482. expect(Cause.hasInterrupts(exit.cause)).toBe(true)
  483. }
  484. await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
  485. },
  486. })
  487. })
  488. test("keeps tools enabled by prompt permissions", async () => {
  489. const server = state.server
  490. if (!server) {
  491. throw new Error("Server not initialized")
  492. }
  493. const providerID = "alibaba"
  494. const modelID = "qwen-plus"
  495. const fixture = await loadFixture(providerID, modelID)
  496. const model = fixture.model
  497. const request = waitRequest(
  498. "/chat/completions",
  499. new Response(createChatStream("Hello"), {
  500. status: 200,
  501. headers: { "Content-Type": "text/event-stream" },
  502. }),
  503. )
  504. await using tmp = await tmpdir({
  505. init: async (dir) => {
  506. await Bun.write(
  507. path.join(dir, "opencode.json"),
  508. JSON.stringify({
  509. $schema: "https://opencode.ai/config.json",
  510. enabled_providers: [providerID],
  511. provider: {
  512. [providerID]: {
  513. options: {
  514. apiKey: "test-key",
  515. baseURL: `${server.url.origin}/v1`,
  516. },
  517. },
  518. },
  519. }),
  520. )
  521. },
  522. })
  523. await Instance.provide({
  524. directory: tmp.path,
  525. fn: async () => {
  526. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  527. const sessionID = SessionID.make("session-test-tools")
  528. const agent = {
  529. name: "test",
  530. mode: "primary",
  531. options: {},
  532. permission: [{ permission: "question", pattern: "*", action: "deny" }],
  533. } satisfies Agent.Info
  534. const user = {
  535. id: MessageID.make("user-tools"),
  536. sessionID,
  537. role: "user",
  538. time: { created: Date.now() },
  539. agent: agent.name,
  540. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  541. tools: { question: true },
  542. } satisfies MessageV2.User
  543. const stream = await LLM.stream({
  544. user,
  545. sessionID,
  546. model: resolved,
  547. agent,
  548. permission: [{ permission: "question", pattern: "*", action: "allow" }],
  549. system: ["You are a helpful assistant."],
  550. abort: new AbortController().signal,
  551. messages: [{ role: "user", content: "Hello" }],
  552. tools: {
  553. question: tool({
  554. description: "Ask a question",
  555. inputSchema: z.object({}),
  556. execute: async () => ({ output: "" }),
  557. }),
  558. },
  559. })
  560. for await (const _ of stream.fullStream) {
  561. }
  562. const capture = await request
  563. const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
  564. expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
  565. },
  566. })
  567. })
  568. test("sends responses API payload for OpenAI models", async () => {
  569. const server = state.server
  570. if (!server) {
  571. throw new Error("Server not initialized")
  572. }
  573. const source = await loadFixture("openai", "gpt-5.2")
  574. const model = source.model
  575. const responseChunks = [
  576. {
  577. type: "response.created",
  578. response: {
  579. id: "resp-1",
  580. created_at: Math.floor(Date.now() / 1000),
  581. model: model.id,
  582. service_tier: null,
  583. },
  584. },
  585. {
  586. type: "response.output_text.delta",
  587. item_id: "item-1",
  588. delta: "Hello",
  589. logprobs: null,
  590. },
  591. {
  592. type: "response.completed",
  593. response: {
  594. incomplete_details: null,
  595. usage: {
  596. input_tokens: 1,
  597. input_tokens_details: null,
  598. output_tokens: 1,
  599. output_tokens_details: null,
  600. },
  601. service_tier: null,
  602. },
  603. },
  604. ]
  605. const request = waitRequest("/responses", createEventResponse(responseChunks, true))
  606. await using tmp = await tmpdir({
  607. init: async (dir) => {
  608. await Bun.write(
  609. path.join(dir, "opencode.json"),
  610. JSON.stringify({
  611. $schema: "https://opencode.ai/config.json",
  612. enabled_providers: ["openai"],
  613. provider: {
  614. openai: {
  615. name: "OpenAI",
  616. env: ["OPENAI_API_KEY"],
  617. npm: "@ai-sdk/openai",
  618. api: "https://api.openai.com/v1",
  619. models: {
  620. [model.id]: model,
  621. },
  622. options: {
  623. apiKey: "test-openai-key",
  624. baseURL: `${server.url.origin}/v1`,
  625. },
  626. },
  627. },
  628. }),
  629. )
  630. },
  631. })
  632. await Instance.provide({
  633. directory: tmp.path,
  634. fn: async () => {
  635. const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
  636. const sessionID = SessionID.make("session-test-2")
  637. const agent = {
  638. name: "test",
  639. mode: "primary",
  640. options: {},
  641. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  642. temperature: 0.2,
  643. } satisfies Agent.Info
  644. const user = {
  645. id: MessageID.make("user-2"),
  646. sessionID,
  647. role: "user",
  648. time: { created: Date.now() },
  649. agent: agent.name,
  650. model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
  651. } satisfies MessageV2.User
  652. const stream = await LLM.stream({
  653. user,
  654. sessionID,
  655. model: resolved,
  656. agent,
  657. system: ["You are a helpful assistant."],
  658. abort: new AbortController().signal,
  659. messages: [{ role: "user", content: "Hello" }],
  660. tools: {},
  661. })
  662. for await (const _ of stream.fullStream) {
  663. }
  664. const capture = await request
  665. const body = capture.body
  666. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  667. expect(body.model).toBe(resolved.api.id)
  668. expect(body.stream).toBe(true)
  669. expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  670. const maxTokens = body.max_output_tokens as number | undefined
  671. expect(maxTokens).toBe(undefined) // match codex cli behavior
  672. },
  673. })
  674. })
  675. test("accepts user image attachments as data URLs for OpenAI models", async () => {
  676. const server = state.server
  677. if (!server) {
  678. throw new Error("Server not initialized")
  679. }
  680. const source = await loadFixture("openai", "gpt-5.2")
  681. const model = source.model
  682. const chunks = [
  683. {
  684. type: "response.created",
  685. response: {
  686. id: "resp-data-url",
  687. created_at: Math.floor(Date.now() / 1000),
  688. model: model.id,
  689. service_tier: null,
  690. },
  691. },
  692. {
  693. type: "response.output_text.delta",
  694. item_id: "item-data-url",
  695. delta: "Looks good",
  696. logprobs: null,
  697. },
  698. {
  699. type: "response.completed",
  700. response: {
  701. incomplete_details: null,
  702. usage: {
  703. input_tokens: 1,
  704. input_tokens_details: null,
  705. output_tokens: 1,
  706. output_tokens_details: null,
  707. },
  708. service_tier: null,
  709. },
  710. },
  711. ]
  712. const request = waitRequest("/responses", createEventResponse(chunks, true))
  713. const image = `data:image/png;base64,${Buffer.from(
  714. await Bun.file(path.join(import.meta.dir, "../tool/fixtures/large-image.png")).arrayBuffer(),
  715. ).toString("base64")}`
  716. await using tmp = await tmpdir({
  717. init: async (dir) => {
  718. await Bun.write(
  719. path.join(dir, "opencode.json"),
  720. JSON.stringify({
  721. $schema: "https://opencode.ai/config.json",
  722. enabled_providers: ["openai"],
  723. provider: {
  724. openai: {
  725. name: "OpenAI",
  726. env: ["OPENAI_API_KEY"],
  727. npm: "@ai-sdk/openai",
  728. api: "https://api.openai.com/v1",
  729. models: {
  730. [model.id]: model,
  731. },
  732. options: {
  733. apiKey: "test-openai-key",
  734. baseURL: `${server.url.origin}/v1`,
  735. },
  736. },
  737. },
  738. }),
  739. )
  740. },
  741. })
  742. await Instance.provide({
  743. directory: tmp.path,
  744. fn: async () => {
  745. const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
  746. const sessionID = SessionID.make("session-test-data-url")
  747. const agent = {
  748. name: "test",
  749. mode: "primary",
  750. options: {},
  751. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  752. } satisfies Agent.Info
  753. const user = {
  754. id: MessageID.make("user-data-url"),
  755. sessionID,
  756. role: "user",
  757. time: { created: Date.now() },
  758. agent: agent.name,
  759. model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
  760. } satisfies MessageV2.User
  761. const stream = await LLM.stream({
  762. user,
  763. sessionID,
  764. model: resolved,
  765. agent,
  766. system: ["You are a helpful assistant."],
  767. abort: new AbortController().signal,
  768. messages: [
  769. {
  770. role: "user",
  771. content: [
  772. { type: "text", text: "Describe this image" },
  773. {
  774. type: "file",
  775. mediaType: "image/png",
  776. filename: "large-image.png",
  777. data: image,
  778. },
  779. ],
  780. },
  781. ] as ModelMessage[],
  782. tools: {},
  783. })
  784. for await (const _ of stream.fullStream) {
  785. }
  786. const capture = await request
  787. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  788. },
  789. })
  790. })
  791. test("sends messages API payload for Anthropic Compatible models", async () => {
  792. const server = state.server
  793. if (!server) {
  794. throw new Error("Server not initialized")
  795. }
  796. const providerID = "minimax"
  797. const modelID = "MiniMax-M2.5"
  798. const fixture = await loadFixture(providerID, modelID)
  799. const model = fixture.model
  800. const chunks = [
  801. {
  802. type: "message_start",
  803. message: {
  804. id: "msg-1",
  805. model: model.id,
  806. usage: {
  807. input_tokens: 3,
  808. cache_creation_input_tokens: null,
  809. cache_read_input_tokens: null,
  810. },
  811. },
  812. },
  813. {
  814. type: "content_block_start",
  815. index: 0,
  816. content_block: { type: "text", text: "" },
  817. },
  818. {
  819. type: "content_block_delta",
  820. index: 0,
  821. delta: { type: "text_delta", text: "Hello" },
  822. },
  823. { type: "content_block_stop", index: 0 },
  824. {
  825. type: "message_delta",
  826. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  827. usage: {
  828. input_tokens: 3,
  829. output_tokens: 2,
  830. cache_creation_input_tokens: null,
  831. cache_read_input_tokens: null,
  832. },
  833. },
  834. { type: "message_stop" },
  835. ]
  836. const request = waitRequest("/messages", createEventResponse(chunks))
  837. await using tmp = await tmpdir({
  838. init: async (dir) => {
  839. await Bun.write(
  840. path.join(dir, "opencode.json"),
  841. JSON.stringify({
  842. $schema: "https://opencode.ai/config.json",
  843. enabled_providers: [providerID],
  844. provider: {
  845. [providerID]: {
  846. options: {
  847. apiKey: "test-anthropic-key",
  848. baseURL: `${server.url.origin}/v1`,
  849. },
  850. },
  851. },
  852. }),
  853. )
  854. },
  855. })
  856. await Instance.provide({
  857. directory: tmp.path,
  858. fn: async () => {
  859. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  860. const sessionID = SessionID.make("session-test-3")
  861. const agent = {
  862. name: "test",
  863. mode: "primary",
  864. options: {},
  865. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  866. temperature: 0.4,
  867. topP: 0.9,
  868. } satisfies Agent.Info
  869. const user = {
  870. id: MessageID.make("user-3"),
  871. sessionID,
  872. role: "user",
  873. time: { created: Date.now() },
  874. agent: agent.name,
  875. model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
  876. } satisfies MessageV2.User
  877. const stream = await LLM.stream({
  878. user,
  879. sessionID,
  880. model: resolved,
  881. agent,
  882. system: ["You are a helpful assistant."],
  883. abort: new AbortController().signal,
  884. messages: [{ role: "user", content: "Hello" }],
  885. tools: {},
  886. })
  887. for await (const _ of stream.fullStream) {
  888. }
  889. const capture = await request
  890. const body = capture.body
  891. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  892. expect(body.model).toBe(resolved.api.id)
  893. expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  894. expect(body.temperature).toBe(0.4)
  895. expect(body.top_p).toBe(0.9)
  896. },
  897. })
  898. })
  899. test("sends Google API payload for Gemini models", async () => {
  900. const server = state.server
  901. if (!server) {
  902. throw new Error("Server not initialized")
  903. }
  904. const providerID = "google"
  905. const modelID = "gemini-2.5-flash"
  906. const fixture = await loadFixture(providerID, modelID)
  907. const provider = fixture.provider
  908. const model = fixture.model
  909. const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
  910. const chunks = [
  911. {
  912. candidates: [
  913. {
  914. content: {
  915. parts: [{ text: "Hello" }],
  916. },
  917. finishReason: "STOP",
  918. },
  919. ],
  920. usageMetadata: {
  921. promptTokenCount: 1,
  922. candidatesTokenCount: 1,
  923. totalTokenCount: 2,
  924. },
  925. },
  926. ]
  927. const request = waitRequest(pathSuffix, createEventResponse(chunks))
  928. await using tmp = await tmpdir({
  929. init: async (dir) => {
  930. await Bun.write(
  931. path.join(dir, "opencode.json"),
  932. JSON.stringify({
  933. $schema: "https://opencode.ai/config.json",
  934. enabled_providers: [providerID],
  935. provider: {
  936. [providerID]: {
  937. options: {
  938. apiKey: "test-google-key",
  939. baseURL: `${server.url.origin}/v1beta`,
  940. },
  941. },
  942. },
  943. }),
  944. )
  945. },
  946. })
  947. await Instance.provide({
  948. directory: tmp.path,
  949. fn: async () => {
  950. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  951. const sessionID = SessionID.make("session-test-4")
  952. const agent = {
  953. name: "test",
  954. mode: "primary",
  955. options: {},
  956. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  957. temperature: 0.3,
  958. topP: 0.8,
  959. } satisfies Agent.Info
  960. const user = {
  961. id: MessageID.make("user-4"),
  962. sessionID,
  963. role: "user",
  964. time: { created: Date.now() },
  965. agent: agent.name,
  966. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  967. } satisfies MessageV2.User
  968. const stream = await LLM.stream({
  969. user,
  970. sessionID,
  971. model: resolved,
  972. agent,
  973. system: ["You are a helpful assistant."],
  974. abort: new AbortController().signal,
  975. messages: [{ role: "user", content: "Hello" }],
  976. tools: {},
  977. })
  978. for await (const _ of stream.fullStream) {
  979. }
  980. const capture = await request
  981. const body = capture.body
  982. const config = body.generationConfig as
  983. | { temperature?: number; topP?: number; maxOutputTokens?: number }
  984. | undefined
  985. expect(capture.url.pathname).toBe(pathSuffix)
  986. expect(config?.temperature).toBe(0.3)
  987. expect(config?.topP).toBe(0.8)
  988. expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  989. },
  990. })
  991. })
  992. })