llm-client.usage.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. import { beforeEach, describe, expect, it, vi } from "vitest"
  2. import type { LlmConfig } from "@/stores/wiki-store"
  3. import { streamChat } from "./llm-client"
  4. import { estimateChatMessagesTokens } from "./chat-request-budget"
  5. import type { ChatMessage } from "./llm-providers"
  6. import { LlmContextBudgetError } from "./context-budget"
  7. const mocks = vi.hoisted(() => ({
  8. fetch: vi.fn(),
  9. }))
  10. vi.mock("./tauri-fetch", () => ({
  11. getHttpFetch: vi.fn(async () => mocks.fetch),
  12. isFetchNetworkError: vi.fn(() => false),
  13. }))
  14. vi.mock("./local-cli-config", () => ({
  15. resolveRuntimeLocalCliConfig: vi.fn(async (config: LlmConfig) => config),
  16. }))
  17. const config: LlmConfig = {
  18. provider: "openai",
  19. apiKey: "sk-test",
  20. model: "gpt-test",
  21. ollamaUrl: "",
  22. customEndpoint: "",
  23. maxContextSize: 128_000,
  24. }
  25. describe("streamChat usage", () => {
  26. beforeEach(() => {
  27. mocks.fetch.mockReset()
  28. })
  29. it("requests and emits OpenAI stream usage once", async () => {
  30. const encoder = new TextEncoder()
  31. const body = new ReadableStream<Uint8Array>({
  32. start(controller) {
  33. controller.enqueue(encoder.encode([
  34. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  35. 'data: {"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":80,"total_tokens":1280,"prompt_tokens_details":{"cached_tokens":1024}}}',
  36. "data: [DONE]",
  37. "",
  38. ].join("\n")))
  39. controller.close()
  40. },
  41. })
  42. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  43. const onUsage = vi.fn()
  44. const onDone = vi.fn()
  45. const onError = vi.fn()
  46. await streamChat(config, [{ role: "user", content: "测试" }], {
  47. onToken: vi.fn(),
  48. onUsage,
  49. onDone,
  50. onError,
  51. })
  52. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  53. expect(JSON.parse(String(request.body))).toMatchObject({
  54. stream: true,
  55. stream_options: { include_usage: true },
  56. })
  57. expect(onUsage).toHaveBeenCalledOnce()
  58. expect(onUsage).toHaveBeenCalledWith({
  59. inputTokens: 1200,
  60. outputTokens: 80,
  61. totalTokens: 1280,
  62. cachedInputTokens: 1024,
  63. })
  64. expect(onDone).toHaveBeenCalledOnce()
  65. expect(onError).not.toHaveBeenCalled()
  66. })
  67. it("同行 tool_calls 仍触发 onReasoningToken", async () => {
  68. const encoder = new TextEncoder()
  69. const body = new ReadableStream<Uint8Array>({
  70. start(controller) {
  71. controller.enqueue(encoder.encode([
  72. 'data: {"choices":[{"delta":{"reasoning_content":"需要读章","tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_chapter","arguments":"{}"}}]}}]}',
  73. "data: [DONE]",
  74. "",
  75. ].join("\n")))
  76. controller.close()
  77. },
  78. })
  79. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  80. const onReasoningToken = vi.fn()
  81. const onToolCallDelta = vi.fn()
  82. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  83. onToken: vi.fn(),
  84. onReasoningToken,
  85. onToolCallDelta,
  86. onDone: vi.fn(),
  87. onError: vi.fn(),
  88. })
  89. expect(onReasoningToken).toHaveBeenCalledWith("需要读章")
  90. expect(onToolCallDelta).toHaveBeenCalledWith(expect.objectContaining({
  91. id: "call_1",
  92. name: "read_chapter",
  93. }))
  94. })
  95. it("发送前按 token 预算裁剪并保持系统与当前请求非空", async () => {
  96. mocks.fetch.mockResolvedValue(new Response([
  97. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  98. "data: [DONE]",
  99. "",
  100. ].join("\n"), { status: 200 }))
  101. // 1843-token window (2048 × 0.9) against ~1800 tokens of CJK input, so the
  102. // trim has to bite while leaving the protected messages intact.
  103. await streamChat({ ...config, maxContextSize: 2_048 }, [
  104. { role: "system", content: "系统".repeat(450) },
  105. { role: "user", content: `任务目标:续写。${"正文".repeat(450)}结尾限制:保持人物关系。` },
  106. ], {
  107. onToken: vi.fn(),
  108. onDone: vi.fn(),
  109. onError: vi.fn(),
  110. })
  111. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  112. const body = JSON.parse(String(request.body)) as {
  113. messages: ChatMessage[]
  114. max_tokens?: number
  115. }
  116. expect(estimateChatMessagesTokens(body.messages)).toBeLessThanOrEqual(1_331)
  117. expect(String(body.messages[0]?.content).trim()).not.toBe("")
  118. expect(body.messages.at(-1)?.content).toContain("任务目标")
  119. expect(body.messages.at(-1)?.content).toContain("保持人物关系")
  120. })
  121. it("上下文无法容纳最小输出时明确失败且不调用供应商", async () => {
  122. await expect(streamChat({ ...config, maxContextSize: 512 }, [
  123. { role: "system", content: "系统约束" },
  124. { role: "user", content: "生成第一卷完整大纲" },
  125. ], {
  126. onToken: vi.fn(),
  127. onDone: vi.fn(),
  128. onError: vi.fn(),
  129. })).rejects.toBeInstanceOf(LlmContextBudgetError)
  130. expect(mocks.fetch).not.toHaveBeenCalled()
  131. })
  132. it("调用方未传 max_tokens 时请求体不带该字段", async () => {
  133. mocks.fetch.mockResolvedValue(new Response([
  134. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  135. "data: [DONE]",
  136. "",
  137. ].join("\n"), { status: 200 }))
  138. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  139. onToken: vi.fn(),
  140. onDone: vi.fn(),
  141. onError: vi.fn(),
  142. })
  143. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  144. expect(JSON.parse(String(request.body))).not.toHaveProperty("max_tokens")
  145. })
  146. it("调用方显式传入的超大 max_tokens 收敛到输出上限", async () => {
  147. mocks.fetch.mockResolvedValue(new Response([
  148. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  149. "data: [DONE]",
  150. "",
  151. ].join("\n"), { status: 200 }))
  152. await streamChat(
  153. { ...config, maxContextSize: 1_000_000, maxOutputTokens: 65_536 },
  154. [{ role: "user", content: "写第一章" }],
  155. { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  156. undefined,
  157. { max_tokens: 300_000 },
  158. )
  159. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  160. expect(JSON.parse(String(request.body))).toMatchObject({ max_tokens: 65_536 })
  161. })
  162. it("脏 SSE 行不会中断整轮流式响应", async () => {
  163. const encoder = new TextEncoder()
  164. const body = new ReadableStream<Uint8Array>({
  165. start(controller) {
  166. controller.enqueue(encoder.encode([
  167. 'data: {"choices":[{"delta":{"content":"前半"}}]}',
  168. "data: {不是合法 JSON",
  169. 'data: {"choices":[{"delta":{"content":"后半"}}]}',
  170. "data: [DONE]",
  171. "",
  172. ].join("\n")))
  173. controller.close()
  174. },
  175. })
  176. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  177. const onToken = vi.fn()
  178. const onDone = vi.fn()
  179. const onError = vi.fn()
  180. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  181. onToken,
  182. onDone,
  183. onError,
  184. })
  185. expect(onToken).toHaveBeenCalledWith("前半")
  186. expect(onToken).toHaveBeenCalledWith("后半")
  187. expect(onDone).toHaveBeenCalledOnce()
  188. expect(onError).not.toHaveBeenCalled()
  189. })
  190. })