proxy-forwarder-endpoint-audit.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. const mocks = vi.hoisted(() => {
  3. return {
  4. getPreferredProviderEndpoints: vi.fn(),
  5. getEndpointFilterStats: vi.fn(async () => null),
  6. recordEndpointSuccess: vi.fn(async () => {}),
  7. recordEndpointFailure: vi.fn(async () => {}),
  8. recordSuccess: vi.fn(),
  9. recordFailure: vi.fn(async () => {}),
  10. getCircuitState: vi.fn(() => "closed"),
  11. getProviderHealthInfo: vi.fn(async () => ({
  12. health: { failureCount: 0 },
  13. config: { failureThreshold: 3 },
  14. })),
  15. isVendorTypeCircuitOpen: vi.fn(async () => false),
  16. recordVendorTypeAllEndpointsTimeout: vi.fn(async () => {}),
  17. categorizeErrorAsync: vi.fn(),
  18. };
  19. });
  20. vi.mock("@/lib/logger", () => ({
  21. logger: {
  22. debug: vi.fn(),
  23. info: vi.fn(),
  24. warn: vi.fn(),
  25. trace: vi.fn(),
  26. error: vi.fn(),
  27. fatal: vi.fn(),
  28. },
  29. }));
  30. vi.mock("@/lib/provider-endpoints/endpoint-selector", () => ({
  31. getPreferredProviderEndpoints: mocks.getPreferredProviderEndpoints,
  32. getEndpointFilterStats: mocks.getEndpointFilterStats,
  33. }));
  34. vi.mock("@/lib/endpoint-circuit-breaker", () => ({
  35. recordEndpointSuccess: mocks.recordEndpointSuccess,
  36. recordEndpointFailure: mocks.recordEndpointFailure,
  37. }));
  38. vi.mock("@/lib/circuit-breaker", () => ({
  39. getCircuitState: mocks.getCircuitState,
  40. getProviderHealthInfo: mocks.getProviderHealthInfo,
  41. recordSuccess: mocks.recordSuccess,
  42. recordFailure: mocks.recordFailure,
  43. }));
  44. vi.mock("@/lib/vendor-type-circuit-breaker", () => ({
  45. isVendorTypeCircuitOpen: mocks.isVendorTypeCircuitOpen,
  46. recordVendorTypeAllEndpointsTimeout: mocks.recordVendorTypeAllEndpointsTimeout,
  47. }));
  48. vi.mock("@/app/v1/_lib/proxy/errors", async (importOriginal) => {
  49. const actual = await importOriginal<typeof import("@/app/v1/_lib/proxy/errors")>();
  50. return {
  51. ...actual,
  52. categorizeErrorAsync: mocks.categorizeErrorAsync,
  53. };
  54. });
  55. import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  56. import { ProxyError } from "@/app/v1/_lib/proxy/errors";
  57. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  58. import { logger } from "@/lib/logger";
  59. import type { Provider, ProviderEndpoint, ProviderType } from "@/types/provider";
  60. function makeEndpoint(input: {
  61. id: number;
  62. vendorId: number;
  63. providerType: ProviderType;
  64. url: string;
  65. }): ProviderEndpoint {
  66. const now = new Date("2026-01-01T00:00:00.000Z");
  67. return {
  68. id: input.id,
  69. vendorId: input.vendorId,
  70. providerType: input.providerType,
  71. url: input.url,
  72. label: null,
  73. sortOrder: 0,
  74. isEnabled: true,
  75. lastProbedAt: null,
  76. lastProbeOk: null,
  77. lastProbeStatusCode: null,
  78. lastProbeLatencyMs: null,
  79. lastProbeErrorType: null,
  80. lastProbeErrorMessage: null,
  81. createdAt: now,
  82. updatedAt: now,
  83. deletedAt: null,
  84. };
  85. }
  86. function createProvider(overrides: Partial<Provider> = {}): Provider {
  87. return {
  88. id: 1,
  89. name: "p1",
  90. url: "https://provider.example.com",
  91. key: "k",
  92. providerVendorId: 123,
  93. isEnabled: true,
  94. weight: 1,
  95. priority: 0,
  96. costMultiplier: 1,
  97. groupTag: null,
  98. providerType: "claude",
  99. preserveClientIp: false,
  100. modelRedirects: null,
  101. allowedModels: null,
  102. mcpPassthroughType: "none",
  103. mcpPassthroughUrl: null,
  104. limit5hUsd: null,
  105. limitDailyUsd: null,
  106. dailyResetMode: "fixed",
  107. dailyResetTime: "00:00",
  108. limitWeeklyUsd: null,
  109. limitMonthlyUsd: null,
  110. limitTotalUsd: null,
  111. totalCostResetAt: null,
  112. limitConcurrentSessions: 0,
  113. maxRetryAttempts: null,
  114. circuitBreakerFailureThreshold: 5,
  115. circuitBreakerOpenDuration: 1_800_000,
  116. circuitBreakerHalfOpenSuccessThreshold: 2,
  117. proxyUrl: null,
  118. proxyFallbackToDirect: false,
  119. firstByteTimeoutStreamingMs: 30_000,
  120. streamingIdleTimeoutMs: 10_000,
  121. requestTimeoutNonStreamingMs: 600_000,
  122. websiteUrl: null,
  123. faviconUrl: null,
  124. cacheTtlPreference: null,
  125. context1mPreference: null,
  126. codexReasoningEffortPreference: null,
  127. codexReasoningSummaryPreference: null,
  128. codexTextVerbosityPreference: null,
  129. codexParallelToolCallsPreference: null,
  130. tpm: 0,
  131. rpm: 0,
  132. rpd: 0,
  133. cc: 0,
  134. createdAt: new Date(),
  135. updatedAt: new Date(),
  136. deletedAt: null,
  137. ...overrides,
  138. };
  139. }
  140. function createSession(requestUrl: URL = new URL("https://example.com/v1/messages")): ProxySession {
  141. const headers = new Headers();
  142. const session = Object.create(ProxySession.prototype);
  143. Object.assign(session, {
  144. startTime: Date.now(),
  145. method: "POST",
  146. requestUrl,
  147. headers,
  148. originalHeaders: new Headers(headers),
  149. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  150. request: {
  151. model: "model-x",
  152. log: "(test)",
  153. message: {
  154. model: "model-x",
  155. messages: [
  156. { role: "user", content: "hello" },
  157. { role: "assistant", content: "ok" },
  158. ],
  159. },
  160. },
  161. userAgent: null,
  162. context: null,
  163. clientAbortSignal: null,
  164. userName: "test-user",
  165. authState: { success: true, user: null, key: null, apiKey: null },
  166. provider: null,
  167. messageContext: null,
  168. sessionId: null,
  169. requestSequence: 1,
  170. originalFormat: "claude",
  171. providerType: null,
  172. originalModelName: null,
  173. originalUrlPathname: null,
  174. providerChain: [],
  175. cacheTtlResolved: null,
  176. context1mApplied: false,
  177. specialSettings: [],
  178. cachedPriceData: undefined,
  179. cachedBillingModelSource: undefined,
  180. isHeaderModified: () => false,
  181. });
  182. return session as ProxySession;
  183. }
  184. describe("ProxyForwarder - endpoint audit", () => {
  185. beforeEach(() => {
  186. vi.clearAllMocks();
  187. });
  188. test("成功时应记录 endpointId 且对 endpointUrl 做脱敏", async () => {
  189. const session = createSession();
  190. const provider = createProvider({ providerType: "claude", providerVendorId: 123 });
  191. session.setProvider(provider);
  192. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  193. makeEndpoint({
  194. id: 42,
  195. vendorId: 123,
  196. providerType: provider.providerType,
  197. url: "https://api.example.com/v1/messages?api_key=SECRET&foo=bar",
  198. }),
  199. ]);
  200. const doForward = vi.spyOn(
  201. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  202. "doForward"
  203. );
  204. doForward.mockResolvedValueOnce(
  205. new Response("{}", {
  206. status: 200,
  207. headers: {
  208. "content-type": "application/json",
  209. "content-length": "2",
  210. },
  211. })
  212. );
  213. const response = await ProxyForwarder.send(session);
  214. expect(response.status).toBe(200);
  215. const chain = session.getProviderChain();
  216. expect(chain).toHaveLength(1);
  217. const item = chain[0];
  218. expect(item).toEqual(
  219. expect.objectContaining({
  220. reason: "request_success",
  221. attemptNumber: 1,
  222. statusCode: 200,
  223. vendorId: 123,
  224. providerType: "claude",
  225. endpointId: 42,
  226. })
  227. );
  228. expect(item.endpointUrl).toContain("[REDACTED]");
  229. expect(item.endpointUrl).not.toContain("SECRET");
  230. });
  231. test("重试时应分别记录每次 attempt 的 endpoint 审计字段", async () => {
  232. vi.useFakeTimers();
  233. try {
  234. const session = createSession(new URL("https://example.com/v1/chat/completions"));
  235. const provider = createProvider({
  236. providerType: "openai-compatible",
  237. providerVendorId: 123,
  238. });
  239. session.setProvider(provider);
  240. mocks.getPreferredProviderEndpoints.mockResolvedValue([
  241. makeEndpoint({
  242. id: 1,
  243. vendorId: 123,
  244. providerType: provider.providerType,
  245. url: "https://api.example.com/v1?token=SECRET_1",
  246. }),
  247. makeEndpoint({
  248. id: 2,
  249. vendorId: 123,
  250. providerType: provider.providerType,
  251. url: "https://api.example.com/v1?api_key=SECRET_2",
  252. }),
  253. ]);
  254. const doForward = vi.spyOn(
  255. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  256. "doForward"
  257. );
  258. // Throw network error (SYSTEM_ERROR) to trigger endpoint switching
  259. // PROVIDER_ERROR (HTTP 4xx/5xx) doesn't trigger endpoint switch, only SYSTEM_ERROR does
  260. doForward.mockImplementationOnce(async () => {
  261. const err = new Error("ECONNREFUSED") as NodeJS.ErrnoException;
  262. err.code = "ECONNREFUSED";
  263. throw err;
  264. });
  265. // Configure categorizeErrorAsync to return SYSTEM_ERROR for network errors
  266. mocks.categorizeErrorAsync.mockResolvedValueOnce(1); // ErrorCategory.SYSTEM_ERROR = 1
  267. doForward.mockResolvedValueOnce(
  268. new Response("{}", {
  269. status: 200,
  270. headers: {
  271. "content-type": "application/json",
  272. "content-length": "2",
  273. },
  274. })
  275. );
  276. const sendPromise = ProxyForwarder.send(session);
  277. await vi.advanceTimersByTimeAsync(100);
  278. const response = await sendPromise;
  279. expect(response.status).toBe(200);
  280. const chain = session.getProviderChain();
  281. expect(chain).toHaveLength(2);
  282. const first = chain[0];
  283. const second = chain[1];
  284. expect(first).toEqual(
  285. expect.objectContaining({
  286. reason: "system_error",
  287. attemptNumber: 1,
  288. vendorId: 123,
  289. providerType: "openai-compatible",
  290. endpointId: 1,
  291. })
  292. );
  293. expect(first.endpointUrl).toContain("[REDACTED]");
  294. expect(first.endpointUrl).not.toContain("SECRET_1");
  295. expect(second).toEqual(
  296. expect.objectContaining({
  297. reason: "retry_success",
  298. attemptNumber: 2,
  299. vendorId: 123,
  300. providerType: "openai-compatible",
  301. endpointId: 2,
  302. })
  303. );
  304. expect(second.endpointUrl).toContain("[REDACTED]");
  305. expect(second.endpointUrl).not.toContain("SECRET_2");
  306. } finally {
  307. vi.useRealTimers();
  308. }
  309. });
  310. test("MCP 请求应保持 provider.url 语义,不触发 strict endpoint 拦截", async () => {
  311. const requestPath = "/mcp/custom-endpoint";
  312. const session = createSession(new URL(`https://example.com${requestPath}`));
  313. const provider = createProvider({
  314. providerType: "claude",
  315. providerVendorId: 123,
  316. url: `https://provider.example.com${requestPath}?key=SECRET`,
  317. });
  318. session.setProvider(provider);
  319. mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([
  320. makeEndpoint({
  321. id: 99,
  322. vendorId: 123,
  323. providerType: "claude",
  324. url: "https://ep99.example.com",
  325. }),
  326. ]);
  327. const doForward = vi.spyOn(
  328. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  329. "doForward"
  330. );
  331. doForward.mockResolvedValueOnce(
  332. new Response("{}", {
  333. status: 200,
  334. headers: {
  335. "content-type": "application/json",
  336. "content-length": "2",
  337. },
  338. })
  339. );
  340. const response = await ProxyForwarder.send(session);
  341. expect(response.status).toBe(200);
  342. expect(mocks.getPreferredProviderEndpoints).not.toHaveBeenCalled();
  343. const chain = session.getProviderChain();
  344. expect(chain).toHaveLength(1);
  345. expect(chain[0]).toEqual(
  346. expect.objectContaining({
  347. endpointId: null,
  348. reason: "request_success",
  349. })
  350. );
  351. const warnMessages = vi.mocked(logger.warn).mock.calls.map(([message]) => message);
  352. expect(warnMessages).not.toContain(
  353. "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback"
  354. );
  355. });
  356. test.each([
  357. { requestPath: "/v1/messages", providerType: "claude" as const },
  358. { requestPath: "/v1/responses", providerType: "codex" as const },
  359. { requestPath: "/v1/chat/completions", providerType: "openai-compatible" as const },
  360. ])("标准端点 $requestPath: endpoint 选择失败时不应静默回退到 provider.url", async ({
  361. requestPath,
  362. providerType,
  363. }) => {
  364. const session = createSession(new URL(`https://example.com${requestPath}`));
  365. const provider = createProvider({
  366. providerType,
  367. providerVendorId: 123,
  368. url: `https://provider.example.com${requestPath}?key=SECRET`,
  369. });
  370. session.setProvider(provider);
  371. mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("boom"));
  372. const doForward = vi.spyOn(
  373. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  374. "doForward"
  375. );
  376. doForward.mockResolvedValueOnce(
  377. new Response("{}", {
  378. status: 200,
  379. headers: {
  380. "content-type": "application/json",
  381. "content-length": "2",
  382. },
  383. })
  384. );
  385. const rejected = await ProxyForwarder.send(session)
  386. .then(() => false)
  387. .catch(() => true);
  388. expect(rejected, `标准端点 ${requestPath} endpoint 选择失败后不允许静默回退 provider.url`).toBe(
  389. true
  390. );
  391. expect(doForward).not.toHaveBeenCalled();
  392. expect(logger.warn).toHaveBeenCalledWith(
  393. "[ProxyForwarder] Failed to load provider endpoints",
  394. expect.objectContaining({
  395. providerId: provider.id,
  396. vendorId: 123,
  397. providerType,
  398. strictEndpointPolicy: true,
  399. reason: "selector_error",
  400. error: "boom",
  401. })
  402. );
  403. expect(logger.warn).toHaveBeenCalledWith(
  404. "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback",
  405. expect.objectContaining({
  406. providerId: provider.id,
  407. vendorId: 123,
  408. providerType,
  409. requestPath,
  410. reason: "strict_blocked_legacy_fallback",
  411. strictBlockCause: "selector_error",
  412. selectorError: "boom",
  413. })
  414. );
  415. });
  416. test("标准端点空候选应记录 no_endpoint_candidates 且不混淆为 selector_error", async () => {
  417. const requestPath = "/v1/messages";
  418. const providerType = "claude" as const;
  419. const session = createSession(new URL(`https://example.com${requestPath}`));
  420. const provider = createProvider({
  421. providerType,
  422. providerVendorId: 123,
  423. url: "https://provider.example.com/v1/messages?key=SECRET",
  424. });
  425. session.setProvider(provider);
  426. mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]);
  427. const doForward = vi.spyOn(
  428. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  429. "doForward"
  430. );
  431. doForward.mockResolvedValueOnce(
  432. new Response("{}", {
  433. status: 200,
  434. headers: {
  435. "content-type": "application/json",
  436. "content-length": "2",
  437. },
  438. })
  439. );
  440. const rejected = await ProxyForwarder.send(session)
  441. .then(() => false)
  442. .catch(() => true);
  443. expect(rejected).toBe(true);
  444. expect(doForward).not.toHaveBeenCalled();
  445. expect(logger.warn).toHaveBeenCalledWith(
  446. "ProxyForwarder: Strict endpoint policy blocked legacy provider.url fallback",
  447. expect.objectContaining({
  448. providerId: provider.id,
  449. vendorId: 123,
  450. providerType,
  451. requestPath,
  452. reason: "strict_blocked_legacy_fallback",
  453. strictBlockCause: "no_endpoint_candidates",
  454. selectorError: undefined,
  455. })
  456. );
  457. const warnMessages = vi.mocked(logger.warn).mock.calls.map(([message]) => message);
  458. expect(warnMessages).not.toContain("[ProxyForwarder] Failed to load provider endpoints");
  459. });
  460. test("endpoint pool exhausted (no_endpoint_candidates) should record endpoint_pool_exhausted in provider chain", async () => {
  461. const requestPath = "/v1/messages";
  462. const session = createSession(new URL(`https://example.com${requestPath}`));
  463. const provider = createProvider({
  464. providerType: "claude",
  465. providerVendorId: 123,
  466. url: "https://provider.example.com/v1/messages",
  467. });
  468. session.setProvider(provider);
  469. // Return empty array => no_endpoint_candidates
  470. mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]);
  471. mocks.getEndpointFilterStats.mockResolvedValueOnce({
  472. total: 3,
  473. enabled: 2,
  474. circuitOpen: 2,
  475. available: 0,
  476. });
  477. const doForward = vi.spyOn(
  478. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  479. "doForward"
  480. );
  481. await expect(ProxyForwarder.send(session)).rejects.toThrow();
  482. expect(doForward).not.toHaveBeenCalled();
  483. const chain = session.getProviderChain();
  484. const exhaustedItem = chain.find((item) => item.reason === "endpoint_pool_exhausted");
  485. expect(exhaustedItem).toBeDefined();
  486. expect(exhaustedItem).toEqual(
  487. expect.objectContaining({
  488. id: provider.id,
  489. name: provider.name,
  490. vendorId: 123,
  491. providerType: "claude",
  492. reason: "endpoint_pool_exhausted",
  493. strictBlockCause: "no_endpoint_candidates",
  494. })
  495. );
  496. // endpointFilterStats should be present at top level
  497. expect(exhaustedItem!.endpointFilterStats).toEqual({
  498. total: 3,
  499. enabled: 2,
  500. circuitOpen: 2,
  501. available: 0,
  502. });
  503. // errorMessage should be undefined for no_endpoint_candidates (no exception)
  504. expect(exhaustedItem!.errorMessage).toBeUndefined();
  505. });
  506. test("endpoint pool exhausted (selector_error) should record endpoint_pool_exhausted with selectorError in decisionContext", async () => {
  507. const requestPath = "/v1/responses";
  508. const session = createSession(new URL(`https://example.com${requestPath}`));
  509. const provider = createProvider({
  510. providerType: "codex",
  511. providerVendorId: 456,
  512. url: "https://provider.example.com/v1/responses",
  513. });
  514. session.setProvider(provider);
  515. // Throw error => selector_error cause
  516. mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("Redis connection lost"));
  517. const doForward = vi.spyOn(
  518. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  519. "doForward"
  520. );
  521. await expect(ProxyForwarder.send(session)).rejects.toThrow();
  522. expect(doForward).not.toHaveBeenCalled();
  523. const chain = session.getProviderChain();
  524. const exhaustedItem = chain.find((item) => item.reason === "endpoint_pool_exhausted");
  525. expect(exhaustedItem).toBeDefined();
  526. expect(exhaustedItem).toEqual(
  527. expect.objectContaining({
  528. id: provider.id,
  529. name: provider.name,
  530. vendorId: 456,
  531. providerType: "codex",
  532. reason: "endpoint_pool_exhausted",
  533. strictBlockCause: "selector_error",
  534. })
  535. );
  536. // selector_error should NOT call getEndpointFilterStats (exception path, no data available)
  537. // endpointFilterStats should be undefined for selector_error
  538. expect(exhaustedItem!.endpointFilterStats).toBeUndefined();
  539. // errorMessage should contain the selector error message
  540. expect(exhaustedItem!.errorMessage).toBe("Redis connection lost");
  541. });
  542. test("selector_error and no_endpoint_candidates are correctly distinguished in provider chain", async () => {
  543. // Test 1: selector_error (exception thrown)
  544. const session1 = createSession(new URL("https://example.com/v1/chat/completions"));
  545. const provider1 = createProvider({
  546. id: 10,
  547. name: "p-selector-err",
  548. providerType: "openai-compatible",
  549. providerVendorId: 789,
  550. });
  551. session1.setProvider(provider1);
  552. mocks.getPreferredProviderEndpoints.mockRejectedValueOnce(new Error("timeout"));
  553. await expect(ProxyForwarder.send(session1)).rejects.toThrow();
  554. const chain1 = session1.getProviderChain();
  555. const item1 = chain1.find((i) => i.reason === "endpoint_pool_exhausted");
  556. expect(item1).toBeDefined();
  557. expect(item1!.strictBlockCause).toBe("selector_error");
  558. expect(item1!.endpointFilterStats).toBeUndefined();
  559. expect(item1!.errorMessage).toBe("timeout");
  560. // Test 2: no_endpoint_candidates (empty array returned)
  561. const session2 = createSession(new URL("https://example.com/v1/chat/completions"));
  562. const provider2 = createProvider({
  563. id: 20,
  564. name: "p-empty-pool",
  565. providerType: "openai-compatible",
  566. providerVendorId: 789,
  567. });
  568. session2.setProvider(provider2);
  569. mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]);
  570. mocks.getEndpointFilterStats.mockResolvedValueOnce({
  571. total: 5,
  572. enabled: 3,
  573. circuitOpen: 3,
  574. available: 0,
  575. });
  576. await expect(ProxyForwarder.send(session2)).rejects.toThrow();
  577. const chain2 = session2.getProviderChain();
  578. const item2 = chain2.find((i) => i.reason === "endpoint_pool_exhausted");
  579. expect(item2).toBeDefined();
  580. expect(item2!.strictBlockCause).toBe("no_endpoint_candidates");
  581. expect(item2!.endpointFilterStats).toEqual({
  582. total: 5,
  583. enabled: 3,
  584. circuitOpen: 3,
  585. available: 0,
  586. });
  587. expect(item2!.errorMessage).toBeUndefined();
  588. });
  589. test("endpointFilterStats should gracefully handle getEndpointFilterStats failure", async () => {
  590. const requestPath = "/v1/messages";
  591. const session = createSession(new URL(`https://example.com${requestPath}`));
  592. const provider = createProvider({
  593. providerType: "claude",
  594. providerVendorId: 123,
  595. url: "https://provider.example.com/v1/messages",
  596. });
  597. session.setProvider(provider);
  598. mocks.getPreferredProviderEndpoints.mockResolvedValueOnce([]);
  599. // Stats call fails - should not break the flow
  600. mocks.getEndpointFilterStats.mockRejectedValueOnce(new Error("DB unavailable"));
  601. const doForward = vi.spyOn(
  602. ProxyForwarder as unknown as { doForward: (...args: unknown[]) => unknown },
  603. "doForward"
  604. );
  605. await expect(ProxyForwarder.send(session)).rejects.toThrow();
  606. expect(doForward).not.toHaveBeenCalled();
  607. const chain = session.getProviderChain();
  608. const exhaustedItem = chain.find((item) => item.reason === "endpoint_pool_exhausted");
  609. expect(exhaustedItem).toBeDefined();
  610. expect(exhaustedItem!.strictBlockCause).toBe("no_endpoint_candidates");
  611. // endpointFilterStats should be undefined when stats call fails
  612. expect(exhaustedItem!.endpointFilterStats).toBeUndefined();
  613. });
  614. });