provider-selector-format-compatibility.test.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  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. describe("ProxyProviderResolver.pickRandomProvider - format/providerType compatibility", () => {
  9. beforeEach(() => {
  10. vi.clearAllMocks();
  11. });
  12. function createSessionStub(originalFormat: string, providers: Provider[], originalModel: string) {
  13. return {
  14. originalFormat,
  15. authState: null,
  16. getProvidersSnapshot: async () => providers,
  17. getOriginalModel: () => originalModel,
  18. getCurrentModel: () => originalModel,
  19. clientRequestsContext1m: () => false,
  20. } as any;
  21. }
  22. function createProvider(
  23. id: number,
  24. providerType: string,
  25. overrides: Partial<Provider> = {}
  26. ): Provider {
  27. return {
  28. id,
  29. name: `provider-${id}`,
  30. isEnabled: true,
  31. providerType,
  32. groupTag: null,
  33. weight: 1,
  34. priority: 0,
  35. costMultiplier: 1,
  36. allowedModels: null,
  37. ...overrides,
  38. } as unknown as Provider;
  39. }
  40. async function setupResolverMocks() {
  41. const { ProxyProviderResolver } = await import("@/app/v1/_lib/proxy/provider-selector");
  42. vi.spyOn(ProxyProviderResolver as any, "filterByLimits").mockImplementation(
  43. async (...args: unknown[]) => args[0] as Provider[]
  44. );
  45. vi.spyOn(ProxyProviderResolver as any, "selectTopPriority").mockImplementation(
  46. (...args: unknown[]) => args[0] as Provider[]
  47. );
  48. vi.spyOn(ProxyProviderResolver as any, "selectOptimal").mockImplementation(
  49. (...args: unknown[]) => (args[0] as Provider[])[0] ?? null
  50. );
  51. return ProxyProviderResolver;
  52. }
  53. test("openai format rejects claude provider, selects openai-compatible", async () => {
  54. const ProxyProviderResolver = await setupResolverMocks();
  55. const incompatible = createProvider(1, "claude");
  56. const compatible = createProvider(2, "openai-compatible");
  57. const session = createSessionStub("openai", [incompatible, compatible], "gpt-4o");
  58. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  59. session,
  60. []
  61. );
  62. expect(provider?.id).toBe(2);
  63. expect(provider?.providerType).toBe("openai-compatible");
  64. const mismatch = context.filteredProviders.find(
  65. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  66. );
  67. expect(mismatch).toBeDefined();
  68. expect(mismatch.details).toContain("openai");
  69. expect(mismatch.details).toContain("claude");
  70. });
  71. test("openai format rejects codex provider, selects openai-compatible", async () => {
  72. const ProxyProviderResolver = await setupResolverMocks();
  73. const incompatible = createProvider(1, "codex");
  74. const compatible = createProvider(2, "openai-compatible");
  75. const session = createSessionStub("openai", [incompatible, compatible], "gpt-4o");
  76. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  77. session,
  78. []
  79. );
  80. expect(provider?.id).toBe(2);
  81. expect(provider?.providerType).toBe("openai-compatible");
  82. const mismatch = context.filteredProviders.find(
  83. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  84. );
  85. expect(mismatch).toBeDefined();
  86. });
  87. test("response format rejects openai-compatible provider, selects codex", async () => {
  88. const ProxyProviderResolver = await setupResolverMocks();
  89. const incompatible = createProvider(1, "openai-compatible");
  90. const compatible = createProvider(2, "codex");
  91. const session = createSessionStub("response", [incompatible, compatible], "codex-mini-latest");
  92. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  93. session,
  94. []
  95. );
  96. expect(provider?.id).toBe(2);
  97. expect(provider?.providerType).toBe("codex");
  98. const mismatch = context.filteredProviders.find(
  99. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  100. );
  101. expect(mismatch).toBeDefined();
  102. expect(mismatch.details).toContain("response");
  103. expect(mismatch.details).toContain("openai-compatible");
  104. });
  105. test("response format rejects claude provider, selects codex", async () => {
  106. const ProxyProviderResolver = await setupResolverMocks();
  107. const incompatible = createProvider(1, "claude");
  108. const compatible = createProvider(2, "codex");
  109. const session = createSessionStub("response", [incompatible, compatible], "codex-mini-latest");
  110. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  111. session,
  112. []
  113. );
  114. expect(provider?.id).toBe(2);
  115. expect(provider?.providerType).toBe("codex");
  116. const mismatch = context.filteredProviders.find(
  117. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  118. );
  119. expect(mismatch).toBeDefined();
  120. });
  121. test("claude format rejects openai-compatible provider, selects claude", async () => {
  122. const ProxyProviderResolver = await setupResolverMocks();
  123. const incompatible = createProvider(1, "openai-compatible");
  124. const compatible = createProvider(2, "claude");
  125. const session = createSessionStub(
  126. "claude",
  127. [incompatible, compatible],
  128. "claude-sonnet-4-20250514"
  129. );
  130. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  131. session,
  132. []
  133. );
  134. expect(provider?.id).toBe(2);
  135. expect(provider?.providerType).toBe("claude");
  136. const mismatch = context.filteredProviders.find(
  137. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  138. );
  139. expect(mismatch).toBeDefined();
  140. expect(mismatch.details).toContain("claude");
  141. expect(mismatch.details).toContain("openai-compatible");
  142. });
  143. test("claude format accepts claude-auth provider", async () => {
  144. const ProxyProviderResolver = await setupResolverMocks();
  145. const incompatible = createProvider(1, "codex");
  146. const compatible = createProvider(2, "claude-auth");
  147. const session = createSessionStub(
  148. "claude",
  149. [incompatible, compatible],
  150. "claude-sonnet-4-20250514"
  151. );
  152. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  153. session,
  154. []
  155. );
  156. expect(provider?.id).toBe(2);
  157. expect(provider?.providerType).toBe("claude-auth");
  158. const mismatch = context.filteredProviders.find(
  159. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  160. );
  161. expect(mismatch).toBeDefined();
  162. });
  163. test("gemini format rejects claude provider, selects gemini", async () => {
  164. const ProxyProviderResolver = await setupResolverMocks();
  165. const incompatible = createProvider(1, "claude");
  166. const compatible = createProvider(2, "gemini");
  167. const session = createSessionStub("gemini", [incompatible, compatible], "gemini-2.0-flash");
  168. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  169. session,
  170. []
  171. );
  172. expect(provider?.id).toBe(2);
  173. expect(provider?.providerType).toBe("gemini");
  174. const mismatch = context.filteredProviders.find(
  175. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  176. );
  177. expect(mismatch).toBeDefined();
  178. expect(mismatch.details).toContain("gemini");
  179. });
  180. test("gemini-cli format rejects gemini provider, selects gemini-cli", async () => {
  181. const ProxyProviderResolver = await setupResolverMocks();
  182. const incompatible = createProvider(1, "gemini");
  183. const compatible = createProvider(2, "gemini-cli");
  184. const session = createSessionStub("gemini-cli", [incompatible, compatible], "gemini-2.0-flash");
  185. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  186. session,
  187. []
  188. );
  189. expect(provider?.id).toBe(2);
  190. expect(provider?.providerType).toBe("gemini-cli");
  191. const mismatch = context.filteredProviders.find(
  192. (fp: any) => fp.id === 1 && fp.reason === "format_type_mismatch"
  193. );
  194. expect(mismatch).toBeDefined();
  195. expect(mismatch.details).toContain("gemini-cli");
  196. expect(mismatch.details).toContain("gemini");
  197. });
  198. test("returns null when no compatible providers exist for response format", async () => {
  199. const ProxyProviderResolver = await setupResolverMocks();
  200. const p1 = createProvider(1, "claude");
  201. const p2 = createProvider(2, "openai-compatible");
  202. const session = createSessionStub("response", [p1, p2], "codex-mini-latest");
  203. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  204. session,
  205. []
  206. );
  207. expect(provider).toBeNull();
  208. const mismatches = context.filteredProviders.filter(
  209. (fp: any) => fp.reason === "format_type_mismatch"
  210. );
  211. expect(mismatches.length).toBe(2);
  212. });
  213. test("multiple incompatible providers are all recorded in filteredProviders", async () => {
  214. const ProxyProviderResolver = await setupResolverMocks();
  215. const p1 = createProvider(1, "claude");
  216. const p2 = createProvider(2, "codex");
  217. const p3 = createProvider(3, "gemini");
  218. const compatible = createProvider(4, "openai-compatible");
  219. const session = createSessionStub("openai", [p1, p2, p3, compatible], "gpt-4o");
  220. const { provider, context } = await (ProxyProviderResolver as any).pickRandomProvider(
  221. session,
  222. []
  223. );
  224. expect(provider?.id).toBe(4);
  225. const mismatches = context.filteredProviders.filter(
  226. (fp: any) => fp.reason === "format_type_mismatch"
  227. );
  228. expect(mismatches.length).toBe(3);
  229. expect(mismatches.map((m: any) => m.id).sort()).toEqual([1, 2, 3]);
  230. });
  231. });