llm.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705
  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. async function writeModels(models: Record<string, ModelsDev.Provider>) {
  181. const modelsPath = path.join(Global.Path.cache, "models.json")
  182. await Bun.write(modelsPath, JSON.stringify(models))
  183. ModelsDev.Data.reset()
  184. }
  185. function createEventStream(chunks: unknown[], includeDone = false) {
  186. const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
  187. if (includeDone) {
  188. lines.push("data: [DONE]")
  189. }
  190. const payload = lines.join("\n\n") + "\n\n"
  191. const encoder = new TextEncoder()
  192. return new ReadableStream<Uint8Array>({
  193. start(controller) {
  194. controller.enqueue(encoder.encode(payload))
  195. controller.close()
  196. },
  197. })
  198. }
  199. function createEventResponse(chunks: unknown[], includeDone = false) {
  200. return new Response(createEventStream(chunks, includeDone), {
  201. status: 200,
  202. headers: { "Content-Type": "text/event-stream" },
  203. })
  204. }
  205. describe("session.llm.stream", () => {
  206. test("sends temperature, tokens, and reasoning options for openai-compatible models", async () => {
  207. const server = state.server
  208. if (!server) {
  209. throw new Error("Server not initialized")
  210. }
  211. const providerID = "alibaba"
  212. const modelID = "qwen-plus"
  213. const fixture = await loadFixture(providerID, modelID)
  214. const provider = fixture.provider
  215. const model = fixture.model
  216. const request = waitRequest(
  217. "/chat/completions",
  218. new Response(createChatStream("Hello"), {
  219. status: 200,
  220. headers: { "Content-Type": "text/event-stream" },
  221. }),
  222. )
  223. await writeModels({ [providerID]: provider })
  224. await using tmp = await tmpdir({
  225. init: async (dir) => {
  226. await Bun.write(
  227. path.join(dir, "opencode.json"),
  228. JSON.stringify({
  229. $schema: "https://opencode.ai/config.json",
  230. enabled_providers: [providerID],
  231. provider: {
  232. [providerID]: {
  233. options: {
  234. apiKey: "test-key",
  235. baseURL: `${server.url.origin}/v1`,
  236. },
  237. },
  238. },
  239. }),
  240. )
  241. },
  242. })
  243. await Instance.provide({
  244. directory: tmp.path,
  245. fn: async () => {
  246. const resolved = await Provider.getModel(providerID, model.id)
  247. const sessionID = "session-test-1"
  248. const agent = {
  249. name: "test",
  250. mode: "primary",
  251. options: {},
  252. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  253. temperature: 0.4,
  254. topP: 0.8,
  255. } satisfies Agent.Info
  256. const user = {
  257. id: "user-1",
  258. sessionID,
  259. role: "user",
  260. time: { created: Date.now() },
  261. agent: agent.name,
  262. model: { providerID, modelID: resolved.id },
  263. variant: "high",
  264. } satisfies MessageV2.User
  265. const stream = await LLM.stream({
  266. user,
  267. sessionID,
  268. model: resolved,
  269. agent,
  270. system: ["You are a helpful assistant."],
  271. abort: new AbortController().signal,
  272. messages: [{ role: "user", content: "Hello" }],
  273. tools: {},
  274. })
  275. for await (const _ of stream.fullStream) {
  276. }
  277. const capture = await request
  278. const body = capture.body
  279. const headers = capture.headers
  280. const url = capture.url
  281. expect(url.pathname.startsWith("/v1/")).toBe(true)
  282. expect(url.pathname.endsWith("/chat/completions")).toBe(true)
  283. expect(headers.get("Authorization")).toBe("Bearer test-key")
  284. expect(headers.get("User-Agent") ?? "").toMatch(/^opencode\//)
  285. expect(body.model).toBe(resolved.api.id)
  286. expect(body.temperature).toBe(0.4)
  287. expect(body.top_p).toBe(0.8)
  288. expect(body.stream).toBe(true)
  289. const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
  290. const expectedMaxTokens = ProviderTransform.maxOutputTokens(
  291. resolved.api.npm,
  292. ProviderTransform.options({ model: resolved, sessionID }),
  293. resolved.limit.output,
  294. LLM.OUTPUT_TOKEN_MAX,
  295. )
  296. expect(maxTokens).toBe(expectedMaxTokens)
  297. const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
  298. expect(reasoning).toBe("high")
  299. },
  300. })
  301. })
  302. test("sends responses API payload for OpenAI models", async () => {
  303. const server = state.server
  304. if (!server) {
  305. throw new Error("Server not initialized")
  306. }
  307. const source = await loadFixture("github-copilot", "gpt-5.1")
  308. const model = source.model
  309. const responseChunks = [
  310. {
  311. type: "response.created",
  312. response: {
  313. id: "resp-1",
  314. created_at: Math.floor(Date.now() / 1000),
  315. model: model.id,
  316. service_tier: null,
  317. },
  318. },
  319. {
  320. type: "response.output_text.delta",
  321. item_id: "item-1",
  322. delta: "Hello",
  323. logprobs: null,
  324. },
  325. {
  326. type: "response.completed",
  327. response: {
  328. incomplete_details: null,
  329. usage: {
  330. input_tokens: 1,
  331. input_tokens_details: null,
  332. output_tokens: 1,
  333. output_tokens_details: null,
  334. },
  335. service_tier: null,
  336. },
  337. },
  338. ]
  339. const request = waitRequest("/responses", createEventResponse(responseChunks, true))
  340. await writeModels({})
  341. await using tmp = await tmpdir({
  342. init: async (dir) => {
  343. await Bun.write(
  344. path.join(dir, "opencode.json"),
  345. JSON.stringify({
  346. $schema: "https://opencode.ai/config.json",
  347. enabled_providers: ["openai"],
  348. provider: {
  349. openai: {
  350. name: "OpenAI",
  351. env: ["OPENAI_API_KEY"],
  352. npm: "@ai-sdk/openai",
  353. api: "https://api.openai.com/v1",
  354. models: {
  355. [model.id]: model,
  356. },
  357. options: {
  358. apiKey: "test-openai-key",
  359. baseURL: `${server.url.origin}/v1`,
  360. },
  361. },
  362. },
  363. }),
  364. )
  365. },
  366. })
  367. await Instance.provide({
  368. directory: tmp.path,
  369. fn: async () => {
  370. const resolved = await Provider.getModel("openai", model.id)
  371. const sessionID = "session-test-2"
  372. const agent = {
  373. name: "test",
  374. mode: "primary",
  375. options: {},
  376. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  377. temperature: 0.2,
  378. } satisfies Agent.Info
  379. const user = {
  380. id: "user-2",
  381. sessionID,
  382. role: "user",
  383. time: { created: Date.now() },
  384. agent: agent.name,
  385. model: { providerID: "openai", modelID: resolved.id },
  386. variant: "high",
  387. } satisfies MessageV2.User
  388. const stream = await LLM.stream({
  389. user,
  390. sessionID,
  391. model: resolved,
  392. agent,
  393. system: ["You are a helpful assistant."],
  394. abort: new AbortController().signal,
  395. messages: [{ role: "user", content: "Hello" }],
  396. tools: {},
  397. })
  398. for await (const _ of stream.fullStream) {
  399. }
  400. const capture = await request
  401. const body = capture.body
  402. expect(capture.url.pathname.endsWith("/responses")).toBe(true)
  403. expect(body.model).toBe(resolved.api.id)
  404. expect(body.stream).toBe(true)
  405. expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
  406. const maxTokens = body.max_output_tokens as number | undefined
  407. const expectedMaxTokens = ProviderTransform.maxOutputTokens(
  408. resolved.api.npm,
  409. ProviderTransform.options({ model: resolved, sessionID }),
  410. resolved.limit.output,
  411. LLM.OUTPUT_TOKEN_MAX,
  412. )
  413. expect(maxTokens).toBe(expectedMaxTokens)
  414. },
  415. })
  416. })
  417. test("sends messages API payload for Anthropic models", async () => {
  418. const server = state.server
  419. if (!server) {
  420. throw new Error("Server not initialized")
  421. }
  422. const providerID = "anthropic"
  423. const modelID = "claude-3-5-sonnet-20241022"
  424. const fixture = await loadFixture(providerID, modelID)
  425. const provider = fixture.provider
  426. const model = fixture.model
  427. const chunks = [
  428. {
  429. type: "message_start",
  430. message: {
  431. id: "msg-1",
  432. model: model.id,
  433. usage: {
  434. input_tokens: 3,
  435. cache_creation_input_tokens: null,
  436. cache_read_input_tokens: null,
  437. },
  438. },
  439. },
  440. {
  441. type: "content_block_start",
  442. index: 0,
  443. content_block: { type: "text", text: "" },
  444. },
  445. {
  446. type: "content_block_delta",
  447. index: 0,
  448. delta: { type: "text_delta", text: "Hello" },
  449. },
  450. { type: "content_block_stop", index: 0 },
  451. {
  452. type: "message_delta",
  453. delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
  454. usage: {
  455. input_tokens: 3,
  456. output_tokens: 2,
  457. cache_creation_input_tokens: null,
  458. cache_read_input_tokens: null,
  459. },
  460. },
  461. { type: "message_stop" },
  462. ]
  463. const request = waitRequest("/messages", createEventResponse(chunks))
  464. await writeModels({ [providerID]: provider })
  465. await using tmp = await tmpdir({
  466. init: async (dir) => {
  467. await Bun.write(
  468. path.join(dir, "opencode.json"),
  469. JSON.stringify({
  470. $schema: "https://opencode.ai/config.json",
  471. enabled_providers: [providerID],
  472. provider: {
  473. [providerID]: {
  474. options: {
  475. apiKey: "test-anthropic-key",
  476. baseURL: `${server.url.origin}/v1`,
  477. },
  478. },
  479. },
  480. }),
  481. )
  482. },
  483. })
  484. await Instance.provide({
  485. directory: tmp.path,
  486. fn: async () => {
  487. const resolved = await Provider.getModel(providerID, model.id)
  488. const sessionID = "session-test-3"
  489. const agent = {
  490. name: "test",
  491. mode: "primary",
  492. options: {},
  493. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  494. temperature: 0.4,
  495. topP: 0.9,
  496. } satisfies Agent.Info
  497. const user = {
  498. id: "user-3",
  499. sessionID,
  500. role: "user",
  501. time: { created: Date.now() },
  502. agent: agent.name,
  503. model: { providerID, modelID: resolved.id },
  504. } satisfies MessageV2.User
  505. const stream = await LLM.stream({
  506. user,
  507. sessionID,
  508. model: resolved,
  509. agent,
  510. system: ["You are a helpful assistant."],
  511. abort: new AbortController().signal,
  512. messages: [{ role: "user", content: "Hello" }],
  513. tools: {},
  514. })
  515. for await (const _ of stream.fullStream) {
  516. }
  517. const capture = await request
  518. const body = capture.body
  519. expect(capture.url.pathname.endsWith("/messages")).toBe(true)
  520. expect(body.model).toBe(resolved.api.id)
  521. expect(body.max_tokens).toBe(
  522. ProviderTransform.maxOutputTokens(
  523. resolved.api.npm,
  524. ProviderTransform.options({ model: resolved, sessionID }),
  525. resolved.limit.output,
  526. LLM.OUTPUT_TOKEN_MAX,
  527. ),
  528. )
  529. expect(body.temperature).toBe(0.4)
  530. expect(body.top_p).toBe(0.9)
  531. },
  532. })
  533. })
  534. test("sends Google API payload for Gemini models", async () => {
  535. const server = state.server
  536. if (!server) {
  537. throw new Error("Server not initialized")
  538. }
  539. const providerID = "google"
  540. const modelID = "gemini-2.5-flash"
  541. const fixture = await loadFixture(providerID, modelID)
  542. const provider = fixture.provider
  543. const model = fixture.model
  544. const pathSuffix = `/v1beta/models/${model.id}:streamGenerateContent`
  545. const chunks = [
  546. {
  547. candidates: [
  548. {
  549. content: {
  550. parts: [{ text: "Hello" }],
  551. },
  552. finishReason: "STOP",
  553. },
  554. ],
  555. usageMetadata: {
  556. promptTokenCount: 1,
  557. candidatesTokenCount: 1,
  558. totalTokenCount: 2,
  559. },
  560. },
  561. ]
  562. const request = waitRequest(pathSuffix, createEventResponse(chunks))
  563. await writeModels({ [providerID]: provider })
  564. await using tmp = await tmpdir({
  565. init: async (dir) => {
  566. await Bun.write(
  567. path.join(dir, "opencode.json"),
  568. JSON.stringify({
  569. $schema: "https://opencode.ai/config.json",
  570. enabled_providers: [providerID],
  571. provider: {
  572. [providerID]: {
  573. options: {
  574. apiKey: "test-google-key",
  575. baseURL: `${server.url.origin}/v1beta`,
  576. },
  577. },
  578. },
  579. }),
  580. )
  581. },
  582. })
  583. await Instance.provide({
  584. directory: tmp.path,
  585. fn: async () => {
  586. const resolved = await Provider.getModel(providerID, model.id)
  587. const sessionID = "session-test-4"
  588. const agent = {
  589. name: "test",
  590. mode: "primary",
  591. options: {},
  592. permission: [{ permission: "*", pattern: "*", action: "allow" }],
  593. temperature: 0.3,
  594. topP: 0.8,
  595. } satisfies Agent.Info
  596. const user = {
  597. id: "user-4",
  598. sessionID,
  599. role: "user",
  600. time: { created: Date.now() },
  601. agent: agent.name,
  602. model: { providerID, modelID: resolved.id },
  603. } satisfies MessageV2.User
  604. const stream = await LLM.stream({
  605. user,
  606. sessionID,
  607. model: resolved,
  608. agent,
  609. system: ["You are a helpful assistant."],
  610. abort: new AbortController().signal,
  611. messages: [{ role: "user", content: "Hello" }],
  612. tools: {},
  613. })
  614. for await (const _ of stream.fullStream) {
  615. }
  616. const capture = await request
  617. const body = capture.body
  618. const config = body.generationConfig as
  619. | { temperature?: number; topP?: number; maxOutputTokens?: number }
  620. | undefined
  621. expect(capture.url.pathname).toBe(pathSuffix)
  622. expect(config?.temperature).toBe(0.3)
  623. expect(config?.topP).toBe(0.8)
  624. expect(config?.maxOutputTokens).toBe(
  625. ProviderTransform.maxOutputTokens(
  626. resolved.api.npm,
  627. ProviderTransform.options({ model: resolved, sessionID }),
  628. resolved.limit.output,
  629. LLM.OUTPUT_TOKEN_MAX,
  630. ),
  631. )
  632. },
  633. })
  634. })
  635. })