dispatch-simulator.test.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import type { Provider } from "@/types/provider";
  3. const authMocks = vi.hoisted(() => ({
  4. getSession: vi.fn(),
  5. }));
  6. const circuitBreakerMocks = vi.hoisted(() => ({
  7. isCircuitOpen: vi.fn(async () => false),
  8. getCircuitState: vi.fn(() => "closed"),
  9. }));
  10. const vendorCircuitMocks = vi.hoisted(() => ({
  11. isVendorTypeCircuitOpen: vi.fn(async () => false),
  12. }));
  13. const rateLimitMocks = vi.hoisted(() => ({
  14. checkCostLimits: vi.fn(async () => ({ allowed: true })),
  15. checkCostLimitsWithLease: vi.fn(async () => ({ allowed: true })),
  16. checkTotalCostLimit: vi.fn(async () => ({ allowed: true })),
  17. }));
  18. const endpointSelectorMocks = vi.hoisted(() => ({
  19. getEndpointFilterStats: vi.fn(async () => ({
  20. total: 2,
  21. enabled: 2,
  22. circuitOpen: 0,
  23. available: 2,
  24. })),
  25. }));
  26. const timezoneMocks = vi.hoisted(() => ({
  27. resolveSystemTimezone: vi.fn(async () => "UTC"),
  28. }));
  29. const repositoryMocks = vi.hoisted(() => ({
  30. findAllProvidersFresh: vi.fn(async () => []),
  31. }));
  32. vi.mock("@/lib/auth", () => authMocks);
  33. vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks);
  34. vi.mock("@/lib/vendor-type-circuit-breaker", () => vendorCircuitMocks);
  35. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  36. getAllEndpointHealthStatusAsync: vi.fn(async () => ({})),
  37. }));
  38. vi.mock("@/lib/provider-endpoints/endpoint-selector", () => endpointSelectorMocks);
  39. vi.mock("@/lib/rate-limit", () => ({
  40. RateLimitService: rateLimitMocks,
  41. }));
  42. vi.mock("@/lib/utils/timezone", () => timezoneMocks);
  43. vi.mock("@/repository/provider", () => repositoryMocks);
  44. function createProvider(id: number, overrides: Partial<Provider> = {}): Provider {
  45. return {
  46. id,
  47. name: `provider-${id}`,
  48. url: `https://provider-${id}.example.com`,
  49. key: `sk-${id}`,
  50. providerVendorId: id,
  51. isEnabled: true,
  52. weight: 1,
  53. priority: 0,
  54. groupPriorities: null,
  55. costMultiplier: 1,
  56. groupTag: "alpha",
  57. providerType: "claude",
  58. preserveClientIp: false,
  59. disableSessionReuse: false,
  60. modelRedirects: null,
  61. activeTimeStart: null,
  62. activeTimeEnd: null,
  63. allowedModels: null,
  64. allowedClients: [],
  65. blockedClients: [],
  66. mcpPassthroughType: "none",
  67. mcpPassthroughUrl: null,
  68. limit5hUsd: null,
  69. limitDailyUsd: null,
  70. dailyResetMode: "fixed",
  71. dailyResetTime: "00:00",
  72. limitWeeklyUsd: null,
  73. limitMonthlyUsd: null,
  74. limitTotalUsd: null,
  75. totalCostResetAt: null,
  76. limitConcurrentSessions: 0,
  77. maxRetryAttempts: null,
  78. circuitBreakerFailureThreshold: 3,
  79. circuitBreakerOpenDuration: 60_000,
  80. circuitBreakerHalfOpenSuccessThreshold: 1,
  81. proxyUrl: null,
  82. proxyFallbackToDirect: false,
  83. firstByteTimeoutStreamingMs: 30_000,
  84. streamingIdleTimeoutMs: 60_000,
  85. requestTimeoutNonStreamingMs: 120_000,
  86. websiteUrl: null,
  87. faviconUrl: null,
  88. cacheTtlPreference: null,
  89. swapCacheTtlBilling: false,
  90. context1mPreference: null,
  91. codexReasoningEffortPreference: null,
  92. codexReasoningSummaryPreference: null,
  93. codexTextVerbosityPreference: null,
  94. codexParallelToolCallsPreference: null,
  95. codexServiceTierPreference: null,
  96. anthropicMaxTokensPreference: null,
  97. anthropicThinkingBudgetPreference: null,
  98. anthropicAdaptiveThinking: null,
  99. geminiGoogleSearchPreference: null,
  100. tpm: null,
  101. rpm: null,
  102. rpd: null,
  103. cc: null,
  104. createdAt: new Date("2026-01-01T00:00:00.000Z"),
  105. updatedAt: new Date("2026-01-01T00:00:00.000Z"),
  106. ...overrides,
  107. } as Provider;
  108. }
  109. describe("dispatch simulator", () => {
  110. beforeEach(() => {
  111. vi.clearAllMocks();
  112. endpointSelectorMocks.getEndpointFilterStats.mockResolvedValue({
  113. total: 2,
  114. enabled: 2,
  115. circuitOpen: 0,
  116. available: 2,
  117. });
  118. });
  119. test("simulates the decision chain and priority tiers end-to-end", async () => {
  120. const { simulateDispatchDecisionTree } = await import("@/actions/dispatch-simulator");
  121. rateLimitMocks.checkCostLimits.mockImplementation(async (entityId: number) =>
  122. entityId === 3
  123. ? { allowed: false, reason: "Provider daily cost limit reached" }
  124. : { allowed: true }
  125. );
  126. const providers: Provider[] = [
  127. createProvider(1, { name: "group-miss", groupTag: "beta" }),
  128. createProvider(2, { name: "format-miss", providerType: "openai-compatible" }),
  129. createProvider(3, {
  130. name: "rate-limited",
  131. priority: 1,
  132. allowedModels: [{ matchType: "prefix", pattern: "claude-" }],
  133. }),
  134. createProvider(4, {
  135. name: "winner",
  136. priority: 0,
  137. weight: 3,
  138. allowedModels: [{ matchType: "prefix", pattern: "claude-" }],
  139. modelRedirects: [{ matchType: "prefix", source: "claude-opus-", target: "glm-4.6" }],
  140. }),
  141. createProvider(5, {
  142. name: "backup",
  143. priority: 2,
  144. weight: 1,
  145. allowedModels: [{ matchType: "prefix", pattern: "claude-" }],
  146. providerVendorId: null,
  147. }),
  148. ];
  149. const result = await simulateDispatchDecisionTree(
  150. providers,
  151. {
  152. clientFormat: "claude",
  153. modelName: "claude-opus-4-1",
  154. groupTags: ["alpha"],
  155. },
  156. { systemTimezone: "UTC" }
  157. );
  158. expect(result.steps.map((step) => step.stepName)).toEqual([
  159. "groupFilter",
  160. "formatCompatibility",
  161. "enabledCheck",
  162. "activeTime",
  163. "modelAllowlist",
  164. "healthAndLimits",
  165. "priorityTiers",
  166. "modelRedirect",
  167. "endpointSummary",
  168. ]);
  169. expect(result.steps[0].outputCount).toBe(4);
  170. expect(result.steps[1].outputCount).toBe(3);
  171. expect(result.steps[5].outputCount).toBe(2);
  172. expect(result.steps[7].outputCount).toBe(1);
  173. expect(result.steps[8].outputCount).toBe(1);
  174. expect(result.priorityTiers).toHaveLength(2);
  175. expect(result.selectedPriority).toBe(0);
  176. expect(result.finalCandidateCount).toBe(1);
  177. expect(result.priorityTiers[0].providers[0].name).toBe("winner");
  178. expect(
  179. result.steps[7].surviving.find((provider) => provider.name === "winner")?.redirectedModel
  180. ).toBe("glm-4.6");
  181. expect(
  182. result.steps[8].surviving.find((provider) => provider.name === "winner")?.endpointStats
  183. ).toEqual({
  184. total: 2,
  185. enabled: 2,
  186. circuitOpen: 0,
  187. available: 2,
  188. });
  189. });
  190. test("skips model allowlist filtering for resource-style requests without model", async () => {
  191. const { simulateDispatchDecisionTree } = await import("@/actions/dispatch-simulator");
  192. const result = await simulateDispatchDecisionTree(
  193. [
  194. createProvider(10, {
  195. groupTag: "default",
  196. providerType: "openai-compatible",
  197. allowedModels: [{ matchType: "exact", pattern: "guarded-model" }],
  198. }),
  199. ],
  200. {
  201. clientFormat: "openai",
  202. modelName: "",
  203. groupTags: [],
  204. },
  205. { systemTimezone: "UTC" }
  206. );
  207. expect(result.steps[0].stepName).toBe("groupFilter");
  208. expect(result.steps[0].outputCount).toBe(1);
  209. expect(result.steps[4].stepName).toBe("modelAllowlist");
  210. expect(result.steps[4].note).toBe("model_filter_skipped_for_resource_request");
  211. expect(result.steps[4].outputCount).toBe(1);
  212. });
  213. test("accepts gemini-cli format and keeps gemini-cli providers eligible", async () => {
  214. const { simulateDispatchDecisionTree } = await import("@/actions/dispatch-simulator");
  215. const result = await simulateDispatchDecisionTree(
  216. [
  217. createProvider(20, {
  218. groupTag: "default",
  219. providerType: "gemini-cli",
  220. allowedModels: [{ matchType: "exact", pattern: "gemini-2.5-pro" }],
  221. }),
  222. ],
  223. {
  224. clientFormat: "gemini-cli",
  225. modelName: "",
  226. groupTags: [],
  227. },
  228. { systemTimezone: "UTC" }
  229. );
  230. expect(result.steps[0].stepName).toBe("groupFilter");
  231. expect(result.steps[0].outputCount).toBe(1);
  232. expect(result.steps[1].stepName).toBe("formatCompatibility");
  233. expect(result.steps[1].outputCount).toBe(1);
  234. expect(result.finalCandidateCount).toBe(1);
  235. });
  236. test("server action rejects non-admin callers", async () => {
  237. const { simulateDispatchAction } = await import("@/actions/dispatch-simulator");
  238. authMocks.getSession.mockResolvedValue(null);
  239. const result = await simulateDispatchAction({
  240. clientFormat: "claude",
  241. modelName: "claude-opus-4-1",
  242. groupTags: [],
  243. });
  244. expect(result.ok).toBe(false);
  245. expect(result.errorCode).toBe("PERMISSION_DENIED");
  246. });
  247. });