llm.test.ts 32 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094
  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://app.kilo.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(headers.get("User-Agent") ?? "").toMatch(/^Kilo-Code\//) // kilocode_change
  335. expect(body.model).toBe(resolved.api.id)
  336. expect(body.temperature).toBe(0.4)
  337. expect(body.top_p).toBe(0.8)
  338. expect(body.stream).toBe(true)
  339. const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
  340. const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
  341. expect(maxTokens).toBe(expectedMaxTokens)
  342. const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
  343. expect(reasoning).toBe("high")
  344. },
  345. })
  346. })
  347. test("raw stream abort signal cancels provider response body promptly", async () => {
  348. const server = state.server
  349. if (!server) throw new Error("Server not initialized")
  350. const providerID = "alibaba"
  351. const modelID = "qwen-plus"
  352. const fixture = await loadFixture(providerID, modelID)
  353. const model = fixture.model
  354. const pending = waitStreamingRequest("/chat/completions")
  355. await using tmp = await tmpdir({
  356. init: async (dir) => {
  357. await Bun.write(
  358. path.join(dir, "opencode.json"),
  359. JSON.stringify({
  360. $schema: "https://opencode.ai/config.json",
  361. enabled_providers: [providerID],
  362. provider: {
  363. [providerID]: {
  364. options: {
  365. apiKey: "test-key",
  366. baseURL: `${server.url.origin}/v1`,
  367. },
  368. },
  369. },
  370. }),
  371. )
  372. },
  373. })
  374. await Instance.provide({
  375. directory: tmp.path,
  376. fn: async () => {
  377. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  378. const sessionID = SessionID.make("session-test-raw-abort")
  379. const agent = {
  380. name: "test",
  381. mode: "primary",
  382. options: {},
  383. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  384. } satisfies Agent.Info
  385. const user = {
  386. id: MessageID.make("user-raw-abort"),
  387. sessionID,
  388. role: "user",
  389. time: { created: Date.now() },
  390. agent: agent.name,
  391. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  392. } satisfies MessageV2.User
  393. const ctrl = new AbortController()
  394. const result = await LLM.stream({
  395. user,
  396. sessionID,
  397. model: resolved,
  398. agent,
  399. system: ["You are a helpful assistant."],
  400. abort: ctrl.signal,
  401. messages: [{ role: "user", content: "Hello" }],
  402. tools: {},
  403. })
  404. const iter = result.fullStream[Symbol.asyncIterator]()
  405. await pending.request
  406. await iter.next()
  407. ctrl.abort()
  408. await Promise.race([pending.responseCanceled, timeout(500)])
  409. await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
  410. await iter.return?.()
  411. },
  412. })
  413. })
  414. test("service stream cancellation cancels provider response body promptly", async () => {
  415. const server = state.server
  416. if (!server) throw new Error("Server not initialized")
  417. const providerID = "alibaba"
  418. const modelID = "qwen-plus"
  419. const fixture = await loadFixture(providerID, modelID)
  420. const model = fixture.model
  421. const pending = waitStreamingRequest("/chat/completions")
  422. await using tmp = await tmpdir({
  423. init: async (dir) => {
  424. await Bun.write(
  425. path.join(dir, "opencode.json"),
  426. JSON.stringify({
  427. $schema: "https://opencode.ai/config.json",
  428. enabled_providers: [providerID],
  429. provider: {
  430. [providerID]: {
  431. options: {
  432. apiKey: "test-key",
  433. baseURL: `${server.url.origin}/v1`,
  434. },
  435. },
  436. },
  437. }),
  438. )
  439. },
  440. })
  441. await Instance.provide({
  442. directory: tmp.path,
  443. fn: async () => {
  444. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  445. const sessionID = SessionID.make("session-test-service-abort")
  446. const agent = {
  447. name: "test",
  448. mode: "primary",
  449. options: {},
  450. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  451. } satisfies Agent.Info
  452. const user = {
  453. id: MessageID.make("user-service-abort"),
  454. sessionID,
  455. role: "user",
  456. time: { created: Date.now() },
  457. agent: agent.name,
  458. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  459. } satisfies MessageV2.User
  460. const ctrl = new AbortController()
  461. const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer)
  462. const run = runPromiseExit(
  463. (svc) =>
  464. svc
  465. .stream({
  466. user,
  467. sessionID,
  468. model: resolved,
  469. agent,
  470. system: ["You are a helpful assistant."],
  471. messages: [{ role: "user", content: "Hello" }],
  472. tools: {},
  473. })
  474. .pipe(Stream.runDrain),
  475. { signal: ctrl.signal },
  476. )
  477. await pending.request
  478. ctrl.abort()
  479. await Promise.race([pending.responseCanceled, timeout(500)])
  480. const exit = await run
  481. expect(Exit.isFailure(exit)).toBe(true)
  482. if (Exit.isFailure(exit)) {
  483. expect(Cause.hasInterrupts(exit.cause)).toBe(true)
  484. }
  485. await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined)
  486. },
  487. })
  488. })
  489. test("keeps tools enabled by prompt permissions", async () => {
  490. const server = state.server
  491. if (!server) {
  492. throw new Error("Server not initialized")
  493. }
  494. const providerID = "alibaba"
  495. const modelID = "qwen-plus"
  496. const fixture = await loadFixture(providerID, modelID)
  497. const model = fixture.model
  498. const request = waitRequest(
  499. "/chat/completions",
  500. new Response(createChatStream("Hello"), {
  501. status: 200,
  502. headers: { "Content-Type": "text/event-stream" },
  503. }),
  504. )
  505. await using tmp = await tmpdir({
  506. init: async (dir) => {
  507. await Bun.write(
  508. path.join(dir, "opencode.json"),
  509. JSON.stringify({
  510. $schema: "https://opencode.ai/config.json",
  511. enabled_providers: [providerID],
  512. provider: {
  513. [providerID]: {
  514. options: {
  515. apiKey: "test-key",
  516. baseURL: `${server.url.origin}/v1`,
  517. },
  518. },
  519. },
  520. }),
  521. )
  522. },
  523. })
  524. await Instance.provide({
  525. directory: tmp.path,
  526. fn: async () => {
  527. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  528. const sessionID = SessionID.make("session-test-tools")
  529. const agent = {
  530. name: "test",
  531. mode: "primary",
  532. options: {},
  533. permission: [{ permission: "question", pattern: "*", action: "deny" }],
  534. } satisfies Agent.Info
  535. const user = {
  536. id: MessageID.make("user-tools"),
  537. sessionID,
  538. role: "user",
  539. time: { created: Date.now() },
  540. agent: agent.name,
  541. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  542. tools: { question: true },
  543. } satisfies MessageV2.User
  544. const stream = await LLM.stream({
  545. user,
  546. sessionID,
  547. model: resolved,
  548. agent,
  549. permission: [{ permission: "question", pattern: "*", action: "allow" }],
  550. system: ["You are a helpful assistant."],
  551. abort: new AbortController().signal,
  552. messages: [{ role: "user", content: "Hello" }],
  553. tools: {
  554. question: tool({
  555. description: "Ask a question",
  556. inputSchema: z.object({}),
  557. execute: async () => ({ output: "" }),
  558. }),
  559. },
  560. })
  561. for await (const _ of stream.fullStream) {
  562. }
  563. const capture = await request
  564. const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined
  565. expect(tools?.some((item) => item.function?.name === "question")).toBe(true)
  566. },
  567. })
  568. })
  569. test("sends responses API payload for OpenAI models", async () => {
  570. const server = state.server
  571. if (!server) {
  572. throw new Error("Server not initialized")
  573. }
  574. const source = await loadFixture("openai", "gpt-5.2")
  575. const model = source.model
  576. const responseChunks = [
  577. {
  578. type: "response.created",
  579. response: {
  580. id: "resp-1",
  581. created_at: Math.floor(Date.now() / 1000),
  582. model: model.id,
  583. service_tier: null,
  584. },
  585. },
  586. {
  587. type: "response.output_text.delta",
  588. item_id: "item-1",
  589. delta: "Hello",
  590. logprobs: null,
  591. },
  592. {
  593. type: "response.completed",
  594. response: {
  595. incomplete_details: null,
  596. usage: {
  597. input_tokens: 1,
  598. input_tokens_details: null,
  599. output_tokens: 1,
  600. output_tokens_details: null,
  601. },
  602. service_tier: null,
  603. },
  604. },
  605. ]
  606. const request = waitRequest("/responses", createEventResponse(responseChunks, true))
  607. await using tmp = await tmpdir({
  608. init: async (dir) => {
  609. await Bun.write(
  610. path.join(dir, "opencode.json"),
  611. JSON.stringify({
  612. $schema: "https://app.kilo.ai/config.json",
  613. enabled_providers: ["openai"],
  614. provider: {
  615. openai: {
  616. name: "OpenAI",
  617. env: ["OPENAI_API_KEY"],
  618. npm: "@ai-sdk/openai",
  619. api: "https://api.openai.com/v1",
  620. models: {
  621. [model.id]: model,
  622. },
  623. options: {
  624. apiKey: "test-openai-key",
  625. baseURL: `${server.url.origin}/v1`,
  626. },
  627. },
  628. },
  629. }),
  630. )
  631. },
  632. })
  633. await Instance.provide({
  634. directory: tmp.path,
  635. fn: async () => {
  636. const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
  637. const sessionID = SessionID.make("session-test-2")
  638. const agent = {
  639. name: "test",
  640. mode: "primary",
  641. options: {},
  642. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  643. temperature: 0.2,
  644. } satisfies Agent.Info
  645. const user = {
  646. id: MessageID.make("user-2"),
  647. sessionID,
  648. role: "user",
  649. time: { created: Date.now() },
  650. agent: agent.name,
  651. model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
  652. } satisfies MessageV2.User
  653. const stream = await LLM.stream({
  654. user,
  655. sessionID,
  656. model: resolved,
  657. agent,
  658. system: ["You are a helpful assistant."],
  659. abort: new AbortController().signal,
  660. messages: [{ role: "user", content: "Hello" }],
  661. tools: {},
  662. })
  663. for await (const _ of stream.fullStream) {
  664. }
  665. const capture = await request
  666. const body = capture.body
  667. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  668. expect(body.model).toBe(resolved.api.id)
  669. expect(body.stream).toBe(true)
  670. expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  671. const maxTokens = body.max_output_tokens as number | undefined
  672. expect(maxTokens).toBe(undefined) // match codex cli behavior
  673. },
  674. })
  675. })
  676. test("accepts user image attachments as data URLs for OpenAI models", async () => {
  677. const server = state.server
  678. if (!server) {
  679. throw new Error("Server not initialized")
  680. }
  681. const source = await loadFixture("openai", "gpt-5.2")
  682. const model = source.model
  683. const chunks = [
  684. {
  685. type: "response.created",
  686. response: {
  687. id: "resp-data-url",
  688. created_at: Math.floor(Date.now() / 1000),
  689. model: model.id,
  690. service_tier: null,
  691. },
  692. },
  693. {
  694. type: "response.output_text.delta",
  695. item_id: "item-data-url",
  696. delta: "Looks good",
  697. logprobs: null,
  698. },
  699. {
  700. type: "response.completed",
  701. response: {
  702. incomplete_details: null,
  703. usage: {
  704. input_tokens: 1,
  705. input_tokens_details: null,
  706. output_tokens: 1,
  707. output_tokens_details: null,
  708. },
  709. service_tier: null,
  710. },
  711. },
  712. ]
  713. const request = waitRequest("/responses", createEventResponse(chunks, true))
  714. const image = `data:image/png;base64,${Buffer.from(
  715. await Bun.file(path.join(import.meta.dir, "../tool/fixtures/large-image.png")).arrayBuffer(),
  716. ).toString("base64")}`
  717. await using tmp = await tmpdir({
  718. init: async (dir) => {
  719. await Bun.write(
  720. path.join(dir, "opencode.json"),
  721. JSON.stringify({
  722. $schema: "https://opencode.ai/config.json",
  723. enabled_providers: ["openai"],
  724. provider: {
  725. openai: {
  726. name: "OpenAI",
  727. env: ["OPENAI_API_KEY"],
  728. npm: "@ai-sdk/openai",
  729. api: "https://api.openai.com/v1",
  730. models: {
  731. [model.id]: model,
  732. },
  733. options: {
  734. apiKey: "test-openai-key",
  735. baseURL: `${server.url.origin}/v1`,
  736. },
  737. },
  738. },
  739. }),
  740. )
  741. },
  742. })
  743. await Instance.provide({
  744. directory: tmp.path,
  745. fn: async () => {
  746. const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id))
  747. const sessionID = SessionID.make("session-test-data-url")
  748. const agent = {
  749. name: "test",
  750. mode: "primary",
  751. options: {},
  752. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  753. } satisfies Agent.Info
  754. const user = {
  755. id: MessageID.make("user-data-url"),
  756. sessionID,
  757. role: "user",
  758. time: { created: Date.now() },
  759. agent: agent.name,
  760. model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
  761. } satisfies MessageV2.User
  762. const stream = await LLM.stream({
  763. user,
  764. sessionID,
  765. model: resolved,
  766. agent,
  767. system: ["You are a helpful assistant."],
  768. abort: new AbortController().signal,
  769. messages: [
  770. {
  771. role: "user",
  772. content: [
  773. { type: "text", text: "Describe this image" },
  774. {
  775. type: "file",
  776. mediaType: "image/png",
  777. filename: "large-image.png",
  778. data: image,
  779. },
  780. ],
  781. },
  782. ] as ModelMessage[],
  783. tools: {},
  784. })
  785. for await (const _ of stream.fullStream) {
  786. }
  787. const capture = await request
  788. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  789. },
  790. })
  791. })
  792. test("sends messages API payload for Anthropic Compatible models", async () => {
  793. const server = state.server
  794. if (!server) {
  795. throw new Error("Server not initialized")
  796. }
  797. const providerID = "minimax"
  798. const modelID = "MiniMax-M2.5"
  799. const fixture = await loadFixture(providerID, modelID)
  800. const model = fixture.model
  801. const chunks = [
  802. {
  803. type: "message_start",
  804. message: {
  805. id: "msg-1",
  806. model: model.id,
  807. usage: {
  808. input_tokens: 3,
  809. cache_creation_input_tokens: null,
  810. cache_read_input_tokens: null,
  811. },
  812. },
  813. },
  814. {
  815. type: "content_block_start",
  816. index: 0,
  817. content_block: { type: "text", text: "" },
  818. },
  819. {
  820. type: "content_block_delta",
  821. index: 0,
  822. delta: { type: "text_delta", text: "Hello" },
  823. },
  824. { type: "content_block_stop", index: 0 },
  825. {
  826. type: "message_delta",
  827. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  828. usage: {
  829. input_tokens: 3,
  830. output_tokens: 2,
  831. cache_creation_input_tokens: null,
  832. cache_read_input_tokens: null,
  833. },
  834. },
  835. { type: "message_stop" },
  836. ]
  837. const request = waitRequest("/messages", createEventResponse(chunks))
  838. await using tmp = await tmpdir({
  839. init: async (dir) => {
  840. await Bun.write(
  841. path.join(dir, "opencode.json"),
  842. JSON.stringify({
  843. $schema: "https://app.kilo.ai/config.json",
  844. enabled_providers: [providerID],
  845. provider: {
  846. [providerID]: {
  847. options: {
  848. apiKey: "test-anthropic-key",
  849. baseURL: `${server.url.origin}/v1`,
  850. },
  851. },
  852. },
  853. }),
  854. )
  855. },
  856. })
  857. await Instance.provide({
  858. directory: tmp.path,
  859. fn: async () => {
  860. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  861. const sessionID = SessionID.make("session-test-3")
  862. const agent = {
  863. name: "test",
  864. mode: "primary",
  865. options: {},
  866. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  867. temperature: 0.4,
  868. topP: 0.9,
  869. } satisfies Agent.Info
  870. const user = {
  871. id: MessageID.make("user-3"),
  872. sessionID,
  873. role: "user",
  874. time: { created: Date.now() },
  875. agent: agent.name,
  876. model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") },
  877. } satisfies MessageV2.User
  878. const stream = await LLM.stream({
  879. user,
  880. sessionID,
  881. model: resolved,
  882. agent,
  883. system: ["You are a helpful assistant."],
  884. abort: new AbortController().signal,
  885. messages: [{ role: "user", content: "Hello" }],
  886. tools: {},
  887. })
  888. for await (const _ of stream.fullStream) {
  889. }
  890. const capture = await request
  891. const body = capture.body
  892. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  893. expect(body.model).toBe(resolved.api.id)
  894. expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  895. expect(body.temperature).toBe(0.4)
  896. expect(body.top_p).toBe(0.9)
  897. },
  898. })
  899. })
  900. test("sends Google API payload for Gemini models", async () => {
  901. const server = state.server
  902. if (!server) {
  903. throw new Error("Server not initialized")
  904. }
  905. const providerID = "google"
  906. const modelID = "gemini-2.5-flash"
  907. const fixture = await loadFixture(providerID, modelID)
  908. const provider = fixture.provider
  909. const model = fixture.model
  910. const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
  911. const chunks = [
  912. {
  913. candidates: [
  914. {
  915. content: {
  916. parts: [{ text: "Hello" }],
  917. },
  918. finishReason: "STOP",
  919. },
  920. ],
  921. usageMetadata: {
  922. promptTokenCount: 1,
  923. candidatesTokenCount: 1,
  924. totalTokenCount: 2,
  925. },
  926. },
  927. ]
  928. const request = waitRequest(pathSuffix, createEventResponse(chunks))
  929. await using tmp = await tmpdir({
  930. init: async (dir) => {
  931. await Bun.write(
  932. path.join(dir, "opencode.json"),
  933. JSON.stringify({
  934. $schema: "https://app.kilo.ai/config.json",
  935. enabled_providers: [providerID],
  936. provider: {
  937. [providerID]: {
  938. options: {
  939. apiKey: "test-google-key",
  940. baseURL: `${server.url.origin}/v1beta`,
  941. },
  942. },
  943. },
  944. }),
  945. )
  946. },
  947. })
  948. await Instance.provide({
  949. directory: tmp.path,
  950. fn: async () => {
  951. const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id))
  952. const sessionID = SessionID.make("session-test-4")
  953. const agent = {
  954. name: "test",
  955. mode: "primary",
  956. options: {},
  957. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  958. temperature: 0.3,
  959. topP: 0.8,
  960. } satisfies Agent.Info
  961. const user = {
  962. id: MessageID.make("user-4"),
  963. sessionID,
  964. role: "user",
  965. time: { created: Date.now() },
  966. agent: agent.name,
  967. model: { providerID: ProviderID.make(providerID), modelID: resolved.id },
  968. } satisfies MessageV2.User
  969. const stream = await LLM.stream({
  970. user,
  971. sessionID,
  972. model: resolved,
  973. agent,
  974. system: ["You are a helpful assistant."],
  975. abort: new AbortController().signal,
  976. messages: [{ role: "user", content: "Hello" }],
  977. tools: {},
  978. })
  979. for await (const _ of stream.fullStream) {
  980. }
  981. const capture = await request
  982. const body = capture.body
  983. const config = body.generationConfig as
  984. | { temperature?: number; topP?: number; maxOutputTokens?: number }
  985. | undefined
  986. expect(capture.url.pathname).toBe(pathSuffix)
  987. expect(config?.temperature).toBe(0.3)
  988. expect(config?.topP).toBe(0.8)
  989. expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  990. },
  991. })
  992. })
  993. })