hedge-winner-dedup.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. /**
  2. * Tests for hedge winner duplicate provider chain entry fix.
  3. *
  4. * Bug: When a streaming hedge request wins, commitWinner() logs the provider with
  5. * reason "hedge_winner", then finalizeDeferredStreamingFinalizationIfNeeded() logs
  6. * the same provider again with reason "retry_success". The dedup logic in
  7. * addProviderToChain() doesn't catch this because "hedge_winner" !== "retry_success".
  8. *
  9. * Fix: Add isHedgeWinner flag to DeferredStreamingFinalization so finalization
  10. * can skip duplicate session binding, provider update, and chain logging.
  11. */
  12. import { beforeEach, describe, expect, it, vi } from "vitest";
  13. import type { Provider } from "@/types/provider";
  14. // ── stream-finalization round-trip ──────────────────────────────────
  15. describe("DeferredStreamingFinalization isHedgeWinner flag", () => {
  16. beforeEach(() => {
  17. vi.resetModules();
  18. });
  19. it("should preserve isHedgeWinner=true through set/consume cycle", async () => {
  20. const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import(
  21. "@/app/v1/_lib/proxy/stream-finalization"
  22. );
  23. const fakeSession = {} as Parameters<typeof setDeferredStreamingFinalization>[0];
  24. setDeferredStreamingFinalization(fakeSession, {
  25. providerId: 1,
  26. providerName: "test",
  27. providerPriority: 10,
  28. attemptNumber: 1,
  29. totalProvidersAttempted: 2,
  30. isFirstAttempt: false,
  31. isFailoverSuccess: false,
  32. endpointId: null,
  33. endpointUrl: "https://api.example.com",
  34. upstreamStatusCode: 200,
  35. isHedgeWinner: true,
  36. });
  37. const meta = consumeDeferredStreamingFinalization(fakeSession);
  38. expect(meta).not.toBeNull();
  39. expect(meta!.isHedgeWinner).toBe(true);
  40. });
  41. it("should preserve isHedgeWinner=false (non-hedge) through set/consume cycle", async () => {
  42. const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import(
  43. "@/app/v1/_lib/proxy/stream-finalization"
  44. );
  45. const fakeSession = {} as Parameters<typeof setDeferredStreamingFinalization>[0];
  46. setDeferredStreamingFinalization(fakeSession, {
  47. providerId: 1,
  48. providerName: "test",
  49. providerPriority: 10,
  50. attemptNumber: 1,
  51. totalProvidersAttempted: 1,
  52. isFirstAttempt: true,
  53. isFailoverSuccess: false,
  54. endpointId: null,
  55. endpointUrl: "https://api.example.com",
  56. upstreamStatusCode: 200,
  57. isHedgeWinner: false,
  58. });
  59. const meta = consumeDeferredStreamingFinalization(fakeSession);
  60. expect(meta).not.toBeNull();
  61. expect(meta!.isHedgeWinner).toBe(false);
  62. });
  63. it("should default isHedgeWinner to undefined when not set", async () => {
  64. const { setDeferredStreamingFinalization, consumeDeferredStreamingFinalization } = await import(
  65. "@/app/v1/_lib/proxy/stream-finalization"
  66. );
  67. const fakeSession = {} as Parameters<typeof setDeferredStreamingFinalization>[0];
  68. setDeferredStreamingFinalization(fakeSession, {
  69. providerId: 1,
  70. providerName: "test",
  71. providerPriority: 10,
  72. attemptNumber: 1,
  73. totalProvidersAttempted: 1,
  74. isFirstAttempt: true,
  75. isFailoverSuccess: false,
  76. endpointId: null,
  77. endpointUrl: "https://api.example.com",
  78. upstreamStatusCode: 200,
  79. });
  80. const meta = consumeDeferredStreamingFinalization(fakeSession);
  81. expect(meta).not.toBeNull();
  82. expect(meta!.isHedgeWinner).toBeUndefined();
  83. });
  84. });
  85. // ── addProviderToChain dedup gap (documents the bug) ────────────────
  86. // These mocks must be declared before importing ProxySession
  87. vi.mock("@/repository/model-price", () => ({
  88. findLatestPriceByModel: vi.fn(),
  89. }));
  90. vi.mock("@/repository/system-config", () => ({
  91. getSystemSettings: vi.fn(),
  92. }));
  93. vi.mock("@/repository/provider", () => ({
  94. findAllProviders: vi.fn(async () => []),
  95. }));
  96. vi.mock("@/lib/redis/live-chain-store", () => ({
  97. writeLiveChain: vi.fn(),
  98. }));
  99. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  100. const makeProvider = (id: number, name: string): Provider =>
  101. ({
  102. id,
  103. name,
  104. providerVendorId: 100,
  105. providerType: "claude",
  106. priority: 10,
  107. weight: 1,
  108. costMultiplier: 1,
  109. groupTag: null,
  110. isEnabled: true,
  111. }) as unknown as Provider;
  112. function createSession(): ProxySession {
  113. return new (
  114. ProxySession as unknown as {
  115. new (init: {
  116. startTime: number;
  117. method: string;
  118. requestUrl: URL;
  119. headers: Headers;
  120. headerLog: string;
  121. request: { message: Record<string, unknown>; log: string; model: string | null };
  122. userAgent: string | null;
  123. context: unknown;
  124. clientAbortSignal: AbortSignal | null;
  125. }): ProxySession;
  126. }
  127. )({
  128. startTime: Date.now(),
  129. method: "POST",
  130. requestUrl: new URL("http://localhost/v1/messages"),
  131. headers: new Headers(),
  132. headerLog: "",
  133. request: { message: {}, log: "(test)", model: "test-model" },
  134. userAgent: null,
  135. context: {},
  136. clientAbortSignal: null,
  137. });
  138. }
  139. describe("addProviderToChain dedup behavior with hedge reasons", () => {
  140. it("same provider with hedge_winner then retry_success produces duplicate (documents bug)", () => {
  141. const session = createSession();
  142. const provider = makeProvider(1, "Provider A");
  143. // commitWinner logs with hedge_winner
  144. session.addProviderToChain(provider, {
  145. reason: "hedge_winner",
  146. attemptNumber: 1,
  147. statusCode: 200,
  148. endpointId: 10,
  149. endpointUrl: "https://api.example.com",
  150. });
  151. // finalization would log with retry_success (the bug)
  152. session.addProviderToChain(provider, {
  153. reason: "retry_success",
  154. attemptNumber: 1,
  155. statusCode: 200,
  156. endpointId: 10,
  157. endpointUrl: "https://api.example.com",
  158. });
  159. const chain = session.getProviderChain();
  160. // Documents the current (broken) behavior: 2 entries for the same provider.
  161. // After the fix, finalization won't call addProviderToChain for hedge winners,
  162. // so this scenario won't arise in practice.
  163. expect(chain).toHaveLength(2);
  164. expect(chain[0].reason).toBe("hedge_winner");
  165. expect(chain[1].reason).toBe("retry_success");
  166. });
  167. it("same provider with identical reason and attemptNumber deduplicates correctly", () => {
  168. const session = createSession();
  169. const provider = makeProvider(1, "Provider A");
  170. session.addProviderToChain(provider, {
  171. reason: "request_success",
  172. attemptNumber: 1,
  173. statusCode: 200,
  174. endpointId: 10,
  175. endpointUrl: "https://api.example.com",
  176. });
  177. // Same reason + same attemptNumber -> should dedup
  178. session.addProviderToChain(provider, {
  179. reason: "request_success",
  180. attemptNumber: 1,
  181. statusCode: 200,
  182. endpointId: 10,
  183. endpointUrl: "https://api.example.com",
  184. });
  185. const chain = session.getProviderChain();
  186. expect(chain).toHaveLength(1);
  187. expect(chain[0].reason).toBe("request_success");
  188. });
  189. it("non-hedge finalization should add entry to chain normally", () => {
  190. const session = createSession();
  191. const provider = makeProvider(1, "Provider A");
  192. session.addProviderToChain(provider, {
  193. reason: "request_success",
  194. attemptNumber: 1,
  195. statusCode: 200,
  196. endpointId: 10,
  197. endpointUrl: "https://api.example.com",
  198. });
  199. const chain = session.getProviderChain();
  200. expect(chain).toHaveLength(1);
  201. expect(chain[0].reason).toBe("request_success");
  202. });
  203. });