extract-usage-metrics.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673
  1. import { describe, it, expect } from "vitest";
  2. // 由于 extractUsageMetrics 是内部函数,需要通过 parseUsageFromResponseText 间接测试
  3. // 或者将其导出用于测试
  4. // 这里我们通过构造 JSON 响应来测试 parseUsageFromResponseText
  5. import { parseUsageFromResponseText } from "@/app/v1/_lib/proxy/response-handler";
  6. describe("extractUsageMetrics", () => {
  7. describe("基本 token 提取", () => {
  8. it("应正确提取 input_tokens 和 output_tokens", () => {
  9. const response = JSON.stringify({
  10. usage: {
  11. input_tokens: 1000,
  12. output_tokens: 500,
  13. },
  14. });
  15. const result = parseUsageFromResponseText(response, "claude");
  16. expect(result.usageMetrics).not.toBeNull();
  17. expect(result.usageMetrics?.input_tokens).toBe(1000);
  18. expect(result.usageMetrics?.output_tokens).toBe(500);
  19. });
  20. it("空值或非对象应返回 null", () => {
  21. expect(parseUsageFromResponseText("", "claude").usageMetrics).toBeNull();
  22. expect(parseUsageFromResponseText("null", "claude").usageMetrics).toBeNull();
  23. expect(parseUsageFromResponseText('"string"', "claude").usageMetrics).toBeNull();
  24. });
  25. });
  26. describe("Claude 嵌套格式 (cache_creation.ephemeral_*)", () => {
  27. it("应从 cache_creation 嵌套对象提取 5m 和 1h token", () => {
  28. const response = JSON.stringify({
  29. usage: {
  30. input_tokens: 1000,
  31. output_tokens: 500,
  32. cache_creation_input_tokens: 800,
  33. cache_creation: {
  34. ephemeral_5m_input_tokens: 300,
  35. ephemeral_1h_input_tokens: 500,
  36. },
  37. cache_read_input_tokens: 200,
  38. },
  39. });
  40. const result = parseUsageFromResponseText(response, "claude");
  41. expect(result.usageMetrics).not.toBeNull();
  42. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(800);
  43. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
  44. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
  45. expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
  46. expect(result.usageMetrics?.cache_ttl).toBe("mixed");
  47. });
  48. it("只有 5m 时应推断 cache_ttl 为 5m", () => {
  49. const response = JSON.stringify({
  50. usage: {
  51. cache_creation_input_tokens: 300,
  52. cache_creation: {
  53. ephemeral_5m_input_tokens: 300,
  54. },
  55. },
  56. });
  57. const result = parseUsageFromResponseText(response, "claude");
  58. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
  59. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBeUndefined();
  60. expect(result.usageMetrics?.cache_ttl).toBe("5m");
  61. });
  62. it("只有 1h 时应推断 cache_ttl 为 1h", () => {
  63. const response = JSON.stringify({
  64. usage: {
  65. cache_creation_input_tokens: 500,
  66. cache_creation: {
  67. ephemeral_1h_input_tokens: 500,
  68. },
  69. },
  70. });
  71. const result = parseUsageFromResponseText(response, "claude");
  72. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
  73. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBeUndefined();
  74. expect(result.usageMetrics?.cache_ttl).toBe("1h");
  75. });
  76. });
  77. describe("旧 relay 格式 (claude_cache_creation_*)", () => {
  78. it("应从旧 relay 字段提取 5m 和 1h token", () => {
  79. const response = JSON.stringify({
  80. usage: {
  81. input_tokens: 1000,
  82. output_tokens: 500,
  83. cache_creation_input_tokens: 800,
  84. claude_cache_creation_5_m_tokens: 300,
  85. claude_cache_creation_1_h_tokens: 500,
  86. cache_read_input_tokens: 200,
  87. },
  88. });
  89. const result = parseUsageFromResponseText(response, "claude");
  90. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
  91. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
  92. expect(result.usageMetrics?.cache_ttl).toBe("mixed");
  93. });
  94. it("嵌套格式应优先于旧 relay 格式", () => {
  95. const response = JSON.stringify({
  96. usage: {
  97. cache_creation: {
  98. ephemeral_5m_input_tokens: 100,
  99. ephemeral_1h_input_tokens: 200,
  100. },
  101. claude_cache_creation_5_m_tokens: 999,
  102. claude_cache_creation_1_h_tokens: 888,
  103. },
  104. });
  105. const result = parseUsageFromResponseText(response, "claude");
  106. // 嵌套格式优先
  107. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(100);
  108. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(200);
  109. });
  110. });
  111. describe("顶层扁平格式 (cache_creation_5m_input_tokens)", () => {
  112. it("应从顶层扁平字段提取 5m 和 1h token", () => {
  113. const response = JSON.stringify({
  114. usage: {
  115. input_tokens: 1000,
  116. output_tokens: 500,
  117. cache_creation_input_tokens: 800,
  118. cache_creation_5m_input_tokens: 300,
  119. cache_creation_1h_input_tokens: 500,
  120. cache_read_input_tokens: 200,
  121. },
  122. });
  123. const result = parseUsageFromResponseText(response, "claude");
  124. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(800);
  125. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
  126. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
  127. expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
  128. expect(result.usageMetrics?.cache_ttl).toBe("mixed");
  129. });
  130. it("只有顶层 5m 时应正确提取并推断 TTL", () => {
  131. const response = JSON.stringify({
  132. usage: {
  133. cache_creation_input_tokens: 300,
  134. cache_creation_5m_input_tokens: 300,
  135. },
  136. });
  137. const result = parseUsageFromResponseText(response, "claude");
  138. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
  139. expect(result.usageMetrics?.cache_ttl).toBe("5m");
  140. });
  141. it("只有顶层 1h 时应正确提取并推断 TTL", () => {
  142. const response = JSON.stringify({
  143. usage: {
  144. cache_creation_input_tokens: 500,
  145. cache_creation_1h_input_tokens: 500,
  146. },
  147. });
  148. const result = parseUsageFromResponseText(response, "claude");
  149. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
  150. expect(result.usageMetrics?.cache_ttl).toBe("1h");
  151. });
  152. it("嵌套格式应优先于顶层扁平格式", () => {
  153. const response = JSON.stringify({
  154. usage: {
  155. cache_creation: {
  156. ephemeral_5m_input_tokens: 100,
  157. ephemeral_1h_input_tokens: 200,
  158. },
  159. cache_creation_5m_input_tokens: 999,
  160. cache_creation_1h_input_tokens: 888,
  161. },
  162. });
  163. const result = parseUsageFromResponseText(response, "claude");
  164. // 嵌套格式优先
  165. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(100);
  166. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(200);
  167. });
  168. it("顶层扁平格式应优先于旧 relay 格式", () => {
  169. const response = JSON.stringify({
  170. usage: {
  171. cache_creation_5m_input_tokens: 300,
  172. cache_creation_1h_input_tokens: 500,
  173. claude_cache_creation_5_m_tokens: 999,
  174. claude_cache_creation_1_h_tokens: 888,
  175. },
  176. });
  177. const result = parseUsageFromResponseText(response, "claude");
  178. // 顶层扁平格式优先于旧 relay 格式
  179. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
  180. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
  181. });
  182. it("三种格式同时存在时应按优先级提取", () => {
  183. const response = JSON.stringify({
  184. usage: {
  185. cache_creation: {
  186. ephemeral_5m_input_tokens: 100,
  187. ephemeral_1h_input_tokens: 200,
  188. },
  189. cache_creation_5m_input_tokens: 300,
  190. cache_creation_1h_input_tokens: 400,
  191. claude_cache_creation_5_m_tokens: 500,
  192. claude_cache_creation_1_h_tokens: 600,
  193. },
  194. });
  195. const result = parseUsageFromResponseText(response, "claude");
  196. // 嵌套格式最优先
  197. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(100);
  198. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(200);
  199. expect(result.usageMetrics?.cache_ttl).toBe("mixed");
  200. });
  201. });
  202. describe("cache_creation_input_tokens 自动计算", () => {
  203. it("当 cache_creation_input_tokens 缺失时应自动计算总量", () => {
  204. const response = JSON.stringify({
  205. usage: {
  206. cache_creation: {
  207. ephemeral_5m_input_tokens: 300,
  208. ephemeral_1h_input_tokens: 500,
  209. },
  210. },
  211. });
  212. const result = parseUsageFromResponseText(response, "claude");
  213. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(800);
  214. });
  215. it("顶层扁平格式缺失 cache_creation_input_tokens 时应自动计算总量", () => {
  216. const response = JSON.stringify({
  217. usage: {
  218. cache_creation_5m_input_tokens: 400,
  219. cache_creation_1h_input_tokens: 600,
  220. },
  221. });
  222. const result = parseUsageFromResponseText(response, "claude");
  223. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(1000);
  224. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(400);
  225. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(600);
  226. });
  227. it("混合回退:嵌套缺失某字段时顶层扁平补齐", () => {
  228. const response = JSON.stringify({
  229. usage: {
  230. cache_creation: {
  231. ephemeral_5m_input_tokens: 200,
  232. // 缺失 ephemeral_1h_input_tokens
  233. },
  234. cache_creation_1h_input_tokens: 300, // 顶层扁平补齐
  235. },
  236. });
  237. const result = parseUsageFromResponseText(response, "claude");
  238. // 5m 来自嵌套,1h 来自顶层扁平
  239. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(200);
  240. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(300);
  241. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(500);
  242. expect(result.usageMetrics?.cache_ttl).toBe("mixed");
  243. });
  244. it("当 cache_creation_input_tokens 存在时不应覆盖", () => {
  245. const response = JSON.stringify({
  246. usage: {
  247. cache_creation_input_tokens: 1000,
  248. cache_creation: {
  249. ephemeral_5m_input_tokens: 300,
  250. ephemeral_1h_input_tokens: 500,
  251. },
  252. },
  253. });
  254. const result = parseUsageFromResponseText(response, "claude");
  255. // 保留原值
  256. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(1000);
  257. });
  258. });
  259. describe("Gemini 格式支持", () => {
  260. it("应正确提取 Gemini usage 字段", () => {
  261. const response = JSON.stringify({
  262. usageMetadata: {
  263. promptTokenCount: 1000,
  264. candidatesTokenCount: 500,
  265. cachedContentTokenCount: 200,
  266. },
  267. });
  268. const result = parseUsageFromResponseText(response, "gemini");
  269. expect(result.usageMetrics).not.toBeNull();
  270. // input_tokens = promptTokenCount - cachedContentTokenCount
  271. expect(result.usageMetrics?.input_tokens).toBe(800);
  272. expect(result.usageMetrics?.output_tokens).toBe(500);
  273. expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
  274. });
  275. it("应正确处理 Gemini thoughtsTokenCount", () => {
  276. const response = JSON.stringify({
  277. usageMetadata: {
  278. promptTokenCount: 1000,
  279. candidatesTokenCount: 500,
  280. thoughtsTokenCount: 100,
  281. },
  282. });
  283. const result = parseUsageFromResponseText(response, "gemini");
  284. // output_tokens = candidatesTokenCount + thoughtsTokenCount
  285. expect(result.usageMetrics?.output_tokens).toBe(600);
  286. });
  287. it("应从 candidatesTokensDetails 提取 IMAGE modality tokens", () => {
  288. const response = JSON.stringify({
  289. usageMetadata: {
  290. promptTokenCount: 326,
  291. candidatesTokenCount: 2340,
  292. candidatesTokensDetails: [
  293. { modality: "IMAGE", tokenCount: 2000 },
  294. { modality: "TEXT", tokenCount: 340 },
  295. ],
  296. },
  297. });
  298. const result = parseUsageFromResponseText(response, "gemini");
  299. expect(result.usageMetrics?.output_image_tokens).toBe(2000);
  300. expect(result.usageMetrics?.output_tokens).toBe(340);
  301. });
  302. it("应从 promptTokensDetails 提取 IMAGE modality tokens", () => {
  303. const response = JSON.stringify({
  304. usageMetadata: {
  305. promptTokenCount: 886,
  306. candidatesTokenCount: 500,
  307. promptTokensDetails: [
  308. { modality: "TEXT", tokenCount: 326 },
  309. { modality: "IMAGE", tokenCount: 560 },
  310. ],
  311. },
  312. });
  313. const result = parseUsageFromResponseText(response, "gemini");
  314. expect(result.usageMetrics?.input_image_tokens).toBe(560);
  315. expect(result.usageMetrics?.input_tokens).toBe(326);
  316. });
  317. it("应正确解析混合输入输出的完整 usage", () => {
  318. const response = JSON.stringify({
  319. usageMetadata: {
  320. promptTokenCount: 357,
  321. candidatesTokenCount: 2100,
  322. totalTokenCount: 2580,
  323. promptTokensDetails: [
  324. { modality: "TEXT", tokenCount: 99 },
  325. { modality: "IMAGE", tokenCount: 258 },
  326. ],
  327. candidatesTokensDetails: [{ modality: "IMAGE", tokenCount: 2000 }],
  328. thoughtsTokenCount: 123,
  329. },
  330. });
  331. const result = parseUsageFromResponseText(response, "gemini");
  332. expect(result.usageMetrics?.input_tokens).toBe(99);
  333. expect(result.usageMetrics?.input_image_tokens).toBe(258);
  334. // output_tokens = (candidatesTokenCount - IMAGE详情) + thoughtsTokenCount
  335. // = (2100 - 2000) + 123 = 223
  336. expect(result.usageMetrics?.output_tokens).toBe(223);
  337. expect(result.usageMetrics?.output_image_tokens).toBe(2000);
  338. });
  339. it("应处理只有 IMAGE modality 的 candidatesTokensDetails", () => {
  340. const response = JSON.stringify({
  341. usageMetadata: {
  342. promptTokenCount: 100,
  343. candidatesTokenCount: 2000,
  344. candidatesTokensDetails: [{ modality: "IMAGE", tokenCount: 2000 }],
  345. },
  346. });
  347. const result = parseUsageFromResponseText(response, "gemini");
  348. expect(result.usageMetrics?.output_image_tokens).toBe(2000);
  349. // candidatesTokenCount = 2000, IMAGE = 2000, 未分类 = 0
  350. expect(result.usageMetrics?.output_tokens).toBe(0);
  351. });
  352. it("应计算 candidatesTokenCount 与 details 的差值作为未分类 TEXT", () => {
  353. const response = JSON.stringify({
  354. usageMetadata: {
  355. promptTokenCount: 326,
  356. candidatesTokenCount: 2340,
  357. candidatesTokensDetails: [{ modality: "IMAGE", tokenCount: 2000 }],
  358. thoughtsTokenCount: 337,
  359. },
  360. });
  361. const result = parseUsageFromResponseText(response, "gemini");
  362. // 未分类 = 2340 - 2000 = 340
  363. // output_tokens = 340 + 337 (thoughts) = 677
  364. expect(result.usageMetrics?.output_tokens).toBe(677);
  365. expect(result.usageMetrics?.output_image_tokens).toBe(2000);
  366. });
  367. it("应处理缺失 candidatesTokensDetails 的情况(向后兼容)", () => {
  368. const response = JSON.stringify({
  369. usageMetadata: {
  370. promptTokenCount: 1000,
  371. candidatesTokenCount: 500,
  372. },
  373. });
  374. const result = parseUsageFromResponseText(response, "gemini");
  375. expect(result.usageMetrics?.output_tokens).toBe(500);
  376. expect(result.usageMetrics?.output_image_tokens).toBeUndefined();
  377. expect(result.usageMetrics?.input_image_tokens).toBeUndefined();
  378. });
  379. it("应处理空的 candidatesTokensDetails 数组", () => {
  380. const response = JSON.stringify({
  381. usageMetadata: {
  382. promptTokenCount: 1000,
  383. candidatesTokenCount: 500,
  384. candidatesTokensDetails: [],
  385. },
  386. });
  387. const result = parseUsageFromResponseText(response, "gemini");
  388. expect(result.usageMetrics?.output_tokens).toBe(500);
  389. expect(result.usageMetrics?.output_image_tokens).toBeUndefined();
  390. });
  391. it("应处理 candidatesTokensDetails 中无效 tokenCount 的情况", () => {
  392. const response = JSON.stringify({
  393. usageMetadata: {
  394. promptTokenCount: 1000,
  395. candidatesTokenCount: 500,
  396. candidatesTokensDetails: [
  397. { modality: "TEXT" },
  398. { modality: "IMAGE", tokenCount: null },
  399. { modality: "TEXT", tokenCount: -1 },
  400. ],
  401. },
  402. });
  403. const result = parseUsageFromResponseText(response, "gemini");
  404. // 无效数据不应覆盖原始 candidatesTokenCount
  405. expect(result.usageMetrics?.output_tokens).toBe(500);
  406. expect(result.usageMetrics?.output_image_tokens).toBeUndefined();
  407. });
  408. it("应处理 modality 大小写变体", () => {
  409. const response = JSON.stringify({
  410. usageMetadata: {
  411. promptTokenCount: 100,
  412. candidatesTokenCount: 2340,
  413. candidatesTokensDetails: [
  414. { modality: "image", tokenCount: 2000 },
  415. { modality: "Image", tokenCount: 100 },
  416. { modality: "TEXT", tokenCount: 240 },
  417. ],
  418. },
  419. });
  420. const result = parseUsageFromResponseText(response, "gemini");
  421. expect(result.usageMetrics?.output_image_tokens).toBe(2100);
  422. expect(result.usageMetrics?.output_tokens).toBe(240);
  423. });
  424. });
  425. describe("OpenAI Response API 格式", () => {
  426. it("应从 input_tokens_details.cached_tokens 提取缓存读取", () => {
  427. const response = JSON.stringify({
  428. usage: {
  429. input_tokens: 1000,
  430. output_tokens: 500,
  431. input_tokens_details: {
  432. cached_tokens: 200,
  433. },
  434. },
  435. });
  436. const result = parseUsageFromResponseText(response, "openai");
  437. expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
  438. });
  439. it("顶层 cache_read_input_tokens 应优先于嵌套格式", () => {
  440. const response = JSON.stringify({
  441. usage: {
  442. input_tokens: 1000,
  443. cache_read_input_tokens: 300,
  444. input_tokens_details: {
  445. cached_tokens: 200,
  446. },
  447. },
  448. });
  449. const result = parseUsageFromResponseText(response, "openai");
  450. // 顶层优先
  451. expect(result.usageMetrics?.cache_read_input_tokens).toBe(300);
  452. });
  453. });
  454. describe("SSE 流式响应解析", () => {
  455. it("应正确合并 message_start 和 message_delta 的 usage", () => {
  456. // 模拟 Claude SSE 流式响应
  457. const sseResponse = [
  458. "event: message_start",
  459. 'data: {"type":"message_start","message":{"usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_creation":{"ephemeral_5m_input_tokens":200,"ephemeral_1h_input_tokens":300},"cache_read_input_tokens":100}}}',
  460. "",
  461. "event: message_delta",
  462. 'data: {"type":"message_delta","usage":{"output_tokens":800}}',
  463. "",
  464. ].join("\n");
  465. const result = parseUsageFromResponseText(sseResponse, "claude");
  466. expect(result.usageMetrics).not.toBeNull();
  467. expect(result.usageMetrics?.input_tokens).toBe(1000);
  468. expect(result.usageMetrics?.output_tokens).toBe(800);
  469. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(500);
  470. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(200);
  471. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(300);
  472. expect(result.usageMetrics?.cache_read_input_tokens).toBe(100);
  473. });
  474. it("message_delta 的值应优先于 message_start", () => {
  475. const sseResponse = [
  476. "event: message_start",
  477. 'data: {"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}',
  478. "",
  479. "event: message_delta",
  480. 'data: {"type":"message_delta","usage":{"input_tokens":1000,"output_tokens":500}}',
  481. "",
  482. ].join("\n");
  483. const result = parseUsageFromResponseText(sseResponse, "claude");
  484. // message_delta 优先
  485. expect(result.usageMetrics?.input_tokens).toBe(1000);
  486. expect(result.usageMetrics?.output_tokens).toBe(500);
  487. });
  488. it("message_start 的 cache 细分应补充 message_delta 缺失的字段", () => {
  489. const sseResponse = [
  490. "event: message_start",
  491. 'data: {"type":"message_start","message":{"usage":{"cache_creation":{"ephemeral_5m_input_tokens":200,"ephemeral_1h_input_tokens":300}}}}',
  492. "",
  493. "event: message_delta",
  494. 'data: {"type":"message_delta","usage":{"input_tokens":1000,"output_tokens":500,"cache_creation_input_tokens":500}}',
  495. "",
  496. ].join("\n");
  497. const result = parseUsageFromResponseText(sseResponse, "claude");
  498. // message_delta 的值
  499. expect(result.usageMetrics?.input_tokens).toBe(1000);
  500. expect(result.usageMetrics?.output_tokens).toBe(500);
  501. expect(result.usageMetrics?.cache_creation_input_tokens).toBe(500);
  502. // message_start 补充的细分字段
  503. expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(200);
  504. expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(300);
  505. });
  506. });
  507. describe("Codex provider 特殊处理", () => {
  508. it("Codex 应从 input_tokens 中减去 cached_tokens", () => {
  509. const response = JSON.stringify({
  510. usage: {
  511. input_tokens: 1000,
  512. output_tokens: 500,
  513. cache_read_input_tokens: 300,
  514. },
  515. });
  516. const result = parseUsageFromResponseText(response, "codex");
  517. // adjustUsageForProviderType 会调整 input_tokens
  518. expect(result.usageMetrics?.input_tokens).toBe(700); // 1000 - 300
  519. expect(result.usageMetrics?.cache_read_input_tokens).toBe(300);
  520. });
  521. });
  522. describe("边界情况", () => {
  523. it("应处理所有值为 0 的情况", () => {
  524. const response = JSON.stringify({
  525. usage: {
  526. input_tokens: 0,
  527. output_tokens: 0,
  528. cache_creation_input_tokens: 0,
  529. cache_read_input_tokens: 0,
  530. },
  531. });
  532. const result = parseUsageFromResponseText(response, "claude");
  533. expect(result.usageMetrics).not.toBeNull();
  534. expect(result.usageMetrics?.input_tokens).toBe(0);
  535. expect(result.usageMetrics?.output_tokens).toBe(0);
  536. });
  537. it("应处理部分字段缺失的情况", () => {
  538. const response = JSON.stringify({
  539. usage: {
  540. input_tokens: 1000,
  541. },
  542. });
  543. const result = parseUsageFromResponseText(response, "claude");
  544. expect(result.usageMetrics?.input_tokens).toBe(1000);
  545. expect(result.usageMetrics?.output_tokens).toBeUndefined();
  546. expect(result.usageMetrics?.cache_creation_input_tokens).toBeUndefined();
  547. });
  548. it("应处理无效的 JSON", () => {
  549. const result = parseUsageFromResponseText("invalid json", "claude");
  550. expect(result.usageMetrics).toBeNull();
  551. });
  552. it("应处理空的 usage 对象", () => {
  553. const response = JSON.stringify({
  554. usage: {},
  555. });
  556. const result = parseUsageFromResponseText(response, "claude");
  557. expect(result.usageMetrics).toBeNull();
  558. });
  559. });
  560. });