llm.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
  2. import path from "path"
  3. import type { ModelMessage } from "ai"
  4. import { LLM } from "../../src/session/llm"
  5. import { Global } from "../../src/global"
  6. import { Instance } from "../../src/project/instance"
  7. import { Provider } from "../../src/provider/provider"
  8. import { ProviderTransform } from "../../src/provider/transform"
  9. import { ModelsDev } from "../../src/provider/models"
  10. import { Filesystem } from "../../src/util/filesystem"
  11. import { tmpdir } from "../fixture/fixture"
  12. import type { Agent } from "../../src/agent/agent"
  13. import type { MessageV2 } from "../../src/session/message-v2"
  14. describe("session.llm.hasToolCalls", () => {
  15. test("returns false for empty messages array", () => {
  16. expect(LLM.hasToolCalls([])).toBe(false)
  17. })
  18. test("returns false for messages with only text content", () => {
  19. const messages: ModelMessage[] = [
  20. {
  21. role: "user",
  22. content: [{ type: "text", text: "Hello" }],
  23. },
  24. {
  25. role: "assistant",
  26. content: [{ type: "text", text: "Hi there" }],
  27. },
  28. ]
  29. expect(LLM.hasToolCalls(messages)).toBe(false)
  30. })
  31. test("returns true when messages contain tool-call", () => {
  32. const messages = [
  33. {
  34. role: "user",
  35. content: [{ type: "text", text: "Run a command" }],
  36. },
  37. {
  38. role: "assistant",
  39. content: [
  40. {
  41. type: "tool-call",
  42. toolCallId: "call-123",
  43. toolName: "bash",
  44. },
  45. ],
  46. },
  47. ] as ModelMessage[]
  48. expect(LLM.hasToolCalls(messages)).toBe(true)
  49. })
  50. test("returns true when messages contain tool-result", () => {
  51. const messages = [
  52. {
  53. role: "tool",
  54. content: [
  55. {
  56. type: "tool-result",
  57. toolCallId: "call-123",
  58. toolName: "bash",
  59. },
  60. ],
  61. },
  62. ] as ModelMessage[]
  63. expect(LLM.hasToolCalls(messages)).toBe(true)
  64. })
  65. test("returns false for messages with string content", () => {
  66. const messages: ModelMessage[] = [
  67. {
  68. role: "user",
  69. content: "Hello world",
  70. },
  71. {
  72. role: "assistant",
  73. content: "Hi there",
  74. },
  75. ]
  76. expect(LLM.hasToolCalls(messages)).toBe(false)
  77. })
  78. test("returns true when tool-call is mixed with text content", () => {
  79. const messages = [
  80. {
  81. role: "assistant",
  82. content: [
  83. { type: "text", text: "Let me run that command" },
  84. {
  85. type: "tool-call",
  86. toolCallId: "call-456",
  87. toolName: "read",
  88. },
  89. ],
  90. },
  91. ] as ModelMessage[]
  92. expect(LLM.hasToolCalls(messages)).toBe(true)
  93. })
  94. })
  95. type Capture = {
  96. url: URL
  97. headers: Headers
  98. body: Record<string, unknown>
  99. }
  100. const state = {
  101. server: null as ReturnType<typeof Bun.serve> | null,
  102. queue: [] as Array<{ path: string; response: Response; resolve: (value: Capture) => void }>,
  103. }
  104. function deferred<T>() {
  105. const result = {} as { promise: Promise<T>; resolve: (value: T) => void }
  106. result.promise = new Promise((resolve) => {
  107. result.resolve = resolve
  108. })
  109. return result
  110. }
  111. function waitRequest(pathname: string, response: Response) {
  112. const pending = deferred<Capture>()
  113. state.queue.push({ path: pathname, response, resolve: pending.resolve })
  114. return pending.promise
  115. }
  116. beforeAll(() => {
  117. state.server = Bun.serve({
  118. port: 0,
  119. async fetch(req) {
  120. const next = state.queue.shift()
  121. if (!next) {
  122. return new Response("unexpected request", { status: 500 })
  123. }
  124. const url = new URL(req.url)
  125. const body = (await req.json()) as Record<string, unknown>
  126. next.resolve({ url, headers: req.headers, body })
  127. if (!url.pathname.endsWith(next.path)) {
  128. return new Response("not found", { status: 404 })
  129. }
  130. return next.response
  131. },
  132. })
  133. })
  134. beforeEach(() => {
  135. state.queue.length = 0
  136. })
  137. afterAll(() => {
  138. state.server?.stop()
  139. })
  140. function createChatStream(text: string) {
  141. const payload =
  142. [
  143. `data: ${JSON.stringify({
  144. id: "chatcmpl-1",
  145. object: "chat.completion.chunk",
  146. choices: [{ delta: { role: "assistant" } }],
  147. })}`,
  148. `data: ${JSON.stringify({
  149. id: "chatcmpl-1",
  150. object: "chat.completion.chunk",
  151. choices: [{ delta: { content: text } }],
  152. })}`,
  153. `data: ${JSON.stringify({
  154. id: "chatcmpl-1",
  155. object: "chat.completion.chunk",
  156. choices: [{ delta: {}, finish_reason: "stop" }],
  157. })}`,
  158. "data: [DONE]",
  159. ].join("\n\n") + "\n\n"
  160. const encoder = new TextEncoder()
  161. return new ReadableStream<Uint8Array>({
  162. start(controller) {
  163. controller.enqueue(encoder.encode(payload))
  164. controller.close()
  165. },
  166. })
  167. }
  168. async function loadFixture(providerID: string, modelID: string) {
  169. const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json")
  170. const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(fixturePath)
  171. const provider = data[providerID]
  172. if (!provider) {
  173. throw new Error(`Missing provider in fixture: ${providerID}`)
  174. }
  175. const model = provider.models[modelID]
  176. if (!model) {
  177. throw new Error(`Missing model in fixture: ${modelID}`)
  178. }
  179. return { provider, model }
  180. }
  181. function createEventStream(chunks: unknown[], includeDone = false) {
  182. const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
  183. if (includeDone) {
  184. lines.push("data: [DONE]")
  185. }
  186. const payload = lines.join("\n\n") + "\n\n"
  187. const encoder = new TextEncoder()
  188. return new ReadableStream<Uint8Array>({
  189. start(controller) {
  190. controller.enqueue(encoder.encode(payload))
  191. controller.close()
  192. },
  193. })
  194. }
  195. function createEventResponse(chunks: unknown[], includeDone = false) {
  196. return new Response(createEventStream(chunks, includeDone), {
  197. status: 200,
  198. headers: { "Content-Type": "text/event-stream" },
  199. })
  200. }
  201. describe("session.llm.stream", () => {
  202. test("sends temperature, tokens, and reasoning options for openai-compatible models", async () => {
  203. const server = state.server
  204. if (!server) {
  205. throw new Error("Server not initialized")
  206. }
  207. const providerID = "alibaba"
  208. const modelID = "qwen-plus"
  209. const fixture = await loadFixture(providerID, modelID)
  210. const provider = fixture.provider
  211. const model = fixture.model
  212. const request = waitRequest(
  213. "/chat/completions",
  214. new Response(createChatStream("Hello"), {
  215. status: 200,
  216. headers: { "Content-Type": "text/event-stream" },
  217. }),
  218. )
  219. await using tmp = await tmpdir({
  220. init: async (dir) => {
  221. await Bun.write(
  222. path.join(dir, "opencode.json"),
  223. JSON.stringify({
  224. $schema: "https://opencode.ai/config.json",
  225. enabled_providers: [providerID],
  226. provider: {
  227. [providerID]: {
  228. options: {
  229. apiKey: "test-key",
  230. baseURL: `${server.url.origin}/v1`,
  231. },
  232. },
  233. },
  234. }),
  235. )
  236. },
  237. })
  238. await Instance.provide({
  239. directory: tmp.path,
  240. fn: async () => {
  241. const resolved = await Provider.getModel(providerID, model.id)
  242. const sessionID = "session-test-1"
  243. const agent = {
  244. name: "test",
  245. mode: "primary",
  246. options: {},
  247. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  248. temperature: 0.4,
  249. topP: 0.8,
  250. } satisfies Agent.Info
  251. const user = {
  252. id: "user-1",
  253. sessionID,
  254. role: "user",
  255. time: { created: Date.now() },
  256. agent: agent.name,
  257. model: { providerID, modelID: resolved.id },
  258. variant: "high",
  259. } satisfies MessageV2.User
  260. const stream = await LLM.stream({
  261. user,
  262. sessionID,
  263. model: resolved,
  264. agent,
  265. system: ["You are a helpful assistant."],
  266. abort: new AbortController().signal,
  267. messages: [{ role: "user", content: "Hello" }],
  268. tools: {},
  269. })
  270. for await (const _ of stream.fullStream) {
  271. }
  272. const capture = await request
  273. const body = capture.body
  274. const headers = capture.headers
  275. const url = capture.url
  276. expect(url.pathname.startsWith("/v1/")).toBe(true)
  277. expect(url.pathname.endsWith("/chat/completions")).toBe(true)
  278. expect(headers.get("Authorization")).toBe("Bearer test-key")
  279. expect(body.model).toBe(resolved.api.id)
  280. expect(body.temperature).toBe(0.4)
  281. expect(body.top_p).toBe(0.8)
  282. expect(body.stream).toBe(true)
  283. const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
  284. const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
  285. expect(maxTokens).toBe(expectedMaxTokens)
  286. const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
  287. expect(reasoning).toBe("high")
  288. },
  289. })
  290. })
  291. test("sends responses API payload for OpenAI models", async () => {
  292. const server = state.server
  293. if (!server) {
  294. throw new Error("Server not initialized")
  295. }
  296. const source = await loadFixture("openai", "gpt-5.2")
  297. const model = source.model
  298. const responseChunks = [
  299. {
  300. type: "response.created",
  301. response: {
  302. id: "resp-1",
  303. created_at: Math.floor(Date.now() / 1000),
  304. model: model.id,
  305. service_tier: null,
  306. },
  307. },
  308. {
  309. type: "response.output_text.delta",
  310. item_id: "item-1",
  311. delta: "Hello",
  312. logprobs: null,
  313. },
  314. {
  315. type: "response.completed",
  316. response: {
  317. incomplete_details: null,
  318. usage: {
  319. input_tokens: 1,
  320. input_tokens_details: null,
  321. output_tokens: 1,
  322. output_tokens_details: null,
  323. },
  324. service_tier: null,
  325. },
  326. },
  327. ]
  328. const request = waitRequest("/responses", createEventResponse(responseChunks, true))
  329. await using tmp = await tmpdir({
  330. init: async (dir) => {
  331. await Bun.write(
  332. path.join(dir, "opencode.json"),
  333. JSON.stringify({
  334. $schema: "https://opencode.ai/config.json",
  335. enabled_providers: ["openai"],
  336. provider: {
  337. openai: {
  338. name: "OpenAI",
  339. env: ["OPENAI_API_KEY"],
  340. npm: "@ai-sdk/openai",
  341. api: "https://api.openai.com/v1",
  342. models: {
  343. [model.id]: model,
  344. },
  345. options: {
  346. apiKey: "test-openai-key",
  347. baseURL: `${server.url.origin}/v1`,
  348. },
  349. },
  350. },
  351. }),
  352. )
  353. },
  354. })
  355. await Instance.provide({
  356. directory: tmp.path,
  357. fn: async () => {
  358. const resolved = await Provider.getModel("openai", model.id)
  359. const sessionID = "session-test-2"
  360. const agent = {
  361. name: "test",
  362. mode: "primary",
  363. options: {},
  364. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  365. temperature: 0.2,
  366. } satisfies Agent.Info
  367. const user = {
  368. id: "user-2",
  369. sessionID,
  370. role: "user",
  371. time: { created: Date.now() },
  372. agent: agent.name,
  373. model: { providerID: "openai", modelID: resolved.id },
  374. variant: "high",
  375. } satisfies MessageV2.User
  376. const stream = await LLM.stream({
  377. user,
  378. sessionID,
  379. model: resolved,
  380. agent,
  381. system: ["You are a helpful assistant."],
  382. abort: new AbortController().signal,
  383. messages: [{ role: "user", content: "Hello" }],
  384. tools: {},
  385. })
  386. for await (const _ of stream.fullStream) {
  387. }
  388. const capture = await request
  389. const body = capture.body
  390. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  391. expect(body.model).toBe(resolved.api.id)
  392. expect(body.stream).toBe(true)
  393. expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  394. const maxTokens = body.max_output_tokens as number | undefined
  395. const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
  396. expect(maxTokens).toBe(expectedMaxTokens)
  397. },
  398. })
  399. })
  400. test("sends messages API payload for Anthropic models", async () => {
  401. const server = state.server
  402. if (!server) {
  403. throw new Error("Server not initialized")
  404. }
  405. const providerID = "anthropic"
  406. const modelID = "claude-3-5-sonnet-20241022"
  407. const fixture = await loadFixture(providerID, modelID)
  408. const provider = fixture.provider
  409. const model = fixture.model
  410. const chunks = [
  411. {
  412. type: "message_start",
  413. message: {
  414. id: "msg-1",
  415. model: model.id,
  416. usage: {
  417. input_tokens: 3,
  418. cache_creation_input_tokens: null,
  419. cache_read_input_tokens: null,
  420. },
  421. },
  422. },
  423. {
  424. type: "content_block_start",
  425. index: 0,
  426. content_block: { type: "text", text: "" },
  427. },
  428. {
  429. type: "content_block_delta",
  430. index: 0,
  431. delta: { type: "text_delta", text: "Hello" },
  432. },
  433. { type: "content_block_stop", index: 0 },
  434. {
  435. type: "message_delta",
  436. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  437. usage: {
  438. input_tokens: 3,
  439. output_tokens: 2,
  440. cache_creation_input_tokens: null,
  441. cache_read_input_tokens: null,
  442. },
  443. },
  444. { type: "message_stop" },
  445. ]
  446. const request = waitRequest("/messages", createEventResponse(chunks))
  447. await using tmp = await tmpdir({
  448. init: async (dir) => {
  449. await Bun.write(
  450. path.join(dir, "opencode.json"),
  451. JSON.stringify({
  452. $schema: "https://opencode.ai/config.json",
  453. enabled_providers: [providerID],
  454. provider: {
  455. [providerID]: {
  456. options: {
  457. apiKey: "test-anthropic-key",
  458. baseURL: `${server.url.origin}/v1`,
  459. },
  460. },
  461. },
  462. }),
  463. )
  464. },
  465. })
  466. await Instance.provide({
  467. directory: tmp.path,
  468. fn: async () => {
  469. const resolved = await Provider.getModel(providerID, model.id)
  470. const sessionID = "session-test-3"
  471. const agent = {
  472. name: "test",
  473. mode: "primary",
  474. options: {},
  475. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  476. temperature: 0.4,
  477. topP: 0.9,
  478. } satisfies Agent.Info
  479. const user = {
  480. id: "user-3",
  481. sessionID,
  482. role: "user",
  483. time: { created: Date.now() },
  484. agent: agent.name,
  485. model: { providerID, modelID: resolved.id },
  486. } satisfies MessageV2.User
  487. const stream = await LLM.stream({
  488. user,
  489. sessionID,
  490. model: resolved,
  491. agent,
  492. system: ["You are a helpful assistant."],
  493. abort: new AbortController().signal,
  494. messages: [{ role: "user", content: "Hello" }],
  495. tools: {},
  496. })
  497. for await (const _ of stream.fullStream) {
  498. }
  499. const capture = await request
  500. const body = capture.body
  501. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  502. expect(body.model).toBe(resolved.api.id)
  503. expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  504. expect(body.temperature).toBe(0.4)
  505. expect(body.top_p).toBe(0.9)
  506. },
  507. })
  508. })
  509. test("sends Google API payload for Gemini models", async () => {
  510. const server = state.server
  511. if (!server) {
  512. throw new Error("Server not initialized")
  513. }
  514. const providerID = "google"
  515. const modelID = "gemini-2.5-flash"
  516. const fixture = await loadFixture(providerID, modelID)
  517. const provider = fixture.provider
  518. const model = fixture.model
  519. const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
  520. const chunks = [
  521. {
  522. candidates: [
  523. {
  524. content: {
  525. parts: [{ text: "Hello" }],
  526. },
  527. finishReason: "STOP",
  528. },
  529. ],
  530. usageMetadata: {
  531. promptTokenCount: 1,
  532. candidatesTokenCount: 1,
  533. totalTokenCount: 2,
  534. },
  535. },
  536. ]
  537. const request = waitRequest(pathSuffix, createEventResponse(chunks))
  538. await using tmp = await tmpdir({
  539. init: async (dir) => {
  540. await Bun.write(
  541. path.join(dir, "opencode.json"),
  542. JSON.stringify({
  543. $schema: "https://opencode.ai/config.json",
  544. enabled_providers: [providerID],
  545. provider: {
  546. [providerID]: {
  547. options: {
  548. apiKey: "test-google-key",
  549. baseURL: `${server.url.origin}/v1beta`,
  550. },
  551. },
  552. },
  553. }),
  554. )
  555. },
  556. })
  557. await Instance.provide({
  558. directory: tmp.path,
  559. fn: async () => {
  560. const resolved = await Provider.getModel(providerID, model.id)
  561. const sessionID = "session-test-4"
  562. const agent = {
  563. name: "test",
  564. mode: "primary",
  565. options: {},
  566. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  567. temperature: 0.3,
  568. topP: 0.8,
  569. } satisfies Agent.Info
  570. const user = {
  571. id: "user-4",
  572. sessionID,
  573. role: "user",
  574. time: { created: Date.now() },
  575. agent: agent.name,
  576. model: { providerID, modelID: resolved.id },
  577. } satisfies MessageV2.User
  578. const stream = await LLM.stream({
  579. user,
  580. sessionID,
  581. model: resolved,
  582. agent,
  583. system: ["You are a helpful assistant."],
  584. abort: new AbortController().signal,
  585. messages: [{ role: "user", content: "Hello" }],
  586. tools: {},
  587. })
  588. for await (const _ of stream.fullStream) {
  589. }
  590. const capture = await request
  591. const body = capture.body
  592. const config = body.generationConfig as
  593. | { temperature?: number; topP?: number; maxOutputTokens?: number }
  594. | undefined
  595. expect(capture.url.pathname).toBe(pathSuffix)
  596. expect(config?.temperature).toBe(0.3)
  597. expect(config?.topP).toBe(0.8)
  598. expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
  599. },
  600. })
  601. })
  602. })