| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578 |
- import { beforeEach, describe, expect, it, vi } from "vitest"
- import type { LlmConfig } from "@/stores/wiki-store"
- import { streamChat } from "./llm-client"
- import { estimateChatMessagesTokens } from "./chat-request-budget"
- import type { ChatMessage } from "./llm-providers"
- import { thinkingMinMaxTokens } from "./llm-providers"
- import {
- RESPONSE_RESERVE_FRAC,
- planLlmRequestBudget,
- } from "./context-budget"
- import { normalizeUserLlmMaxOutputTokens } from "./llm-context-size"
- const mocks = vi.hoisted(() => ({
- fetch: vi.fn(),
- isFetchNetworkError: vi.fn(() => false),
- streamClaudeCodeCli: vi.fn(),
- }))
- vi.mock("./tauri-fetch", () => ({
- getHttpFetch: vi.fn(async () => mocks.fetch),
- isFetchNetworkError: (...args: unknown[]) => mocks.isFetchNetworkError(...args),
- }))
- vi.mock("./local-cli-config", () => ({
- resolveRuntimeLocalCliConfig: vi.fn(async (config: LlmConfig) => config),
- }))
- vi.mock("./claude-cli-transport", () => ({
- streamClaudeCodeCli: (...args: unknown[]) => mocks.streamClaudeCodeCli(...args),
- }))
- const config: LlmConfig = {
- provider: "openai",
- apiKey: "sk-test",
- model: "gpt-test",
- ollamaUrl: "",
- customEndpoint: "",
- maxContextSize: 128_000,
- }
- describe("streamChat usage", () => {
- beforeEach(() => {
- mocks.fetch.mockReset()
- mocks.isFetchNetworkError.mockReset()
- mocks.isFetchNetworkError.mockReturnValue(false)
- mocks.streamClaudeCodeCli.mockReset()
- })
- it("requests and emits OpenAI stream usage once", async () => {
- const encoder = new TextEncoder()
- const body = new ReadableStream<Uint8Array>({
- start(controller) {
- controller.enqueue(encoder.encode([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- 'data: {"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":80,"total_tokens":1280,"prompt_tokens_details":{"cached_tokens":1024}}}',
- "data: [DONE]",
- "",
- ].join("\n")))
- controller.close()
- },
- })
- mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
- const onUsage = vi.fn()
- const onDone = vi.fn()
- const onError = vi.fn()
- const onRequestTrace = vi.fn()
- await streamChat(config, [
- {
- role: "system",
- content: [
- { type: "text", text: "固定规则" },
- { type: "text", text: "项目稳定核心", cacheControl: true },
- { type: "text", text: "动态任务" },
- ],
- },
- { role: "user", content: "测试" },
- ], {
- onToken: vi.fn(),
- onUsage,
- onRequestTrace,
- onDone,
- onError,
- })
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- expect(JSON.parse(String(request.body))).toMatchObject({
- stream: true,
- stream_options: { include_usage: true },
- })
- expect(onUsage).toHaveBeenCalledOnce()
- expect(onUsage).toHaveBeenCalledWith({
- inputTokens: 1200,
- outputTokens: 80,
- totalTokens: 1280,
- cachedInputTokens: 1024,
- })
- expect(onDone).toHaveBeenCalledOnce()
- expect(onError).not.toHaveBeenCalled()
- expect(onRequestTrace).toHaveBeenCalledOnce()
- expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({
- provider: "openai",
- model: "gpt-test",
- prefixFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
- inputTokens: 1200,
- outputTokens: 80,
- cacheReadTokens: 1024,
- status: "success",
- }))
- })
- it("同行 tool_calls 仍触发 onReasoningToken", async () => {
- const encoder = new TextEncoder()
- const body = new ReadableStream<Uint8Array>({
- start(controller) {
- controller.enqueue(encoder.encode([
- 'data: {"choices":[{"delta":{"reasoning_content":"需要读章","tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_chapter","arguments":"{}"}}]}}]}',
- "data: [DONE]",
- "",
- ].join("\n")))
- controller.close()
- },
- })
- mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
- const onReasoningToken = vi.fn()
- const onToolCallDelta = vi.fn()
- await streamChat(config, [{ role: "user", content: "写第一章" }], {
- onToken: vi.fn(),
- onReasoningToken,
- onToolCallDelta,
- onDone: vi.fn(),
- onError: vi.fn(),
- })
- expect(onReasoningToken).toHaveBeenCalledWith("需要读章")
- expect(onToolCallDelta).toHaveBeenCalledWith(expect.objectContaining({
- id: "call_1",
- name: "read_chapter",
- }))
- })
- it("does not treat reasoning plus tool calls as a reasoning-only failure", async () => {
- const thinking = "先列出大纲和章节再决定怎么写。".repeat(20)
- expect(thinking.length).toBeGreaterThan(200)
- const encoder = new TextEncoder()
- const body = new ReadableStream<Uint8Array>({
- start(controller) {
- controller.enqueue(encoder.encode([
- `data: {"choices":[{"delta":{"reasoning_content":${JSON.stringify(thinking)}}}]}`,
- 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"list_outlines","arguments":"{}"}}]}}]}',
- "data: [DONE]",
- "",
- ].join("\n")))
- controller.close()
- },
- })
- mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
- const onError = vi.fn()
- const onToolCallDelta = vi.fn()
- const onDone = vi.fn()
- await streamChat(config, [{ role: "user", content: "写第45章" }], {
- onToken: vi.fn(),
- onToolCallDelta,
- onDone,
- onError,
- })
- expect(onToolCallDelta).toHaveBeenCalled()
- expect(onDone).toHaveBeenCalledOnce()
- expect(onError).not.toHaveBeenCalled()
- })
- it("disables thinking and drops empty reasoning before a tool-follow-up request", async () => {
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"继续"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- const deepseekConfig: LlmConfig = {
- ...config,
- provider: "custom",
- model: "deepseek/deepseek-v4-flash",
- customEndpoint: "https://api.deepseek.com/v1",
- reasoning: { mode: "high" },
- }
- await streamChat(deepseekConfig, [
- { role: "user", content: "写第45章" },
- {
- role: "assistant",
- content: "",
- tool_calls: [{
- id: "call_1",
- type: "function",
- function: { name: "list_outlines", arguments: "{}" },
- }],
- reasoning_content: "",
- },
- { role: "tool", content: "大纲列表", tool_call_id: "call_1", name: "list_outlines" },
- ], {
- onToken: vi.fn(),
- onDone: vi.fn(),
- onError: vi.fn(),
- })
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- const body = JSON.parse(String(request.body)) as {
- thinking?: { type: string }
- messages: Array<{ reasoning_content?: string }>
- }
- expect(body.thinking).toEqual({ type: "disabled" })
- expect(body.messages[1]).not.toHaveProperty("reasoning_content")
- })
- it("retries a reasoning_content 400 once with thinking disabled", async () => {
- const encoder = new TextEncoder()
- mocks.fetch
- .mockResolvedValueOnce(new Response(
- JSON.stringify({
- error: {
- message: "The reasoning_content in the thinking mode must be passed back to the API.",
- type: "invalid_request_error",
- },
- }),
- { status: 400 },
- ))
- .mockResolvedValueOnce(new Response(new ReadableStream<Uint8Array>({
- start(controller) {
- controller.enqueue(encoder.encode([
- 'data: {"choices":[{"delta":{"content":"已继续"}}]}',
- "data: [DONE]",
- "",
- ].join("\n")))
- controller.close()
- },
- }), { status: 200 }))
- const deepseekConfig: LlmConfig = {
- ...config,
- provider: "custom",
- model: "deepseek/deepseek-v4-flash",
- customEndpoint: "https://api.deepseek.com/v1",
- reasoning: { mode: "high" },
- }
- const onToken = vi.fn()
- const onError = vi.fn()
- await streamChat(deepseekConfig, [
- { role: "user", content: "写第45章" },
- {
- role: "assistant",
- content: "先读大纲",
- reasoning_content: "看起来像思考但接口仍拒收",
- },
- ], {
- onToken,
- onDone: vi.fn(),
- onError,
- })
- expect(mocks.fetch).toHaveBeenCalledTimes(2)
- const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body)) as {
- thinking?: { type: string }
- }
- expect(retryBody.thinking).toEqual({ type: "disabled" })
- expect(onToken).toHaveBeenCalledWith("已继续")
- expect(onError).not.toHaveBeenCalled()
- })
- it("发送前按 token 预算裁剪并保持系统与当前请求非空", async () => {
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- // Window clamps to ≥204800; overflow with a large middle user turn so trim
- // must drop history while keeping the system + current-user ends intact.
- const windowTokens = 204_800
- const outputReserve = Math.floor(windowTokens * RESPONSE_RESERVE_FRAC)
- await streamChat({ ...config, maxContextSize: windowTokens }, [
- { role: "system", content: "系统".repeat(20_000) },
- { role: "user", content: "旧请求".repeat(90_000) },
- { role: "assistant", content: "旧回复".repeat(90_000) },
- { role: "user", content: `任务目标:续写。${"正文".repeat(40_000)}结尾限制:保持人物关系。` },
- ], {
- onToken: vi.fn(),
- onDone: vi.fn(),
- onError: vi.fn(),
- })
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- const body = JSON.parse(String(request.body)) as {
- messages: ChatMessage[]
- max_tokens?: number
- }
- expect(estimateChatMessagesTokens(body.messages)).toBeLessThanOrEqual(
- windowTokens - outputReserve,
- )
- expect(String(body.messages[0]?.content).trim()).not.toBe("")
- expect(body.messages.at(-1)?.content).toContain("任务目标")
- expect(body.messages.at(-1)?.content).toContain("保持人物关系")
- })
- it("超大受保护消息在归一化窗口下会被压缩后仍发送", async () => {
- // With maxContextSize clamped to ≥204800, the old "tiny window → hard fail"
- // path is unreachable through streamChat; protected ends are compressed
- // instead so the request can still leave.
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- await streamChat({ ...config, maxContextSize: 204_800 }, [
- { role: "system", content: "系统".repeat(120_000) },
- { role: "user", content: "生成".repeat(120_000) },
- ], {
- onToken: vi.fn(),
- onDone: vi.fn(),
- onError: vi.fn(),
- })
- expect(mocks.fetch).toHaveBeenCalledTimes(1)
- const body = JSON.parse(String((mocks.fetch.mock.calls[0][1] as RequestInit).body)) as {
- messages: ChatMessage[]
- }
- expect(estimateChatMessagesTokens(body.messages)).toBeLessThan(204_800)
- expect(String(body.messages[0]?.content).trim()).not.toBe("")
- expect(String(body.messages.at(-1)?.content).trim()).not.toBe("")
- })
- it("调用方未传 max_tokens 时仍外发窗口比例预算", async () => {
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- const planned = planLlmRequestBudget({
- maxContextSize: config.maxContextSize,
- desiredOutputTokens: Math.floor(
- Math.max(204_800, config.maxContextSize) * RESPONSE_RESERVE_FRAC,
- ),
- scaffoldReserveTokens: 0,
- minimumContextTokens: 64,
- maxOutputTokensCap: normalizeUserLlmMaxOutputTokens(config.maxOutputTokens),
- })
- await streamChat(config, [{ role: "user", content: "写第一章" }], {
- onToken: vi.fn(),
- onDone: vi.fn(),
- onError: vi.fn(),
- })
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- expect(JSON.parse(String(request.body))).toMatchObject({
- max_tokens: planned.outputTokens,
- })
- })
- it("reasoning.mode=auto 且调用方未传 max_tokens 时仍外发窗口比例预算", async () => {
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- const planned = planLlmRequestBudget({
- maxContextSize: config.maxContextSize,
- desiredOutputTokens: Math.floor(
- Math.max(204_800, config.maxContextSize) * RESPONSE_RESERVE_FRAC,
- ),
- scaffoldReserveTokens: 0,
- minimumContextTokens: 64,
- maxOutputTokensCap: normalizeUserLlmMaxOutputTokens(config.maxOutputTokens),
- })
- await streamChat(
- { ...config, reasoning: { mode: "auto" } },
- [{ role: "user", content: "写第一章" }],
- { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
- )
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- expect(JSON.parse(String(request.body))).toMatchObject({
- max_tokens: planned.outputTokens,
- })
- })
- it("reasoning.mode=high 且调用方未传 max_tokens 时发送预算规划的 max_tokens", async () => {
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- const reasoning = { mode: "high" as const }
- const thinkingFloorTokens = thinkingMinMaxTokens(reasoning)
- expect(thinkingFloorTokens).toBeGreaterThan(0)
- const windowTokens = Math.max(204_800, config.maxContextSize)
- const planned = planLlmRequestBudget({
- maxContextSize: windowTokens,
- desiredOutputTokens: Math.floor(windowTokens * RESPONSE_RESERVE_FRAC),
- scaffoldReserveTokens: 0,
- minimumContextTokens: 64,
- maxOutputTokensCap: normalizeUserLlmMaxOutputTokens(config.maxOutputTokens),
- thinkingFloorTokens,
- })
- await streamChat(
- { ...config, reasoning },
- [{ role: "user", content: "写第一章" }],
- { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
- )
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- expect(JSON.parse(String(request.body))).toMatchObject({
- max_tokens: planned.outputTokens,
- })
- expect(planned.outputTokens).toBeGreaterThanOrEqual(thinkingFloorTokens)
- })
- it("调用方显式传入的超大 max_tokens 收敛到输出上限", async () => {
- mocks.fetch.mockResolvedValue(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- await streamChat(
- { ...config, maxContextSize: 1_000_000, maxOutputTokens: 65_536 },
- [{ role: "user", content: "写第一章" }],
- { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
- undefined,
- { max_tokens: 300_000 },
- )
- const request = mocks.fetch.mock.calls[0][1] as RequestInit
- expect(JSON.parse(String(request.body))).toMatchObject({ max_tokens: 65_536 })
- })
- it("本地 CLI 供应商不外发 max_tokens", async () => {
- mocks.streamClaudeCodeCli.mockImplementation(async (
- _config: LlmConfig,
- _messages: ChatMessage[],
- callbacks: { onToken: (token: string) => void; onDone: () => void },
- _signal?: AbortSignal,
- overrides?: { max_tokens?: number },
- ) => {
- expect(overrides).not.toHaveProperty("max_tokens")
- callbacks.onToken("ok")
- callbacks.onDone()
- })
- await streamChat(
- {
- ...config,
- provider: "claude-code",
- model: "claude-sonnet-5",
- },
- [{ role: "user", content: "写第一章" }],
- { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
- undefined,
- { max_tokens: 30_720 },
- )
- expect(mocks.fetch).not.toHaveBeenCalled()
- expect(mocks.streamClaudeCodeCli).toHaveBeenCalledTimes(1)
- })
- it("服务商回报 max_tokens 超限时按其上限重试一次", async () => {
- mocks.fetch
- .mockResolvedValueOnce(new Response(
- JSON.stringify({
- error: {
- message: "max_tokens is too large: this model supports at most 8192 output tokens",
- },
- }),
- { status: 400 },
- ))
- .mockResolvedValueOnce(new Response([
- 'data: {"choices":[{"delta":{"content":"完成"}}]}',
- "data: [DONE]",
- "",
- ].join("\n"), { status: 200 }))
- const onError = vi.fn()
- const onRequestTrace = vi.fn()
- await streamChat(config, [{ role: "user", content: "写第一章" }], {
- onToken: vi.fn(),
- onDone: vi.fn(),
- onRequestTrace,
- onError,
- })
- expect(mocks.fetch).toHaveBeenCalledTimes(2)
- const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body))
- expect(retryBody.max_tokens).toBe(8_192)
- expect(onError).not.toHaveBeenCalled()
- expect(onRequestTrace.mock.calls.map(([trace]) => trace.status)).toEqual(["error", "success"])
- })
- it("脏 SSE 行不会中断整轮流式响应", async () => {
- const encoder = new TextEncoder()
- const body = new ReadableStream<Uint8Array>({
- start(controller) {
- controller.enqueue(encoder.encode([
- 'data: {"choices":[{"delta":{"content":"前半"}}]}',
- "data: {不是合法 JSON",
- 'data: {"choices":[{"delta":{"content":"后半"}}]}',
- "data: [DONE]",
- "",
- ].join("\n")))
- controller.close()
- },
- })
- mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
- const onToken = vi.fn()
- const onDone = vi.fn()
- const onError = vi.fn()
- await streamChat(config, [{ role: "user", content: "写第一章" }], {
- onToken,
- onDone,
- onError,
- })
- expect(onToken).toHaveBeenCalledWith("前半")
- expect(onToken).toHaveBeenCalledWith("后半")
- expect(onDone).toHaveBeenCalledOnce()
- expect(onError).not.toHaveBeenCalled()
- })
- it("records a mid-stream network failure as network_error", async () => {
- const body = new ReadableStream<Uint8Array>({
- start(controller) {
- controller.error(new Error("connection dropped"))
- },
- })
- mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
- mocks.isFetchNetworkError.mockReturnValue(true)
- const onRequestTrace = vi.fn()
- const onError = vi.fn()
- await streamChat(config, [{ role: "user", content: "测试网络中断" }], {
- onToken: vi.fn(),
- onRequestTrace,
- onDone: vi.fn(),
- onError,
- })
- expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "network_error" }))
- expect(onError).toHaveBeenCalledWith(expect.objectContaining({
- message: expect.stringContaining("流式响应读取中断"),
- }))
- })
- it("records an aborted supplier attempt as cancelled", async () => {
- mocks.fetch.mockRejectedValue(new DOMException("aborted", "AbortError"))
- const controller = new AbortController()
- controller.abort()
- const onRequestTrace = vi.fn()
- const onDone = vi.fn()
- await streamChat(config, [{ role: "user", content: "取消请求" }], {
- onToken: vi.fn(),
- onRequestTrace,
- onDone,
- onError: vi.fn(),
- }, controller.signal)
- expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "cancelled" }))
- expect(onDone).toHaveBeenCalledOnce()
- })
- })
|