pricing-no-price.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. import type { ModelPriceData } from "@/types/model-price";
  3. import type { SystemSettings } from "@/types/system-config";
  4. const asyncTasks: Promise<void>[] = [];
  5. const cloudSyncRequests: Array<{ reason: string }> = [];
  6. const { requestCloudPriceTableSyncMock } = vi.hoisted(() => {
  7. const requestCloudPriceTableSyncMock = vi.fn((payload: { reason: string }) => {
  8. cloudSyncRequests.push(payload);
  9. });
  10. return { requestCloudPriceTableSyncMock };
  11. });
  12. vi.mock("@/lib/price-sync/cloud-price-updater", () => ({
  13. requestCloudPriceTableSync: requestCloudPriceTableSyncMock,
  14. }));
  15. vi.mock("@/lib/logger", () => ({
  16. logger: {
  17. debug: vi.fn(),
  18. info: vi.fn(),
  19. warn: vi.fn(),
  20. error: vi.fn(),
  21. trace: vi.fn(),
  22. },
  23. }));
  24. vi.mock("@/repository/model-price", () => ({
  25. findLatestPriceByModel: vi.fn(),
  26. }));
  27. vi.mock("@/repository/system-config", () => ({
  28. getSystemSettings: vi.fn(),
  29. }));
  30. vi.mock("@/repository/message", () => ({
  31. updateMessageRequestCost: vi.fn(),
  32. updateMessageRequestDetails: vi.fn(),
  33. updateMessageRequestDuration: vi.fn(),
  34. }));
  35. vi.mock("@/lib/session-manager", () => ({
  36. SessionManager: {
  37. updateSessionUsage: vi.fn(async () => {}),
  38. storeSessionResponse: vi.fn(async () => {}),
  39. extractCodexPromptCacheKey: vi.fn(),
  40. updateSessionWithCodexCacheKey: vi.fn(async () => {}),
  41. },
  42. }));
  43. vi.mock("@/lib/rate-limit", () => ({
  44. RateLimitService: {
  45. trackCost: vi.fn(),
  46. trackUserDailyCost: vi.fn(),
  47. },
  48. }));
  49. vi.mock("@/lib/proxy-status-tracker", () => ({
  50. ProxyStatusTracker: {
  51. getInstance: () => ({
  52. endRequest: vi.fn(),
  53. }),
  54. },
  55. }));
  56. import { finalizeRequestStats } from "@/app/v1/_lib/proxy/response-handler";
  57. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  58. import { RateLimitService } from "@/lib/rate-limit";
  59. import { updateMessageRequestCost, updateMessageRequestDetails } from "@/repository/message";
  60. import { findLatestPriceByModel } from "@/repository/model-price";
  61. import { getSystemSettings } from "@/repository/system-config";
  62. function makeSystemSettings(
  63. billingModelSource: SystemSettings["billingModelSource"]
  64. ): SystemSettings {
  65. const now = new Date();
  66. return {
  67. id: 1,
  68. siteTitle: "test",
  69. allowGlobalUsageView: false,
  70. currencyDisplay: "USD",
  71. billingModelSource,
  72. codexPriorityBillingSource: "requested",
  73. timezone: null,
  74. enableAutoCleanup: false,
  75. cleanupRetentionDays: 30,
  76. cleanupSchedule: "0 2 * * *",
  77. cleanupBatchSize: 10000,
  78. enableClientVersionCheck: false,
  79. verboseProviderError: false,
  80. enableHttp2: false,
  81. enableHighConcurrencyMode: false,
  82. interceptAnthropicWarmupRequests: false,
  83. enableThinkingSignatureRectifier: true,
  84. enableThinkingBudgetRectifier: true,
  85. enableBillingHeaderRectifier: true,
  86. enableResponseInputRectifier: true,
  87. enableCodexSessionIdCompletion: true,
  88. enableClaudeMetadataUserIdInjection: true,
  89. enableResponseFixer: true,
  90. responseFixerConfig: {
  91. fixTruncatedJson: true,
  92. fixSseFormat: true,
  93. fixEncoding: true,
  94. maxJsonDepth: 200,
  95. maxFixSize: 1024 * 1024,
  96. },
  97. quotaDbRefreshIntervalSeconds: 10,
  98. quotaLeasePercent5h: 0.05,
  99. quotaLeasePercentDaily: 0.05,
  100. quotaLeasePercentWeekly: 0.05,
  101. quotaLeasePercentMonthly: 0.05,
  102. quotaLeaseCapUsd: null,
  103. createdAt: now,
  104. updatedAt: now,
  105. };
  106. }
  107. function createSession({
  108. originalModel,
  109. redirectedModel,
  110. }: {
  111. originalModel: string;
  112. redirectedModel: string;
  113. }): ProxySession {
  114. const session = new (
  115. ProxySession as unknown as {
  116. new (init: {
  117. startTime: number;
  118. method: string;
  119. requestUrl: URL;
  120. headers: Headers;
  121. headerLog: string;
  122. request: { message: Record<string, unknown>; log: string; model: string | null };
  123. userAgent: string | null;
  124. context: unknown;
  125. clientAbortSignal: AbortSignal | null;
  126. }): ProxySession;
  127. }
  128. )({
  129. startTime: Date.now(),
  130. method: "POST",
  131. requestUrl: new URL("http://localhost/v1/messages"),
  132. headers: new Headers(),
  133. headerLog: "",
  134. request: { message: {}, log: "(test)", model: redirectedModel },
  135. userAgent: null,
  136. context: {},
  137. clientAbortSignal: null,
  138. });
  139. session.setOriginalModel(originalModel);
  140. session.setSessionId("sess-test");
  141. const provider = {
  142. id: 99,
  143. name: "test-provider",
  144. providerType: "claude",
  145. costMultiplier: 1.0,
  146. streamingIdleTimeoutMs: 0,
  147. } as any;
  148. const user = {
  149. id: 123,
  150. name: "test-user",
  151. dailyResetTime: "00:00",
  152. dailyResetMode: "fixed",
  153. } as any;
  154. const key = {
  155. id: 456,
  156. name: "test-key",
  157. dailyResetTime: "00:00",
  158. dailyResetMode: "fixed",
  159. } as any;
  160. session.setProvider(provider);
  161. session.setAuthState({
  162. user,
  163. key,
  164. apiKey: "sk-test",
  165. success: true,
  166. });
  167. session.setMessageContext({
  168. id: 2000,
  169. createdAt: new Date(),
  170. user,
  171. key,
  172. apiKey: "sk-test",
  173. });
  174. return session;
  175. }
  176. describe("价格表缺失/查询失败:请求不计费且不报错", () => {
  177. beforeEach(() => {
  178. cloudSyncRequests.splice(0, cloudSyncRequests.length);
  179. vi.clearAllMocks();
  180. });
  181. it("无 usageMetrics 时仍应写入 request details", async () => {
  182. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  183. vi.mocked(findLatestPriceByModel).mockResolvedValue(null);
  184. const session = createSession({ originalModel: "m1", redirectedModel: "m2" });
  185. await finalizeRequestStats(
  186. session,
  187. JSON.stringify({ type: "message", usage: null }),
  188. 502,
  189. 9,
  190. "bad upstream"
  191. );
  192. expect(updateMessageRequestDetails).toHaveBeenCalledWith(
  193. 2000,
  194. expect.objectContaining({
  195. statusCode: 502,
  196. errorMessage: "bad upstream",
  197. model: "m2",
  198. providerId: 99,
  199. })
  200. );
  201. expect(updateMessageRequestCost).not.toHaveBeenCalled();
  202. });
  203. it("无价格:应跳过 DB cost 更新与限流 cost 追踪,并触发异步同步", async () => {
  204. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  205. vi.mocked(findLatestPriceByModel).mockResolvedValue(null);
  206. const session = createSession({ originalModel: "m1", redirectedModel: "m2" });
  207. const responseText = JSON.stringify({
  208. type: "message",
  209. usage: { input_tokens: 2, output_tokens: 3 },
  210. });
  211. await finalizeRequestStats(session, responseText, 200, 5);
  212. expect(updateMessageRequestCost).not.toHaveBeenCalled();
  213. expect(RateLimitService.trackCost).not.toHaveBeenCalled();
  214. expect(findLatestPriceByModel).toHaveBeenCalled();
  215. expect(cloudSyncRequests).toEqual([{ reason: "missing-model" }]);
  216. });
  217. it("价格数据为空对象:应视为无价格并触发异步同步", async () => {
  218. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  219. vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => {
  220. if (modelName === "m2") {
  221. return {
  222. id: 1,
  223. modelName: "m2",
  224. priceData: {},
  225. source: "litellm",
  226. createdAt: new Date(),
  227. updatedAt: new Date(),
  228. } as any;
  229. }
  230. return null;
  231. });
  232. const session = createSession({ originalModel: "m1", redirectedModel: "m2" });
  233. const responseText = JSON.stringify({
  234. type: "message",
  235. usage: { input_tokens: 2, output_tokens: 3 },
  236. });
  237. await finalizeRequestStats(session, responseText, 200, 5);
  238. expect(updateMessageRequestCost).not.toHaveBeenCalled();
  239. expect(RateLimitService.trackCost).not.toHaveBeenCalled();
  240. expect(cloudSyncRequests).toEqual([{ reason: "missing-model" }]);
  241. });
  242. it("价格查询抛错:应跳过计费且不影响响应", async () => {
  243. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original"));
  244. vi.mocked(findLatestPriceByModel).mockImplementation(async () => {
  245. throw new Error("db query failed");
  246. });
  247. const session = createSession({ originalModel: "m1", redirectedModel: "m2" });
  248. const responseText = JSON.stringify({
  249. type: "message",
  250. usage: { input_tokens: 2, output_tokens: 3 },
  251. });
  252. await finalizeRequestStats(session, responseText, 200, 5);
  253. expect(updateMessageRequestCost).not.toHaveBeenCalled();
  254. expect(RateLimitService.trackCost).not.toHaveBeenCalled();
  255. });
  256. });