provider-selector-model-mismatch-binding.test.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import type { Provider } from "@/types/provider";
  3. const circuitBreakerMocks = vi.hoisted(() => ({
  4. isCircuitOpen: vi.fn(async () => false),
  5. getCircuitState: vi.fn(() => "closed"),
  6. }));
  7. vi.mock("@/lib/circuit-breaker", () => circuitBreakerMocks);
  8. const vendorTypeCircuitMocks = vi.hoisted(() => ({
  9. isVendorTypeCircuitOpen: vi.fn(async () => false),
  10. }));
  11. vi.mock("@/lib/vendor-type-circuit-breaker", () => vendorTypeCircuitMocks);
  12. const sessionManagerMocks = vi.hoisted(() => ({
  13. SessionManager: {
  14. getSessionProvider: vi.fn(async () => null as number | null),
  15. clearSessionProvider: vi.fn(async () => undefined),
  16. },
  17. }));
  18. vi.mock("@/lib/session-manager", () => sessionManagerMocks);
  19. const providerRepositoryMocks = vi.hoisted(() => ({
  20. findProviderById: vi.fn(async () => null as Provider | null),
  21. findAllProviders: vi.fn(async () => [] as Provider[]),
  22. }));
  23. vi.mock("@/repository/provider", () => providerRepositoryMocks);
  24. const rateLimitMocks = vi.hoisted(() => ({
  25. RateLimitService: {
  26. checkCostLimitsWithLease: vi.fn(async () => ({ allowed: true })),
  27. checkTotalCostLimit: vi.fn(async () => ({ allowed: true, current: 0 })),
  28. },
  29. }));
  30. vi.mock("@/lib/rate-limit", () => rateLimitMocks);
  31. beforeEach(() => {
  32. vi.resetAllMocks();
  33. });
  34. function createHaikuOnlyProvider(): Provider {
  35. return {
  36. id: 78,
  37. name: "zhipu_Haiku",
  38. isEnabled: true,
  39. providerType: "claude",
  40. groupTag: null,
  41. weight: 1,
  42. priority: 1,
  43. costMultiplier: 1,
  44. disableSessionReuse: false,
  45. allowedModels: ["claude-haiku-4-5-20251001", "claude-haiku-4-5"],
  46. providerVendorId: null,
  47. limit5hUsd: null,
  48. limitDailyUsd: null,
  49. dailyResetMode: "fixed",
  50. dailyResetTime: "00:00",
  51. limitWeeklyUsd: null,
  52. limitMonthlyUsd: null,
  53. limitTotalUsd: null,
  54. totalCostResetAt: null,
  55. limitConcurrentSessions: 0,
  56. } as unknown as Provider;
  57. }
  58. function createOpusProvider(): Provider {
  59. return {
  60. id: 94,
  61. name: "yescode_team",
  62. isEnabled: true,
  63. providerType: "claude",
  64. groupTag: null,
  65. weight: 1,
  66. priority: 0,
  67. costMultiplier: 1,
  68. allowedModels: null, // supports all claude models
  69. providerVendorId: null,
  70. limit5hUsd: null,
  71. limitDailyUsd: null,
  72. dailyResetMode: "fixed",
  73. dailyResetTime: "00:00",
  74. limitWeeklyUsd: null,
  75. limitMonthlyUsd: null,
  76. limitTotalUsd: null,
  77. totalCostResetAt: null,
  78. limitConcurrentSessions: 0,
  79. } as unknown as Provider;
  80. }
  81. describe("findReusable - model mismatch clears stale binding", () => {
  82. test("should clear binding when bound provider disables session reuse", async () => {
  83. const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
  84. sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(78);
  85. const provider = createHaikuOnlyProvider();
  86. providerRepositoryMocks.findProviderById.mockResolvedValueOnce({
  87. ...provider,
  88. disableSessionReuse: true,
  89. } as Provider);
  90. const session = {
  91. sessionId: "sess_disable_reuse",
  92. shouldReuseProvider: () => true,
  93. getOriginalModel: () => "claude-haiku-4-5-20251001",
  94. authState: null,
  95. getCurrentModel: () => null,
  96. } as any;
  97. const result = await (ProxyProviderResolver as any).findReusable(session);
  98. expect(result).toBeNull();
  99. expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith(
  100. "sess_disable_reuse"
  101. );
  102. });
  103. test("should clear stale binding when bound provider does not support requested model", async () => {
  104. const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
  105. // Session bound to haiku-only provider
  106. sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(78);
  107. providerRepositoryMocks.findProviderById.mockResolvedValueOnce(createHaikuOnlyProvider());
  108. const session = {
  109. sessionId: "4c25cf92",
  110. shouldReuseProvider: () => true,
  111. getOriginalModel: () => "claude-opus-4-6",
  112. authState: null,
  113. getCurrentModel: () => null,
  114. } as any;
  115. const result = await (ProxyProviderResolver as any).findReusable(session);
  116. expect(result).toBeNull();
  117. // Key assertion: clearSessionProvider should have been called
  118. expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith(
  119. "4c25cf92"
  120. );
  121. });
  122. test("should NOT clear binding when bound provider supports requested model", async () => {
  123. const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
  124. // Session bound to provider that supports all claude models
  125. sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(94);
  126. providerRepositoryMocks.findProviderById.mockResolvedValueOnce(createOpusProvider());
  127. rateLimitMocks.RateLimitService.checkCostLimitsWithLease.mockResolvedValueOnce({
  128. allowed: true,
  129. });
  130. rateLimitMocks.RateLimitService.checkTotalCostLimit.mockResolvedValueOnce({
  131. allowed: true,
  132. current: 0,
  133. });
  134. const session = {
  135. sessionId: "sess_ok",
  136. shouldReuseProvider: () => true,
  137. getOriginalModel: () => "claude-opus-4-6",
  138. authState: null,
  139. getCurrentModel: () => null,
  140. } as any;
  141. const result = await (ProxyProviderResolver as any).findReusable(session);
  142. // Should return the provider (model matches)
  143. expect(result).not.toBeNull();
  144. expect(result?.id).toBe(94);
  145. // clearSessionProvider should NOT have been called
  146. expect(sessionManagerMocks.SessionManager.clearSessionProvider).not.toHaveBeenCalled();
  147. });
  148. test("should NOT clear binding when shouldReuseProvider returns false", async () => {
  149. const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
  150. const session = {
  151. sessionId: "sess_short",
  152. shouldReuseProvider: () => false,
  153. getOriginalModel: () => "claude-opus-4-6",
  154. authState: null,
  155. } as any;
  156. const result = await (ProxyProviderResolver as any).findReusable(session);
  157. expect(result).toBeNull();
  158. // Should not even reach the model check, so no clear
  159. expect(sessionManagerMocks.SessionManager.clearSessionProvider).not.toHaveBeenCalled();
  160. expect(sessionManagerMocks.SessionManager.getSessionProvider).not.toHaveBeenCalled();
  161. });
  162. test("should clear binding for haiku-only provider when requesting haiku-4-5 variant not in allowlist", async () => {
  163. const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
  164. sessionManagerMocks.SessionManager.getSessionProvider.mockResolvedValueOnce(78);
  165. const provider = createHaikuOnlyProvider();
  166. // Restrictive allowlist - only allows specific variant
  167. provider.allowedModels = ["claude-haiku-4-5-20251001"];
  168. providerRepositoryMocks.findProviderById.mockResolvedValueOnce(provider);
  169. const session = {
  170. sessionId: "sess_variant",
  171. shouldReuseProvider: () => true,
  172. getOriginalModel: () => "claude-sonnet-4-5-20250929",
  173. authState: null,
  174. getCurrentModel: () => null,
  175. } as any;
  176. const result = await (ProxyProviderResolver as any).findReusable(session);
  177. expect(result).toBeNull();
  178. expect(sessionManagerMocks.SessionManager.clearSessionProvider).toHaveBeenCalledWith(
  179. "sess_variant"
  180. );
  181. });
  182. });