| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396 |
- /**
- * embedding-autofallback.test.ts - Tests for AutoFallbackEmbeddingProvider.
- */
- import { describe, test, expect } from "vitest";
- import {
- AutoFallbackEmbeddingProvider,
- type AutoFallbackProviderConfig,
- } from "../src/embedding/autofallback.js";
- import { CircuitOpenError } from "../src/embedding/openai.js";
- import type {
- EmbeddingProvider,
- ProviderEmbedOptions,
- ProviderEmbedding,
- ProviderHealth,
- ProviderKind,
- } from "../src/embedding/provider.js";
- // ─────────────────────────── Test fakes ──────────────────────────────────────
- class FakeProvider implements EmbeddingProvider {
- readonly kind: ProviderKind;
- readonly modelId: string;
- readonly dim: number;
- embedCalls = 0;
- embedBatchCalls = 0;
- healthcheckCalls = 0;
- disposed = false;
- /** Override behavior for next N calls */
- nextThrows: Array<Error | null> = [];
- /** Always-throw mode */
- alwaysThrows: Error | null = null;
- /** Health response */
- healthResponse: ProviderHealth | null = null;
- /** Stub for getLastError() return value */
- lastErr: string | undefined = undefined;
- constructor(kind: ProviderKind, modelId: string, dim = 4) {
- this.kind = kind;
- this.modelId = modelId;
- this.dim = dim;
- }
- getModelId(): string {
- return this.modelId;
- }
- getDimensions(): number | undefined {
- return this.dim;
- }
- getLastError(): string | undefined {
- return this.lastErr;
- }
- async healthcheck(): Promise<ProviderHealth> {
- this.healthcheckCalls++;
- if (this.healthResponse) return this.healthResponse;
- return { ok: true, model: this.modelId, dimensions: this.dim };
- }
- async embed(text: string, _options?: ProviderEmbedOptions): Promise<ProviderEmbedding | null> {
- this.embedCalls++;
- this.maybeThrow();
- return { embedding: this.fakeEmbed(text), model: this.modelId };
- }
- async embedBatch(texts: string[], _options?: ProviderEmbedOptions): Promise<(ProviderEmbedding | null)[]> {
- this.embedBatchCalls++;
- this.maybeThrow();
- return texts.map((t) => ({ embedding: this.fakeEmbed(t), model: this.modelId }));
- }
- async dispose(): Promise<void> {
- this.disposed = true;
- }
- private maybeThrow(): void {
- if (this.alwaysThrows) throw this.alwaysThrows;
- const next = this.nextThrows.shift();
- if (next) throw next;
- }
- private fakeEmbed(text: string): number[] {
- return Array.from({ length: this.dim }, (_, i) => (text.length + i) * 0.01);
- }
- }
- function buildAutoFallback(opts: Partial<AutoFallbackProviderConfig> = {}): {
- af: AutoFallbackEmbeddingProvider;
- primary: FakeProvider;
- fallback: FakeProvider;
- warns: string[];
- setNow: (n: number) => void;
- } {
- const primary = new FakeProvider("openai", "embeddinggemma");
- const fallback = new FakeProvider("local", "embeddinggemma");
- const warns: string[] = [];
- let now = 1_000_000;
- const af = new AutoFallbackEmbeddingProvider({
- primary,
- fallback,
- failureStreakThreshold: opts.failureStreakThreshold ?? 3,
- cooldownMs: opts.cooldownMs ?? 60_000,
- warn: (m) => warns.push(m),
- now: () => now,
- ...opts,
- });
- return { af, primary, fallback, warns, setNow: (n) => (now = n) };
- }
- // ─────────────────────────── Construction ────────────────────────────────────
- describe("AutoFallbackEmbeddingProvider — construction", () => {
- test("requires primary", () => {
- expect(
- () =>
- new AutoFallbackEmbeddingProvider({
- // @ts-expect-error testing runtime guard
- primary: undefined,
- fallback: new FakeProvider("local", "x"),
- }),
- ).toThrow(/primary is required/);
- });
- test("requires fallback", () => {
- expect(
- () =>
- new AutoFallbackEmbeddingProvider({
- primary: new FakeProvider("openai", "x"),
- // @ts-expect-error testing runtime guard
- fallback: undefined,
- }),
- ).toThrow(/fallback is required/);
- });
- test("rejects identical primary and fallback", () => {
- const same = new FakeProvider("openai", "x");
- expect(
- () =>
- new AutoFallbackEmbeddingProvider({
- primary: same,
- fallback: same,
- }),
- ).toThrow(/must differ/);
- });
- test("inherits primary's kind", () => {
- const { af } = buildAutoFallback();
- expect(af.kind).toBe("openai");
- });
- });
- // ─────────────────────────── Happy path ──────────────────────────────────────
- describe("AutoFallbackEmbeddingProvider — happy path", () => {
- test("primary succeeds → fallback never called", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- const r = await af.embed("hello");
- expect(r).not.toBeNull();
- expect(primary.embedCalls).toBe(1);
- expect(fallback.embedCalls).toBe(0);
- expect(af.getRoutingState()).toBe("primary");
- });
- test("primary embedBatch succeeds → fallback untouched", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- const out = await af.embedBatch(["a", "b"]);
- expect(out.length).toBe(2);
- expect(primary.embedBatchCalls).toBe(1);
- expect(fallback.embedBatchCalls).toBe(0);
- });
- test("getModelId / getDimensions delegate to primary", () => {
- const { af, primary } = buildAutoFallback();
- expect(af.getModelId()).toBe(primary.getModelId());
- expect(af.getDimensions()).toBe(primary.getDimensions());
- });
- });
- // ─────────────────────────── Circuit-open fallback ───────────────────────────
- describe("AutoFallbackEmbeddingProvider — CircuitOpenError handling", () => {
- test("primary throws CircuitOpenError → fallback served + cooldown opens", async () => {
- const { af, primary, fallback, warns } = buildAutoFallback();
- primary.nextThrows.push(new CircuitOpenError());
- const r = await af.embed("hello");
- expect(r).not.toBeNull();
- expect(r!.embedding.length).toBe(4); // came from fallback
- expect(primary.embedCalls).toBe(1);
- expect(fallback.embedCalls).toBe(1);
- expect(af.getRoutingState()).toBe("fallback");
- expect(warns.some((w) => w.includes("CircuitOpenError"))).toBe(true);
- });
- test("during cooldown subsequent calls skip primary entirely", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.nextThrows.push(new CircuitOpenError());
- await af.embed("first");
- expect(primary.embedCalls).toBe(1);
- expect(fallback.embedCalls).toBe(1);
- // Subsequent call within cooldown
- await af.embed("second");
- expect(primary.embedCalls).toBe(1); // unchanged
- expect(fallback.embedCalls).toBe(2);
- });
- test("after cooldown expires, primary is retried", async () => {
- const { af, primary, fallback, setNow } = buildAutoFallback({ cooldownMs: 5000 });
- primary.nextThrows.push(new CircuitOpenError());
- await af.embed("a");
- expect(af.getRoutingState()).toBe("fallback");
- setNow(1_000_000 + 5_001);
- expect(af.getRoutingState()).toBe("primary");
- // Next call reaches primary again
- await af.embed("b");
- expect(primary.embedCalls).toBe(2);
- expect(fallback.embedCalls).toBe(1);
- });
- test("WARN fired only once per transition (not per call during cooldown)", async () => {
- const { af, primary, warns } = buildAutoFallback();
- primary.nextThrows.push(new CircuitOpenError());
- await af.embed("a");
- await af.embed("b");
- await af.embed("c");
- const fallbackWarns = warns.filter((w) => w.includes("falling back"));
- expect(fallbackWarns.length).toBe(1);
- });
- });
- // ─────────────────────────── Failure-streak threshold ────────────────────────
- describe("AutoFallbackEmbeddingProvider — failure streak", () => {
- test("non-CircuitOpen errors below threshold → no cooldown", async () => {
- const { af, primary, fallback } = buildAutoFallback({ failureStreakThreshold: 3 });
- primary.nextThrows.push(new Error("transient"));
- const r = await af.embed("a");
- expect(r).not.toBeNull(); // fallback served it
- expect(af.getRoutingState()).toBe("primary");
- expect(primary.embedCalls).toBe(1);
- expect(fallback.embedCalls).toBe(1);
- });
- test("threshold consecutive failures → cooldown opens", async () => {
- const { af, primary, fallback } = buildAutoFallback({ failureStreakThreshold: 3 });
- for (let i = 0; i < 3; i++) {
- primary.nextThrows.push(new Error(`err ${i}`));
- }
- await af.embed("a");
- await af.embed("b");
- await af.embed("c");
- expect(af.getRoutingState()).toBe("fallback");
- expect(primary.embedCalls).toBe(3);
- expect(fallback.embedCalls).toBe(3);
- });
- test("a single primary success resets the streak", async () => {
- const { af, primary } = buildAutoFallback({ failureStreakThreshold: 3 });
- primary.nextThrows.push(new Error("e1"));
- primary.nextThrows.push(new Error("e2"));
- await af.embed("a");
- await af.embed("b");
- // Now success
- await af.embed("c");
- // Streak reset; another two failures shouldn't trip cooldown yet
- primary.nextThrows.push(new Error("e3"));
- primary.nextThrows.push(new Error("e4"));
- await af.embed("d");
- await af.embed("e");
- expect(af.getRoutingState()).toBe("primary");
- });
- });
- // ─────────────────────────── Recovery transition ─────────────────────────────
- describe("AutoFallbackEmbeddingProvider — recovery transitions", () => {
- test("recovery WARN fires when primary call succeeds after fallback", async () => {
- const { af, primary, warns, setNow } = buildAutoFallback({ cooldownMs: 5000 });
- primary.nextThrows.push(new CircuitOpenError());
- await af.embed("a");
- setNow(1_000_000 + 5_001);
- await af.embed("b"); // primary succeeds
- const recoveryWarns = warns.filter((w) => w.includes("recovered"));
- expect(recoveryWarns.length).toBe(1);
- });
- test("reset() clears state + transitions back to primary", async () => {
- const { af, primary } = buildAutoFallback({ cooldownMs: 60_000 });
- primary.nextThrows.push(new CircuitOpenError());
- await af.embed("a");
- expect(af.getRoutingState()).toBe("fallback");
- af.reset();
- expect(af.getRoutingState()).toBe("primary");
- await af.embed("b");
- expect(primary.embedCalls).toBe(2);
- });
- });
- // ─────────────────────────── Both fail ───────────────────────────────────────
- describe("AutoFallbackEmbeddingProvider — both providers fail", () => {
- test("primary throws + fallback throws → embedBatch returns nulls", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.alwaysThrows = new Error("primary down");
- fallback.alwaysThrows = new Error("local broken");
- const r = await af.embedBatch(["a", "b"]);
- expect(r).toEqual([null, null]);
- });
- test("primary throws + fallback throws → embed propagates fallback error", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.alwaysThrows = new Error("primary down");
- fallback.alwaysThrows = new Error("local broken");
- await expect(af.embed("a")).rejects.toThrow(/local broken/);
- });
- });
- // ─────────────────────────── Healthcheck ─────────────────────────────────────
- describe("AutoFallbackEmbeddingProvider — healthcheck", () => {
- test("primary healthy → returns primary health", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- const h = await af.healthcheck();
- expect(h.ok).toBe(true);
- expect(primary.healthcheckCalls).toBe(1);
- expect(fallback.healthcheckCalls).toBe(0);
- });
- test("primary unhealthy → fallback checked + reported", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.healthResponse = { ok: false, model: "primary-model", detail: "down" };
- fallback.healthResponse = { ok: true, model: "local-model", detail: "fine" };
- const h = await af.healthcheck();
- expect(h.ok).toBe(true);
- expect(primary.healthcheckCalls).toBe(1);
- expect(fallback.healthcheckCalls).toBe(1);
- expect(h.detail).toContain("primary");
- expect(h.detail).toContain("fallback");
- });
- test("both unhealthy → ok=false", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.healthResponse = { ok: false, model: "p", detail: "down" };
- fallback.healthResponse = { ok: false, model: "f", detail: "down" };
- const h = await af.healthcheck();
- expect(h.ok).toBe(false);
- });
- });
- // ─────────────────────────── getLastError (i-vm1lxwry) ──────────────────────
- describe("AutoFallbackEmbeddingProvider — getLastError (i-vm1lxwry)", () => {
- test("returns undefined when both legs are clean", () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.lastErr = undefined;
- fallback.lastErr = undefined;
- expect(af.getLastError()).toBeUndefined();
- });
- test("returns primary error when only primary has one", () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.lastErr = `endpoint=https://ai.mm.mk/v1/embeddings status=503 body="busy"`;
- fallback.lastErr = undefined;
- expect(af.getLastError()).toBe(primary.lastErr);
- });
- test("returns fallback error when only fallback has one", () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.lastErr = undefined;
- fallback.lastErr = `provider=local error="model file not found"`;
- expect(af.getLastError()).toBe(fallback.lastErr);
- });
- test("combines primary + fallback when both failed", () => {
- const { af, primary, fallback } = buildAutoFallback();
- primary.lastErr = `endpoint=https://ai.mm.mk/v1/embeddings status=503`;
- fallback.lastErr = `provider=local error="OOM"`;
- const combined = af.getLastError();
- expect(combined).toContain("primary:");
- expect(combined).toContain("fallback:");
- expect(combined).toContain("status=503");
- expect(combined).toContain("OOM");
- });
- });
- // ─────────────────────────── dispose ─────────────────────────────────────────
- describe("AutoFallbackEmbeddingProvider — dispose", () => {
- test("dispose cascades to both providers", async () => {
- const { af, primary, fallback } = buildAutoFallback();
- await af.dispose();
- expect(primary.disposed).toBe(true);
- expect(fallback.disposed).toBe(true);
- });
- });
|