llm.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  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(
  285. resolved.api.npm,
  286. ProviderTransform.options({ model: resolved, sessionID }),
  287. resolved.limit.output,
  288. LLM.OUTPUT_TOKEN_MAX,
  289. )
  290. expect(maxTokens).toBe(expectedMaxTokens)
  291. const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
  292. expect(reasoning).toBe("high")
  293. },
  294. })
  295. })
  296. test("sends responses API payload for OpenAI models", async () => {
  297. const server = state.server
  298. if (!server) {
  299. throw new Error("Server not initialized")
  300. }
  301. const source = await loadFixture("openai", "gpt-5.2")
  302. const model = source.model
  303. const responseChunks = [
  304. {
  305. type: "response.created",
  306. response: {
  307. id: "resp-1",
  308. created_at: Math.floor(Date.now() / 1000),
  309. model: model.id,
  310. service_tier: null,
  311. },
  312. },
  313. {
  314. type: "response.output_text.delta",
  315. item_id: "item-1",
  316. delta: "Hello",
  317. logprobs: null,
  318. },
  319. {
  320. type: "response.completed",
  321. response: {
  322. incomplete_details: null,
  323. usage: {
  324. input_tokens: 1,
  325. input_tokens_details: null,
  326. output_tokens: 1,
  327. output_tokens_details: null,
  328. },
  329. service_tier: null,
  330. },
  331. },
  332. ]
  333. const request = waitRequest("/responses", createEventResponse(responseChunks, true))
  334. await using tmp = await tmpdir({
  335. init: async (dir) => {
  336. await Bun.write(
  337. path.join(dir, "opencode.json"),
  338. JSON.stringify({
  339. $schema: "https://opencode.ai/config.json",
  340. enabled_providers: ["openai"],
  341. provider: {
  342. openai: {
  343. name: "OpenAI",
  344. env: ["OPENAI_API_KEY"],
  345. npm: "@ai-sdk/openai",
  346. api: "https://api.openai.com/v1",
  347. models: {
  348. [model.id]: model,
  349. },
  350. options: {
  351. apiKey: "test-openai-key",
  352. baseURL: `${server.url.origin}/v1`,
  353. },
  354. },
  355. },
  356. }),
  357. )
  358. },
  359. })
  360. await Instance.provide({
  361. directory: tmp.path,
  362. fn: async () => {
  363. const resolved = await Provider.getModel("openai", model.id)
  364. const sessionID = "session-test-2"
  365. const agent = {
  366. name: "test",
  367. mode: "primary",
  368. options: {},
  369. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  370. temperature: 0.2,
  371. } satisfies Agent.Info
  372. const user = {
  373. id: "user-2",
  374. sessionID,
  375. role: "user",
  376. time: { created: Date.now() },
  377. agent: agent.name,
  378. model: { providerID: "openai", modelID: resolved.id },
  379. variant: "high",
  380. } satisfies MessageV2.User
  381. const stream = await LLM.stream({
  382. user,
  383. sessionID,
  384. model: resolved,
  385. agent,
  386. system: ["You are a helpful assistant."],
  387. abort: new AbortController().signal,
  388. messages: [{ role: "user", content: "Hello" }],
  389. tools: {},
  390. })
  391. for await (const _ of stream.fullStream) {
  392. }
  393. const capture = await request
  394. const body = capture.body
  395. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  396. expect(body.model).toBe(resolved.api.id)
  397. expect(body.stream).toBe(true)
  398. expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  399. const maxTokens = body.max_output_tokens as number | undefined
  400. const expectedMaxTokens = ProviderTransform.maxOutputTokens(
  401. resolved.api.npm,
  402. ProviderTransform.options({ model: resolved, sessionID }),
  403. resolved.limit.output,
  404. LLM.OUTPUT_TOKEN_MAX,
  405. )
  406. expect(maxTokens).toBe(expectedMaxTokens)
  407. },
  408. })
  409. })
  410. test("sends messages API payload for Anthropic models", async () => {
  411. const server = state.server
  412. if (!server) {
  413. throw new Error("Server not initialized")
  414. }
  415. const providerID = "anthropic"
  416. const modelID = "claude-3-5-sonnet-20241022"
  417. const fixture = await loadFixture(providerID, modelID)
  418. const provider = fixture.provider
  419. const model = fixture.model
  420. const chunks = [
  421. {
  422. type: "message_start",
  423. message: {
  424. id: "msg-1",
  425. model: model.id,
  426. usage: {
  427. input_tokens: 3,
  428. cache_creation_input_tokens: null,
  429. cache_read_input_tokens: null,
  430. },
  431. },
  432. },
  433. {
  434. type: "content_block_start",
  435. index: 0,
  436. content_block: { type: "text", text: "" },
  437. },
  438. {
  439. type: "content_block_delta",
  440. index: 0,
  441. delta: { type: "text_delta", text: "Hello" },
  442. },
  443. { type: "content_block_stop", index: 0 },
  444. {
  445. type: "message_delta",
  446. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  447. usage: {
  448. input_tokens: 3,
  449. output_tokens: 2,
  450. cache_creation_input_tokens: null,
  451. cache_read_input_tokens: null,
  452. },
  453. },
  454. { type: "message_stop" },
  455. ]
  456. const request = waitRequest("/messages", createEventResponse(chunks))
  457. await using tmp = await tmpdir({
  458. init: async (dir) => {
  459. await Bun.write(
  460. path.join(dir, "opencode.json"),
  461. JSON.stringify({
  462. $schema: "https://opencode.ai/config.json",
  463. enabled_providers: [providerID],
  464. provider: {
  465. [providerID]: {
  466. options: {
  467. apiKey: "test-anthropic-key",
  468. baseURL: `${server.url.origin}/v1`,
  469. },
  470. },
  471. },
  472. }),
  473. )
  474. },
  475. })
  476. await Instance.provide({
  477. directory: tmp.path,
  478. fn: async () => {
  479. const resolved = await Provider.getModel(providerID, model.id)
  480. const sessionID = "session-test-3"
  481. const agent = {
  482. name: "test",
  483. mode: "primary",
  484. options: {},
  485. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  486. temperature: 0.4,
  487. topP: 0.9,
  488. } satisfies Agent.Info
  489. const user = {
  490. id: "user-3",
  491. sessionID,
  492. role: "user",
  493. time: { created: Date.now() },
  494. agent: agent.name,
  495. model: { providerID, modelID: resolved.id },
  496. } satisfies MessageV2.User
  497. const stream = await LLM.stream({
  498. user,
  499. sessionID,
  500. model: resolved,
  501. agent,
  502. system: ["You are a helpful assistant."],
  503. abort: new AbortController().signal,
  504. messages: [{ role: "user", content: "Hello" }],
  505. tools: {},
  506. })
  507. for await (const _ of stream.fullStream) {
  508. }
  509. const capture = await request
  510. const body = capture.body
  511. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  512. expect(body.model).toBe(resolved.api.id)
  513. expect(body.max_tokens).toBe(
  514. ProviderTransform.maxOutputTokens(
  515. resolved.api.npm,
  516. ProviderTransform.options({ model: resolved, sessionID }),
  517. resolved.limit.output,
  518. LLM.OUTPUT_TOKEN_MAX,
  519. ),
  520. )
  521. expect(body.temperature).toBe(0.4)
  522. expect(body.top_p).toBe(0.9)
  523. },
  524. })
  525. })
  526. test("sends Google API payload for Gemini models", async () => {
  527. const server = state.server
  528. if (!server) {
  529. throw new Error("Server not initialized")
  530. }
  531. const providerID = "google"
  532. const modelID = "gemini-2.5-flash"
  533. const fixture = await loadFixture(providerID, modelID)
  534. const provider = fixture.provider
  535. const model = fixture.model
  536. const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
  537. const chunks = [
  538. {
  539. candidates: [
  540. {
  541. content: {
  542. parts: [{ text: "Hello" }],
  543. },
  544. finishReason: "STOP",
  545. },
  546. ],
  547. usageMetadata: {
  548. promptTokenCount: 1,
  549. candidatesTokenCount: 1,
  550. totalTokenCount: 2,
  551. },
  552. },
  553. ]
  554. const request = waitRequest(pathSuffix, createEventResponse(chunks))
  555. await using tmp = await tmpdir({
  556. init: async (dir) => {
  557. await Bun.write(
  558. path.join(dir, "opencode.json"),
  559. JSON.stringify({
  560. $schema: "https://opencode.ai/config.json",
  561. enabled_providers: [providerID],
  562. provider: {
  563. [providerID]: {
  564. options: {
  565. apiKey: "test-google-key",
  566. baseURL: `${server.url.origin}/v1beta`,
  567. },
  568. },
  569. },
  570. }),
  571. )
  572. },
  573. })
  574. await Instance.provide({
  575. directory: tmp.path,
  576. fn: async () => {
  577. const resolved = await Provider.getModel(providerID, model.id)
  578. const sessionID = "session-test-4"
  579. const agent = {
  580. name: "test",
  581. mode: "primary",
  582. options: {},
  583. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  584. temperature: 0.3,
  585. topP: 0.8,
  586. } satisfies Agent.Info
  587. const user = {
  588. id: "user-4",
  589. sessionID,
  590. role: "user",
  591. time: { created: Date.now() },
  592. agent: agent.name,
  593. model: { providerID, modelID: resolved.id },
  594. } satisfies MessageV2.User
  595. const stream = await LLM.stream({
  596. user,
  597. sessionID,
  598. model: resolved,
  599. agent,
  600. system: ["You are a helpful assistant."],
  601. abort: new AbortController().signal,
  602. messages: [{ role: "user", content: "Hello" }],
  603. tools: {},
  604. })
  605. for await (const _ of stream.fullStream) {
  606. }
  607. const capture = await request
  608. const body = capture.body
  609. const config = body.generationConfig as
  610. | { temperature?: number; topP?: number; maxOutputTokens?: number }
  611. | undefined
  612. expect(capture.url.pathname).toBe(pathSuffix)
  613. expect(config?.temperature).toBe(0.3)
  614. expect(config?.topP).toBe(0.8)
  615. expect(config?.maxOutputTokens).toBe(
  616. ProviderTransform.maxOutputTokens(
  617. resolved.api.npm,
  618. ProviderTransform.options({ model: resolved, sessionID }),
  619. resolved.limit.output,
  620. LLM.OUTPUT_TOKEN_MAX,
  621. ),
  622. )
  623. },
  624. })
  625. })
  626. })