embedding-autofallback.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. /**
  2. * embedding-autofallback.test.ts - Tests for AutoFallbackEmbeddingProvider.
  3. */
  4. import { describe, test, expect } from "vitest";
  5. import {
  6. AutoFallbackEmbeddingProvider,
  7. type AutoFallbackProviderConfig,
  8. } from "../src/embedding/autofallback.js";
  9. import { CircuitOpenError } from "../src/embedding/openai.js";
  10. import type {
  11. EmbeddingProvider,
  12. ProviderEmbedOptions,
  13. ProviderEmbedding,
  14. ProviderHealth,
  15. ProviderKind,
  16. } from "../src/embedding/provider.js";
  17. // ─────────────────────────── Test fakes ──────────────────────────────────────
  18. class FakeProvider implements EmbeddingProvider {
  19. readonly kind: ProviderKind;
  20. readonly modelId: string;
  21. readonly dim: number;
  22. embedCalls = 0;
  23. embedBatchCalls = 0;
  24. healthcheckCalls = 0;
  25. disposed = false;
  26. /** Override behavior for next N calls */
  27. nextThrows: Array<Error | null> = [];
  28. /** Always-throw mode */
  29. alwaysThrows: Error | null = null;
  30. /** Health response */
  31. healthResponse: ProviderHealth | null = null;
  32. constructor(kind: ProviderKind, modelId: string, dim = 4) {
  33. this.kind = kind;
  34. this.modelId = modelId;
  35. this.dim = dim;
  36. }
  37. getModelId(): string {
  38. return this.modelId;
  39. }
  40. getDimensions(): number | undefined {
  41. return this.dim;
  42. }
  43. async healthcheck(): Promise<ProviderHealth> {
  44. this.healthcheckCalls++;
  45. if (this.healthResponse) return this.healthResponse;
  46. return { ok: true, model: this.modelId, dimensions: this.dim };
  47. }
  48. async embed(text: string, _options?: ProviderEmbedOptions): Promise<ProviderEmbedding | null> {
  49. this.embedCalls++;
  50. this.maybeThrow();
  51. return { embedding: this.fakeEmbed(text), model: this.modelId };
  52. }
  53. async embedBatch(texts: string[], _options?: ProviderEmbedOptions): Promise<(ProviderEmbedding | null)[]> {
  54. this.embedBatchCalls++;
  55. this.maybeThrow();
  56. return texts.map((t) => ({ embedding: this.fakeEmbed(t), model: this.modelId }));
  57. }
  58. async dispose(): Promise<void> {
  59. this.disposed = true;
  60. }
  61. private maybeThrow(): void {
  62. if (this.alwaysThrows) throw this.alwaysThrows;
  63. const next = this.nextThrows.shift();
  64. if (next) throw next;
  65. }
  66. private fakeEmbed(text: string): number[] {
  67. return Array.from({ length: this.dim }, (_, i) => (text.length + i) * 0.01);
  68. }
  69. }
  70. function buildAutoFallback(opts: Partial<AutoFallbackProviderConfig> = {}): {
  71. af: AutoFallbackEmbeddingProvider;
  72. primary: FakeProvider;
  73. fallback: FakeProvider;
  74. warns: string[];
  75. setNow: (n: number) => void;
  76. } {
  77. const primary = new FakeProvider("openai", "embeddinggemma");
  78. const fallback = new FakeProvider("local", "embeddinggemma");
  79. const warns: string[] = [];
  80. let now = 1_000_000;
  81. const af = new AutoFallbackEmbeddingProvider({
  82. primary,
  83. fallback,
  84. failureStreakThreshold: opts.failureStreakThreshold ?? 3,
  85. cooldownMs: opts.cooldownMs ?? 60_000,
  86. warn: (m) => warns.push(m),
  87. now: () => now,
  88. ...opts,
  89. });
  90. return { af, primary, fallback, warns, setNow: (n) => (now = n) };
  91. }
  92. // ─────────────────────────── Construction ────────────────────────────────────
  93. describe("AutoFallbackEmbeddingProvider — construction", () => {
  94. test("requires primary", () => {
  95. expect(
  96. () =>
  97. new AutoFallbackEmbeddingProvider({
  98. // @ts-expect-error testing runtime guard
  99. primary: undefined,
  100. fallback: new FakeProvider("local", "x"),
  101. }),
  102. ).toThrow(/primary is required/);
  103. });
  104. test("requires fallback", () => {
  105. expect(
  106. () =>
  107. new AutoFallbackEmbeddingProvider({
  108. primary: new FakeProvider("openai", "x"),
  109. // @ts-expect-error testing runtime guard
  110. fallback: undefined,
  111. }),
  112. ).toThrow(/fallback is required/);
  113. });
  114. test("rejects identical primary and fallback", () => {
  115. const same = new FakeProvider("openai", "x");
  116. expect(
  117. () =>
  118. new AutoFallbackEmbeddingProvider({
  119. primary: same,
  120. fallback: same,
  121. }),
  122. ).toThrow(/must differ/);
  123. });
  124. test("inherits primary's kind", () => {
  125. const { af } = buildAutoFallback();
  126. expect(af.kind).toBe("openai");
  127. });
  128. });
  129. // ─────────────────────────── Happy path ──────────────────────────────────────
  130. describe("AutoFallbackEmbeddingProvider — happy path", () => {
  131. test("primary succeeds → fallback never called", async () => {
  132. const { af, primary, fallback } = buildAutoFallback();
  133. const r = await af.embed("hello");
  134. expect(r).not.toBeNull();
  135. expect(primary.embedCalls).toBe(1);
  136. expect(fallback.embedCalls).toBe(0);
  137. expect(af.getRoutingState()).toBe("primary");
  138. });
  139. test("primary embedBatch succeeds → fallback untouched", async () => {
  140. const { af, primary, fallback } = buildAutoFallback();
  141. const out = await af.embedBatch(["a", "b"]);
  142. expect(out.length).toBe(2);
  143. expect(primary.embedBatchCalls).toBe(1);
  144. expect(fallback.embedBatchCalls).toBe(0);
  145. });
  146. test("getModelId / getDimensions delegate to primary", () => {
  147. const { af, primary } = buildAutoFallback();
  148. expect(af.getModelId()).toBe(primary.getModelId());
  149. expect(af.getDimensions()).toBe(primary.getDimensions());
  150. });
  151. });
  152. // ─────────────────────────── Circuit-open fallback ───────────────────────────
  153. describe("AutoFallbackEmbeddingProvider — CircuitOpenError handling", () => {
  154. test("primary throws CircuitOpenError → fallback served + cooldown opens", async () => {
  155. const { af, primary, fallback, warns } = buildAutoFallback();
  156. primary.nextThrows.push(new CircuitOpenError());
  157. const r = await af.embed("hello");
  158. expect(r).not.toBeNull();
  159. expect(r!.embedding.length).toBe(4); // came from fallback
  160. expect(primary.embedCalls).toBe(1);
  161. expect(fallback.embedCalls).toBe(1);
  162. expect(af.getRoutingState()).toBe("fallback");
  163. expect(warns.some((w) => w.includes("CircuitOpenError"))).toBe(true);
  164. });
  165. test("during cooldown subsequent calls skip primary entirely", async () => {
  166. const { af, primary, fallback } = buildAutoFallback();
  167. primary.nextThrows.push(new CircuitOpenError());
  168. await af.embed("first");
  169. expect(primary.embedCalls).toBe(1);
  170. expect(fallback.embedCalls).toBe(1);
  171. // Subsequent call within cooldown
  172. await af.embed("second");
  173. expect(primary.embedCalls).toBe(1); // unchanged
  174. expect(fallback.embedCalls).toBe(2);
  175. });
  176. test("after cooldown expires, primary is retried", async () => {
  177. const { af, primary, fallback, setNow } = buildAutoFallback({ cooldownMs: 5000 });
  178. primary.nextThrows.push(new CircuitOpenError());
  179. await af.embed("a");
  180. expect(af.getRoutingState()).toBe("fallback");
  181. setNow(1_000_000 + 5_001);
  182. expect(af.getRoutingState()).toBe("primary");
  183. // Next call reaches primary again
  184. await af.embed("b");
  185. expect(primary.embedCalls).toBe(2);
  186. expect(fallback.embedCalls).toBe(1);
  187. });
  188. test("WARN fired only once per transition (not per call during cooldown)", async () => {
  189. const { af, primary, warns } = buildAutoFallback();
  190. primary.nextThrows.push(new CircuitOpenError());
  191. await af.embed("a");
  192. await af.embed("b");
  193. await af.embed("c");
  194. const fallbackWarns = warns.filter((w) => w.includes("falling back"));
  195. expect(fallbackWarns.length).toBe(1);
  196. });
  197. });
  198. // ─────────────────────────── Failure-streak threshold ────────────────────────
  199. describe("AutoFallbackEmbeddingProvider — failure streak", () => {
  200. test("non-CircuitOpen errors below threshold → no cooldown", async () => {
  201. const { af, primary, fallback } = buildAutoFallback({ failureStreakThreshold: 3 });
  202. primary.nextThrows.push(new Error("transient"));
  203. const r = await af.embed("a");
  204. expect(r).not.toBeNull(); // fallback served it
  205. expect(af.getRoutingState()).toBe("primary");
  206. expect(primary.embedCalls).toBe(1);
  207. expect(fallback.embedCalls).toBe(1);
  208. });
  209. test("threshold consecutive failures → cooldown opens", async () => {
  210. const { af, primary, fallback } = buildAutoFallback({ failureStreakThreshold: 3 });
  211. for (let i = 0; i < 3; i++) {
  212. primary.nextThrows.push(new Error(`err ${i}`));
  213. }
  214. await af.embed("a");
  215. await af.embed("b");
  216. await af.embed("c");
  217. expect(af.getRoutingState()).toBe("fallback");
  218. expect(primary.embedCalls).toBe(3);
  219. expect(fallback.embedCalls).toBe(3);
  220. });
  221. test("a single primary success resets the streak", async () => {
  222. const { af, primary } = buildAutoFallback({ failureStreakThreshold: 3 });
  223. primary.nextThrows.push(new Error("e1"));
  224. primary.nextThrows.push(new Error("e2"));
  225. await af.embed("a");
  226. await af.embed("b");
  227. // Now success
  228. await af.embed("c");
  229. // Streak reset; another two failures shouldn't trip cooldown yet
  230. primary.nextThrows.push(new Error("e3"));
  231. primary.nextThrows.push(new Error("e4"));
  232. await af.embed("d");
  233. await af.embed("e");
  234. expect(af.getRoutingState()).toBe("primary");
  235. });
  236. });
  237. // ─────────────────────────── Recovery transition ─────────────────────────────
  238. describe("AutoFallbackEmbeddingProvider — recovery transitions", () => {
  239. test("recovery WARN fires when primary call succeeds after fallback", async () => {
  240. const { af, primary, warns, setNow } = buildAutoFallback({ cooldownMs: 5000 });
  241. primary.nextThrows.push(new CircuitOpenError());
  242. await af.embed("a");
  243. setNow(1_000_000 + 5_001);
  244. await af.embed("b"); // primary succeeds
  245. const recoveryWarns = warns.filter((w) => w.includes("recovered"));
  246. expect(recoveryWarns.length).toBe(1);
  247. });
  248. test("reset() clears state + transitions back to primary", async () => {
  249. const { af, primary } = buildAutoFallback({ cooldownMs: 60_000 });
  250. primary.nextThrows.push(new CircuitOpenError());
  251. await af.embed("a");
  252. expect(af.getRoutingState()).toBe("fallback");
  253. af.reset();
  254. expect(af.getRoutingState()).toBe("primary");
  255. await af.embed("b");
  256. expect(primary.embedCalls).toBe(2);
  257. });
  258. });
  259. // ─────────────────────────── Both fail ───────────────────────────────────────
  260. describe("AutoFallbackEmbeddingProvider — both providers fail", () => {
  261. test("primary throws + fallback throws → embedBatch returns nulls", async () => {
  262. const { af, primary, fallback } = buildAutoFallback();
  263. primary.alwaysThrows = new Error("primary down");
  264. fallback.alwaysThrows = new Error("local broken");
  265. const r = await af.embedBatch(["a", "b"]);
  266. expect(r).toEqual([null, null]);
  267. });
  268. test("primary throws + fallback throws → embed propagates fallback error", async () => {
  269. const { af, primary, fallback } = buildAutoFallback();
  270. primary.alwaysThrows = new Error("primary down");
  271. fallback.alwaysThrows = new Error("local broken");
  272. await expect(af.embed("a")).rejects.toThrow(/local broken/);
  273. });
  274. });
  275. // ─────────────────────────── Healthcheck ─────────────────────────────────────
  276. describe("AutoFallbackEmbeddingProvider — healthcheck", () => {
  277. test("primary healthy → returns primary health", async () => {
  278. const { af, primary, fallback } = buildAutoFallback();
  279. const h = await af.healthcheck();
  280. expect(h.ok).toBe(true);
  281. expect(primary.healthcheckCalls).toBe(1);
  282. expect(fallback.healthcheckCalls).toBe(0);
  283. });
  284. test("primary unhealthy → fallback checked + reported", async () => {
  285. const { af, primary, fallback } = buildAutoFallback();
  286. primary.healthResponse = { ok: false, model: "primary-model", detail: "down" };
  287. fallback.healthResponse = { ok: true, model: "local-model", detail: "fine" };
  288. const h = await af.healthcheck();
  289. expect(h.ok).toBe(true);
  290. expect(primary.healthcheckCalls).toBe(1);
  291. expect(fallback.healthcheckCalls).toBe(1);
  292. expect(h.detail).toContain("primary");
  293. expect(h.detail).toContain("fallback");
  294. });
  295. test("both unhealthy → ok=false", async () => {
  296. const { af, primary, fallback } = buildAutoFallback();
  297. primary.healthResponse = { ok: false, model: "p", detail: "down" };
  298. fallback.healthResponse = { ok: false, model: "f", detail: "down" };
  299. const h = await af.healthcheck();
  300. expect(h.ok).toBe(false);
  301. });
  302. });
  303. // ─────────────────────────── dispose ─────────────────────────────────────────
  304. describe("AutoFallbackEmbeddingProvider — dispose", () => {
  305. test("dispose cascades to both providers", async () => {
  306. const { af, primary, fallback } = buildAutoFallback();
  307. await af.dispose();
  308. expect(primary.disposed).toBe(true);
  309. expect(fallback.disposed).toBe(true);
  310. });
  311. });