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 { tmpdir } from "../fixture/fixture"
  11. import type { Agent } from "../../src/agent/agent"
  12. import type { MessageV2 } from "../../src/session/message-v2"
  13. describe("session.llm.hasToolCalls", () => {
  14. test("returns false for empty messages array", () => {
  15. expect(LLM.hasToolCalls([])).toBe(false)
  16. })
  17. test("returns false for messages with only text content", () => {
  18. const messages: ModelMessage[] = [
  19. {
  20. role: "user",
  21. content: [{ type: "text", text: "Hello" }],
  22. },
  23. {
  24. role: "assistant",
  25. content: [{ type: "text", text: "Hi there" }],
  26. },
  27. ]
  28. expect(LLM.hasToolCalls(messages)).toBe(false)
  29. })
  30. test("returns true when messages contain tool-call", () => {
  31. const messages = [
  32. {
  33. role: "user",
  34. content: [{ type: "text", text: "Run a command" }],
  35. },
  36. {
  37. role: "assistant",
  38. content: [
  39. {
  40. type: "tool-call",
  41. toolCallId: "call-123",
  42. toolName: "bash",
  43. },
  44. ],
  45. },
  46. ] as ModelMessage[]
  47. expect(LLM.hasToolCalls(messages)).toBe(true)
  48. })
  49. test("returns true when messages contain tool-result", () => {
  50. const messages = [
  51. {
  52. role: "tool",
  53. content: [
  54. {
  55. type: "tool-result",
  56. toolCallId: "call-123",
  57. toolName: "bash",
  58. },
  59. ],
  60. },
  61. ] as ModelMessage[]
  62. expect(LLM.hasToolCalls(messages)).toBe(true)
  63. })
  64. test("returns false for messages with string content", () => {
  65. const messages: ModelMessage[] = [
  66. {
  67. role: "user",
  68. content: "Hello world",
  69. },
  70. {
  71. role: "assistant",
  72. content: "Hi there",
  73. },
  74. ]
  75. expect(LLM.hasToolCalls(messages)).toBe(false)
  76. })
  77. test("returns true when tool-call is mixed with text content", () => {
  78. const messages = [
  79. {
  80. role: "assistant",
  81. content: [
  82. { type: "text", text: "Let me run that command" },
  83. {
  84. type: "tool-call",
  85. toolCallId: "call-456",
  86. toolName: "read",
  87. },
  88. ],
  89. },
  90. ] as ModelMessage[]
  91. expect(LLM.hasToolCalls(messages)).toBe(true)
  92. })
  93. })
  94. type Capture = {
  95. url: URL
  96. headers: Headers
  97. body: Record<string, unknown>
  98. }
  99. const state = {
  100. server: null as ReturnType<typeof Bun.serve> | null,
  101. queue: [] as Array<{ path: string; response: Response; resolve: (value: Capture) => void }>,
  102. }
  103. function deferred<T>() {
  104. const result = {} as { promise: Promise<T>; resolve: (value: T) => void }
  105. result.promise = new Promise((resolve) => {
  106. result.resolve = resolve
  107. })
  108. return result
  109. }
  110. function waitRequest(pathname: string, response: Response) {
  111. const pending = deferred<Capture>()
  112. state.queue.push({ path: pathname, response, resolve: pending.resolve })
  113. return pending.promise
  114. }
  115. beforeAll(() => {
  116. state.server = Bun.serve({
  117. port: 0,
  118. async fetch(req) {
  119. const next = state.queue.shift()
  120. if (!next) {
  121. return new Response("unexpected request", { status: 500 })
  122. }
  123. const url = new URL(req.url)
  124. const body = (await req.json()) as Record<string, unknown>
  125. next.resolve({ url, headers: req.headers, body })
  126. if (!url.pathname.endsWith(next.path)) {
  127. return new Response("not found", { status: 404 })
  128. }
  129. return next.response
  130. },
  131. })
  132. })
  133. beforeEach(() => {
  134. state.queue.length = 0
  135. })
  136. afterAll(() => {
  137. state.server?.stop()
  138. })
  139. function createChatStream(text: string) {
  140. const payload =
  141. [
  142. `data: ${JSON.stringify({
  143. id: "chatcmpl-1",
  144. object: "chat.completion.chunk",
  145. choices: [{ delta: { role: "assistant" } }],
  146. })}`,
  147. `data: ${JSON.stringify({
  148. id: "chatcmpl-1",
  149. object: "chat.completion.chunk",
  150. choices: [{ delta: { content: text } }],
  151. })}`,
  152. `data: ${JSON.stringify({
  153. id: "chatcmpl-1",
  154. object: "chat.completion.chunk",
  155. choices: [{ delta: {}, finish_reason: "stop" }],
  156. })}`,
  157. "data: [DONE]",
  158. ].join("\n\n") + "\n\n"
  159. const encoder = new TextEncoder()
  160. return new ReadableStream<Uint8Array>({
  161. start(controller) {
  162. controller.enqueue(encoder.encode(payload))
  163. controller.close()
  164. },
  165. })
  166. }
  167. async function loadFixture(providerID: string, modelID: string) {
  168. const fixturePath = path.join(import.meta.dir, "../tool/fixtures/models-api.json")
  169. const data = (await Bun.file(fixturePath).json()) as Record<string, ModelsDev.Provider>
  170. const provider = data[providerID]
  171. if (!provider) {
  172. throw new Error(`Missing provider in fixture: ${providerID}`)
  173. }
  174. const model = provider.models[modelID]
  175. if (!model) {
  176. throw new Error(`Missing model in fixture: ${modelID}`)
  177. }
  178. return { provider, model }
  179. }
  180. function createEventStream(chunks: unknown[], includeDone = false) {
  181. const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
  182. if (includeDone) {
  183. lines.push("data: [DONE]")
  184. }
  185. const payload = lines.join("\n\n") + "\n\n"
  186. const encoder = new TextEncoder()
  187. return new ReadableStream<Uint8Array>({
  188. start(controller) {
  189. controller.enqueue(encoder.encode(payload))
  190. controller.close()
  191. },
  192. })
  193. }
  194. function createEventResponse(chunks: unknown[], includeDone = false) {
  195. return new Response(createEventStream(chunks, includeDone), {
  196. status: 200,
  197. headers: { "Content-Type": "text/event-stream" },
  198. })
  199. }
  200. describe("session.llm.stream", () => {
  201. test("sends temperature, tokens, and reasoning options for openai-compatible models", async () => {
  202. const server = state.server
  203. if (!server) {
  204. throw new Error("Server not initialized")
  205. }
  206. const providerID = "alibaba"
  207. const modelID = "qwen-plus"
  208. const fixture = await loadFixture(providerID, modelID)
  209. const provider = fixture.provider
  210. const model = fixture.model
  211. const request = waitRequest(
  212. "/chat/completions",
  213. new Response(createChatStream("Hello"), {
  214. status: 200,
  215. headers: { "Content-Type": "text/event-stream" },
  216. }),
  217. )
  218. await using tmp = await tmpdir({
  219. init: async (dir) => {
  220. await Bun.write(
  221. path.join(dir, "opencode.json"),
  222. JSON.stringify({
  223. $schema: "https://opencode.ai/config.json",
  224. enabled_providers: [providerID],
  225. provider: {
  226. [providerID]: {
  227. options: {
  228. apiKey: "test-key",
  229. baseURL: `${server.url.origin}/v1`,
  230. },
  231. },
  232. },
  233. }),
  234. )
  235. },
  236. })
  237. await Instance.provide({
  238. directory: tmp.path,
  239. fn: async () => {
  240. const resolved = await Provider.getModel(providerID, model.id)
  241. const sessionID = "session-test-1"
  242. const agent = {
  243. name: "test",
  244. mode: "primary",
  245. options: {},
  246. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  247. temperature: 0.4,
  248. topP: 0.8,
  249. } satisfies Agent.Info
  250. const user = {
  251. id: "user-1",
  252. sessionID,
  253. role: "user",
  254. time: { created: Date.now() },
  255. agent: agent.name,
  256. model: { providerID, modelID: resolved.id },
  257. variant: "high",
  258. } satisfies MessageV2.User
  259. const stream = await LLM.stream({
  260. user,
  261. sessionID,
  262. model: resolved,
  263. agent,
  264. system: ["You are a helpful assistant."],
  265. abort: new AbortController().signal,
  266. messages: [{ role: "user", content: "Hello" }],
  267. tools: {},
  268. })
  269. for await (const _ of stream.fullStream) {
  270. }
  271. const capture = await request
  272. const body = capture.body
  273. const headers = capture.headers
  274. const url = capture.url
  275. expect(url.pathname.startsWith("/v1/")).toBe(true)
  276. expect(url.pathname.endsWith("/chat/completions")).toBe(true)
  277. expect(headers.get("Authorization")).toBe("Bearer test-key")
  278. expect(headers.get("User-Agent") ?? "").toMatch(/^opencode\//)
  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. })