endpoint-selector.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import { describe, expect, test, vi } from "vitest";
  2. import type { ProviderEndpoint } from "@/types/provider";
  3. function makeEndpoint(overrides: Partial<ProviderEndpoint>): ProviderEndpoint {
  4. return {
  5. id: 1,
  6. vendorId: 1,
  7. providerType: "claude",
  8. url: "https://example.com",
  9. label: null,
  10. sortOrder: 0,
  11. isEnabled: true,
  12. lastProbedAt: null,
  13. lastProbeOk: null,
  14. lastProbeStatusCode: null,
  15. lastProbeLatencyMs: null,
  16. lastProbeErrorType: null,
  17. lastProbeErrorMessage: null,
  18. createdAt: new Date(0),
  19. updatedAt: new Date(0),
  20. deletedAt: null,
  21. ...overrides,
  22. };
  23. }
  24. describe("provider-endpoints: endpoint-selector", () => {
  25. test("rankProviderEndpoints 应过滤 disabled/deleted,并按 lastProbeOk/sortOrder/latency/id 排序", async () => {
  26. vi.resetModules();
  27. vi.doMock("@/repository", () => ({
  28. findProviderEndpointsByVendorAndType: vi.fn(),
  29. }));
  30. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  31. isEndpointCircuitOpen: vi.fn(),
  32. }));
  33. const { rankProviderEndpoints } = await import("@/lib/provider-endpoints/endpoint-selector");
  34. const healthyHighOrder = makeEndpoint({
  35. id: 10,
  36. lastProbeOk: true,
  37. sortOrder: 1,
  38. lastProbeLatencyMs: 50,
  39. });
  40. const healthyLowOrder = makeEndpoint({
  41. id: 11,
  42. lastProbeOk: true,
  43. sortOrder: 0,
  44. lastProbeLatencyMs: 999,
  45. });
  46. const unknownFast = makeEndpoint({
  47. id: 20,
  48. lastProbeOk: null,
  49. sortOrder: 0,
  50. lastProbeLatencyMs: 10,
  51. });
  52. const unknownNoLatency = makeEndpoint({
  53. id: 21,
  54. lastProbeOk: null,
  55. sortOrder: 0,
  56. lastProbeLatencyMs: null,
  57. });
  58. const unhealthyFast30 = makeEndpoint({
  59. id: 30,
  60. lastProbeOk: false,
  61. sortOrder: 0,
  62. lastProbeLatencyMs: 1,
  63. });
  64. const unhealthyFast31 = makeEndpoint({
  65. id: 31,
  66. lastProbeOk: false,
  67. sortOrder: 0,
  68. lastProbeLatencyMs: 1,
  69. });
  70. const disabled = makeEndpoint({ id: 40, isEnabled: false, lastProbeOk: true });
  71. const deleted = makeEndpoint({ id: 41, deletedAt: new Date(1), lastProbeOk: true });
  72. const ranked = rankProviderEndpoints([
  73. healthyHighOrder,
  74. healthyLowOrder,
  75. unknownFast,
  76. unknownNoLatency,
  77. unhealthyFast30,
  78. unhealthyFast31,
  79. disabled,
  80. deleted,
  81. ]);
  82. expect(ranked.map((e) => e.id)).toEqual([11, 10, 20, 21, 30, 31]);
  83. });
  84. test("getPreferredProviderEndpoints 应排除禁用/已删除/显式 exclude/熔断 open 的端点,并返回排序结果", async () => {
  85. vi.resetModules();
  86. const endpoints: ProviderEndpoint[] = [
  87. makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 20 }),
  88. makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 999 }),
  89. makeEndpoint({ id: 3, lastProbeOk: null, sortOrder: 0, lastProbeLatencyMs: 10 }),
  90. makeEndpoint({ id: 4, isEnabled: false }),
  91. makeEndpoint({ id: 5, deletedAt: new Date(1) }),
  92. makeEndpoint({ id: 6, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 1 }),
  93. ];
  94. const findMock = vi.fn(async () => endpoints);
  95. const isOpenMock = vi.fn(async (endpointId: number) => endpointId === 2);
  96. vi.doMock("@/repository", () => ({
  97. findProviderEndpointsByVendorAndType: findMock,
  98. }));
  99. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  100. isEndpointCircuitOpen: isOpenMock,
  101. }));
  102. vi.doMock("@/lib/config/env.schema", () => ({
  103. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  104. }));
  105. const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import(
  106. "@/lib/provider-endpoints/endpoint-selector"
  107. );
  108. const result = await getPreferredProviderEndpoints({
  109. vendorId: 123,
  110. providerType: "claude",
  111. excludeEndpointIds: [6],
  112. });
  113. expect(findMock).toHaveBeenCalledWith(123, "claude");
  114. expect(isOpenMock.mock.calls.map((c) => c[0])).toEqual([1, 2, 3]);
  115. expect(result.map((e) => e.id)).toEqual([1, 3]);
  116. const best = await pickBestProviderEndpoint({ vendorId: 123, providerType: "claude" });
  117. expect(best?.id).toBe(1);
  118. });
  119. test("getPreferredProviderEndpoints 过滤后无候选时返回空数组", async () => {
  120. vi.resetModules();
  121. const findMock = vi.fn(async () => [makeEndpoint({ id: 1, isEnabled: false })]);
  122. const isOpenMock = vi.fn(async () => false);
  123. vi.doMock("@/repository", () => ({
  124. findProviderEndpointsByVendorAndType: findMock,
  125. }));
  126. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  127. isEndpointCircuitOpen: isOpenMock,
  128. }));
  129. vi.doMock("@/lib/config/env.schema", () => ({
  130. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  131. }));
  132. const { getPreferredProviderEndpoints, pickBestProviderEndpoint } = await import(
  133. "@/lib/provider-endpoints/endpoint-selector"
  134. );
  135. const result = await getPreferredProviderEndpoints({ vendorId: 1, providerType: "claude" });
  136. expect(result).toEqual([]);
  137. const best = await pickBestProviderEndpoint({ vendorId: 1, providerType: "claude" });
  138. expect(best).toBeNull();
  139. expect(isOpenMock).not.toHaveBeenCalled();
  140. });
  141. });
  142. describe("getEndpointFilterStats", () => {
  143. test("should correctly count total, enabled, circuitOpen, and available endpoints", async () => {
  144. vi.resetModules();
  145. const endpoints: ProviderEndpoint[] = [
  146. makeEndpoint({ id: 1, isEnabled: true, lastProbeOk: true }),
  147. makeEndpoint({ id: 2, isEnabled: true, lastProbeOk: true }),
  148. makeEndpoint({ id: 3, isEnabled: true, lastProbeOk: false }),
  149. makeEndpoint({ id: 4, isEnabled: false }),
  150. makeEndpoint({ id: 5, deletedAt: new Date(1) }),
  151. ];
  152. const findMock = vi.fn(async () => endpoints);
  153. // id=2 is circuit open
  154. const isOpenMock = vi.fn(async (endpointId: number) => endpointId === 2);
  155. vi.doMock("@/repository", () => ({
  156. findProviderEndpointsByVendorAndType: findMock,
  157. }));
  158. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  159. isEndpointCircuitOpen: isOpenMock,
  160. }));
  161. vi.doMock("@/lib/config/env.schema", () => ({
  162. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  163. }));
  164. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  165. const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" });
  166. expect(findMock).toHaveBeenCalledWith(10, "claude");
  167. expect(stats).toEqual({
  168. total: 5, // all endpoints
  169. enabled: 3, // id=1,2,3 (isEnabled && !deletedAt)
  170. circuitOpen: 1, // id=2
  171. available: 2, // enabled - circuitOpen = 3 - 1
  172. });
  173. });
  174. test("should return all zeros when no endpoints exist", async () => {
  175. vi.resetModules();
  176. const findMock = vi.fn(async () => []);
  177. const isOpenMock = vi.fn(async () => false);
  178. vi.doMock("@/repository", () => ({
  179. findProviderEndpointsByVendorAndType: findMock,
  180. }));
  181. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  182. isEndpointCircuitOpen: isOpenMock,
  183. }));
  184. vi.doMock("@/lib/config/env.schema", () => ({
  185. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  186. }));
  187. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  188. const stats = await getEndpointFilterStats({ vendorId: 99, providerType: "codex" });
  189. expect(stats).toEqual({
  190. total: 0,
  191. enabled: 0,
  192. circuitOpen: 0,
  193. available: 0,
  194. });
  195. expect(isOpenMock).not.toHaveBeenCalled();
  196. });
  197. test("should count all enabled endpoints as circuitOpen when all are open", async () => {
  198. vi.resetModules();
  199. const endpoints: ProviderEndpoint[] = [
  200. makeEndpoint({ id: 1, isEnabled: true }),
  201. makeEndpoint({ id: 2, isEnabled: true }),
  202. ];
  203. const findMock = vi.fn(async () => endpoints);
  204. const isOpenMock = vi.fn(async () => true);
  205. vi.doMock("@/repository", () => ({
  206. findProviderEndpointsByVendorAndType: findMock,
  207. }));
  208. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  209. isEndpointCircuitOpen: isOpenMock,
  210. }));
  211. vi.doMock("@/lib/config/env.schema", () => ({
  212. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: true }),
  213. }));
  214. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  215. const stats = await getEndpointFilterStats({ vendorId: 1, providerType: "openai-compatible" });
  216. expect(stats).toEqual({
  217. total: 2,
  218. enabled: 2,
  219. circuitOpen: 2,
  220. available: 0,
  221. });
  222. });
  223. });
  224. describe("ENABLE_ENDPOINT_CIRCUIT_BREAKER disabled", () => {
  225. test("getPreferredProviderEndpoints skips circuit check when disabled", async () => {
  226. vi.resetModules();
  227. const endpoints: ProviderEndpoint[] = [
  228. makeEndpoint({ id: 1, lastProbeOk: true, sortOrder: 0, lastProbeLatencyMs: 100 }),
  229. makeEndpoint({ id: 2, lastProbeOk: true, sortOrder: 1, lastProbeLatencyMs: 50 }),
  230. makeEndpoint({ id: 3, lastProbeOk: false, sortOrder: 0, lastProbeLatencyMs: 10 }),
  231. makeEndpoint({ id: 4, isEnabled: false }),
  232. makeEndpoint({ id: 5, deletedAt: new Date(1) }),
  233. ];
  234. const findMock = vi.fn(async () => endpoints);
  235. const isOpenMock = vi.fn(async () => true);
  236. vi.doMock("@/repository", () => ({
  237. findProviderEndpointsByVendorAndType: findMock,
  238. }));
  239. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  240. isEndpointCircuitOpen: isOpenMock,
  241. }));
  242. vi.doMock("@/lib/config/env.schema", () => ({
  243. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  244. }));
  245. const { getPreferredProviderEndpoints } = await import(
  246. "@/lib/provider-endpoints/endpoint-selector"
  247. );
  248. const result = await getPreferredProviderEndpoints({
  249. vendorId: 1,
  250. providerType: "claude",
  251. });
  252. expect(isOpenMock).not.toHaveBeenCalled();
  253. // All enabled, non-deleted endpoints returned (id=1,2,3), ranked by sortOrder/health
  254. expect(result.map((e) => e.id)).toEqual([1, 2, 3]);
  255. });
  256. test("getEndpointFilterStats returns circuitOpen=0 when disabled", async () => {
  257. vi.resetModules();
  258. const endpoints: ProviderEndpoint[] = [
  259. makeEndpoint({ id: 1, isEnabled: true, lastProbeOk: true }),
  260. makeEndpoint({ id: 2, isEnabled: true, lastProbeOk: false }),
  261. makeEndpoint({ id: 3, isEnabled: false }),
  262. makeEndpoint({ id: 4, deletedAt: new Date(1) }),
  263. ];
  264. const findMock = vi.fn(async () => endpoints);
  265. const isOpenMock = vi.fn(async () => true);
  266. vi.doMock("@/repository", () => ({
  267. findProviderEndpointsByVendorAndType: findMock,
  268. }));
  269. vi.doMock("@/lib/endpoint-circuit-breaker", () => ({
  270. isEndpointCircuitOpen: isOpenMock,
  271. }));
  272. vi.doMock("@/lib/config/env.schema", () => ({
  273. getEnvConfig: () => ({ ENABLE_ENDPOINT_CIRCUIT_BREAKER: false }),
  274. }));
  275. const { getEndpointFilterStats } = await import("@/lib/provider-endpoints/endpoint-selector");
  276. const stats = await getEndpointFilterStats({ vendorId: 10, providerType: "claude" });
  277. expect(isOpenMock).not.toHaveBeenCalled();
  278. expect(stats).toEqual({
  279. total: 4,
  280. enabled: 2, // id=1,2 (isEnabled && !deletedAt)
  281. circuitOpen: 0, // always 0 when disabled
  282. available: 2, // equals enabled when disabled
  283. });
  284. });
  285. });