embedding-autofallback.test.ts 15 KB

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