llm-request-trace.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. import { describe, expect, it } from "vitest"
  2. import type { ChatMessage, RequestOverrides } from "./llm-providers"
  3. import {
  4. LlmRequestTraceCollector,
  5. MAX_LLM_REQUEST_CACHE_TRACES,
  6. buildLlmRequestPrefixDescriptor,
  7. isLlmRequestCacheTrace,
  8. type LlmRequestCacheTrace,
  9. } from "./llm-request-trace"
  10. import type { LlmConfig } from "@/stores/wiki-store"
  11. const config: LlmConfig = {
  12. provider: "openai",
  13. apiKey: "sk-must-not-be-persisted",
  14. model: "gpt-test",
  15. apiMode: "chat_completions",
  16. ollamaUrl: "",
  17. customEndpoint: "https://secret.example/v1",
  18. maxContextSize: 204_800,
  19. reasoning: { mode: "medium" },
  20. }
  21. function messages(dynamicRule: string, stableCore = "项目稳定核心"): ChatMessage[] {
  22. return [
  23. {
  24. role: "system",
  25. content: [
  26. { type: "text", text: "固定基础规则\n" },
  27. { type: "text", text: stableCore, cacheControl: true },
  28. { type: "text", text: `\n动态规则:${dynamicRule}` },
  29. ],
  30. },
  31. { role: "user", content: `任务:${dynamicRule}` },
  32. ]
  33. }
  34. const tools: NonNullable<RequestOverrides["tools"]> = [{
  35. type: "function",
  36. function: {
  37. name: "read_outline",
  38. description: "读取大纲",
  39. parameters: { type: "object", properties: {} },
  40. },
  41. }]
  42. describe("LLM request prefix fingerprint", () => {
  43. it("ignores task, chapter and Skill changes after the cache breakpoint", async () => {
  44. const first = await buildLlmRequestPrefixDescriptor(config, messages("写第 11 章并启用 Skill A"), {
  45. tools,
  46. toolChoice: "auto",
  47. reasoning: { mode: "medium" },
  48. })
  49. const second = await buildLlmRequestPrefixDescriptor(config, messages("分析第 229 章并启用 Skill B"), {
  50. tools,
  51. toolChoice: "auto",
  52. reasoning: { mode: "medium" },
  53. })
  54. expect(first.prefixFingerprint).toMatch(/^[a-f0-9]{64}$/)
  55. expect(second.prefixFingerprint).toBe(first.prefixFingerprint)
  56. expect(first.prefixEstimatedTokens).toBeGreaterThan(0)
  57. })
  58. it("changes for stable text, model, tool schema and reasoning changes", async () => {
  59. const base = await buildLlmRequestPrefixDescriptor(config, messages("动态"), {
  60. tools,
  61. toolChoice: "auto",
  62. reasoning: { mode: "medium" },
  63. })
  64. const variants = await Promise.all([
  65. buildLlmRequestPrefixDescriptor(config, messages("动态", "变化后的稳定核心"), { tools, toolChoice: "auto", reasoning: { mode: "medium" } }),
  66. buildLlmRequestPrefixDescriptor({ ...config, model: "gpt-other" }, messages("动态"), { tools, toolChoice: "auto", reasoning: { mode: "medium" } }),
  67. buildLlmRequestPrefixDescriptor(config, messages("动态"), { tools: [{ ...tools[0], function: { ...tools[0].function, description: "变化" } }], toolChoice: "auto", reasoning: { mode: "medium" } }),
  68. buildLlmRequestPrefixDescriptor(config, messages("动态"), { tools, toolChoice: "auto", reasoning: { mode: "high" } }),
  69. ])
  70. for (const variant of variants) {
  71. expect(variant.prefixFingerprint).not.toBe(base.prefixFingerprint)
  72. }
  73. })
  74. it("returns no fingerprint when no virtual or real breakpoint exists", async () => {
  75. await expect(buildLlmRequestPrefixDescriptor(config, [
  76. { role: "system", content: "普通系统提示" },
  77. { role: "user", content: "任务" },
  78. ])).resolves.toEqual({})
  79. })
  80. })
  81. function trace(index: number, fingerprint = "a".repeat(64)): LlmRequestCacheTrace {
  82. return {
  83. provider: "openai",
  84. model: "gpt-test",
  85. apiMode: "chat_completions",
  86. prefixFingerprint: fingerprint,
  87. startedAt: index * 1_000,
  88. finishedAt: index * 1_000 + 400,
  89. durationMs: 400,
  90. firstResponseMs: 120,
  91. inputTokens: 1_000,
  92. outputTokens: 100,
  93. cacheReadTokens: 800,
  94. cacheWriteTokens: 0,
  95. status: "success",
  96. }
  97. }
  98. describe("LLM request trace collector", () => {
  99. it("computes same-prefix start/idle gaps and caps snapshots at 32 requests", () => {
  100. const collector = new LlmRequestTraceCollector()
  101. for (let index = 0; index < MAX_LLM_REQUEST_CACHE_TRACES + 2; index += 1) {
  102. collector.record(trace(index))
  103. }
  104. const snapshot = collector.snapshot()
  105. expect(snapshot.requests).toHaveLength(MAX_LLM_REQUEST_CACHE_TRACES)
  106. expect(snapshot.omittedRequestCount).toBe(2)
  107. expect(snapshot.requests[0].startedAt).toBe(2_000)
  108. expect(snapshot.requests[1]).toMatchObject({ startGapMs: 1_000, idleGapMs: 600 })
  109. })
  110. it("stores only sanitized diagnostics and strictly rejects damaged traces", () => {
  111. const value = trace(1)
  112. expect(isLlmRequestCacheTrace(value)).toBe(true)
  113. expect(JSON.stringify(value)).not.toContain(config.apiKey)
  114. expect(JSON.stringify(value)).not.toContain(config.customEndpoint)
  115. expect(JSON.stringify(value)).not.toContain("项目稳定核心")
  116. expect(isLlmRequestCacheTrace({ ...value, status: "timeout" })).toBe(false)
  117. expect(isLlmRequestCacheTrace({ ...value, durationMs: -1 })).toBe(false)
  118. })
  119. })