|
|
@@ -0,0 +1,721 @@
|
|
|
+/**
|
|
|
+ * embedding-openai.test.ts - Tests for OpenAIEmbeddingsProvider (HTTP backend).
|
|
|
+ *
|
|
|
+ * Uses a mock fetch — no network required. Covers:
|
|
|
+ * - 200 happy path
|
|
|
+ * - 429 → retry → success
|
|
|
+ * - 503 persistent → exhausted retries → null
|
|
|
+ * - 4xx (non-429) → no retry, immediate failure
|
|
|
+ * - batch chunking (>64 items → multiple HTTP calls)
|
|
|
+ * - timeout / abort
|
|
|
+ * - malformed JSON / missing data array
|
|
|
+ * - circuit breaker open + half-open recovery
|
|
|
+ * - dimension probing
|
|
|
+ * - healthcheck endpoint
|
|
|
+ */
|
|
|
+
|
|
|
+import { describe, test, expect, vi } from "vitest";
|
|
|
+import {
|
|
|
+ OpenAIEmbeddingsProvider,
|
|
|
+ CircuitBreaker,
|
|
|
+ CircuitOpenError,
|
|
|
+ HttpError,
|
|
|
+ isRetryableStatus,
|
|
|
+ chunkArray,
|
|
|
+ RETRY_BACKOFFS_MS,
|
|
|
+} from "../src/embedding/openai.js";
|
|
|
+
|
|
|
+// ─────────────────────────── Helpers ─────────────────────────────────────────
|
|
|
+
|
|
|
+function mockResponse(status: number, body: unknown, opts?: { delayMs?: number }): Response {
|
|
|
+ const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
|
+ const init: ResponseInit = {
|
|
|
+ status,
|
|
|
+ headers: { "content-type": "application/json" },
|
|
|
+ };
|
|
|
+ if (opts?.delayMs) {
|
|
|
+ // Synchronous Response — test code awaits it directly, so delayMs would
|
|
|
+ // need to be implemented in the fetch wrapper, not here.
|
|
|
+ }
|
|
|
+ return new Response(text, init);
|
|
|
+}
|
|
|
+
|
|
|
+function makeFetchSequence(responses: Array<() => Promise<Response> | Response>): {
|
|
|
+ fetchImpl: typeof fetch;
|
|
|
+ calls: { url: string; init?: RequestInit }[];
|
|
|
+} {
|
|
|
+ const calls: { url: string; init?: RequestInit }[] = [];
|
|
|
+ let i = 0;
|
|
|
+ const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
|
+ const url = typeof input === "string" ? input : input.toString();
|
|
|
+ calls.push({ url, init });
|
|
|
+ if (i >= responses.length) throw new Error(`Mock fetch exhausted at call ${i + 1}`);
|
|
|
+ const r = responses[i++]!();
|
|
|
+ return r instanceof Promise ? r : r;
|
|
|
+ }) as typeof fetch;
|
|
|
+ return { fetchImpl, calls };
|
|
|
+}
|
|
|
+
|
|
|
+function fakeEmbedding(dim: number, seed = 0): number[] {
|
|
|
+ return Array.from({ length: dim }, (_, i) => Math.sin(seed + i) * 0.5);
|
|
|
+}
|
|
|
+
|
|
|
+function embeddingsResponse(texts: string[], dim = 4): Response {
|
|
|
+ return mockResponse(200, {
|
|
|
+ object: "list",
|
|
|
+ model: "embeddinggemma:300m",
|
|
|
+ data: texts.map((_, i) => ({
|
|
|
+ object: "embedding",
|
|
|
+ index: i,
|
|
|
+ embedding: fakeEmbedding(dim, i * 7),
|
|
|
+ })),
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ─────────────────────────── Pure helpers ────────────────────────────────────
|
|
|
+
|
|
|
+describe("isRetryableStatus", () => {
|
|
|
+ test("429 retryable", () => expect(isRetryableStatus(429)).toBe(true));
|
|
|
+ test("503 retryable", () => expect(isRetryableStatus(503)).toBe(true));
|
|
|
+ test("400 NOT retryable", () => expect(isRetryableStatus(400)).toBe(false));
|
|
|
+ test("401 NOT retryable", () => expect(isRetryableStatus(401)).toBe(false));
|
|
|
+ test("404 NOT retryable", () => expect(isRetryableStatus(404)).toBe(false));
|
|
|
+ test("500 NOT retryable", () => expect(isRetryableStatus(500)).toBe(false));
|
|
|
+ test("502 NOT retryable", () => expect(isRetryableStatus(502)).toBe(false));
|
|
|
+ test("200 NOT retryable", () => expect(isRetryableStatus(200)).toBe(false));
|
|
|
+});
|
|
|
+
|
|
|
+describe("chunkArray", () => {
|
|
|
+ test("empty input → empty output", () => {
|
|
|
+ expect(chunkArray([], 5)).toEqual([]);
|
|
|
+ });
|
|
|
+ test("input ≤ size → single chunk", () => {
|
|
|
+ expect(chunkArray([1, 2, 3], 5)).toEqual([[1, 2, 3]]);
|
|
|
+ });
|
|
|
+ test("input = size → single chunk", () => {
|
|
|
+ expect(chunkArray([1, 2, 3, 4, 5], 5)).toEqual([[1, 2, 3, 4, 5]]);
|
|
|
+ });
|
|
|
+ test("input > size → multiple chunks", () => {
|
|
|
+ expect(chunkArray([1, 2, 3, 4, 5, 6, 7], 3)).toEqual([
|
|
|
+ [1, 2, 3],
|
|
|
+ [4, 5, 6],
|
|
|
+ [7],
|
|
|
+ ]);
|
|
|
+ });
|
|
|
+ test("65 items at size 64 → 64 + 1", () => {
|
|
|
+ const items = Array.from({ length: 65 }, (_, i) => i);
|
|
|
+ const chunks = chunkArray(items, 64);
|
|
|
+ expect(chunks.length).toBe(2);
|
|
|
+ expect(chunks[0]!.length).toBe(64);
|
|
|
+ expect(chunks[1]!.length).toBe(1);
|
|
|
+ });
|
|
|
+ test("size < 1 throws", () => {
|
|
|
+ expect(() => chunkArray([1, 2, 3], 0)).toThrow();
|
|
|
+ expect(() => chunkArray([1, 2, 3], -1)).toThrow();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Circuit Breaker ─────────────────────────────────
|
|
|
+
|
|
|
+describe("CircuitBreaker", () => {
|
|
|
+ test("starts closed", () => {
|
|
|
+ const cb = new CircuitBreaker();
|
|
|
+ expect(cb.getState()).toBe("closed");
|
|
|
+ expect(cb.shouldFailFast()).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("stays closed below minSamples even with all-failures", () => {
|
|
|
+ const cb = new CircuitBreaker({ minSamples: 4, threshold: 0.5 });
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ expect(cb.getState()).toBe("closed");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("opens when failure rate exceeds threshold", () => {
|
|
|
+ const cb = new CircuitBreaker({ minSamples: 4, threshold: 0.5 });
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ expect(cb.getState()).toBe("open");
|
|
|
+ expect(cb.shouldFailFast()).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("transitions OPEN → HALF-OPEN after openDurationMs", () => {
|
|
|
+ let now = 1_000_000;
|
|
|
+ const cb = new CircuitBreaker({
|
|
|
+ minSamples: 4,
|
|
|
+ threshold: 0.5,
|
|
|
+ openDurationMs: 5000,
|
|
|
+ now: () => now,
|
|
|
+ });
|
|
|
+ for (let i = 0; i < 4; i++) cb.recordFailure();
|
|
|
+ expect(cb.getState()).toBe("open");
|
|
|
+ now += 5001;
|
|
|
+ expect(cb.getState()).toBe("half-open");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("HALF-OPEN + success → CLOSED", () => {
|
|
|
+ let now = 1_000_000;
|
|
|
+ const cb = new CircuitBreaker({
|
|
|
+ minSamples: 4,
|
|
|
+ threshold: 0.5,
|
|
|
+ openDurationMs: 5000,
|
|
|
+ now: () => now,
|
|
|
+ });
|
|
|
+ for (let i = 0; i < 4; i++) cb.recordFailure();
|
|
|
+ now += 5001;
|
|
|
+ cb.recordSuccess(); // half-open probe
|
|
|
+ expect(cb.getState()).toBe("closed");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("HALF-OPEN + failure → re-OPEN", () => {
|
|
|
+ let now = 1_000_000;
|
|
|
+ const cb = new CircuitBreaker({
|
|
|
+ minSamples: 4,
|
|
|
+ threshold: 0.5,
|
|
|
+ openDurationMs: 5000,
|
|
|
+ now: () => now,
|
|
|
+ });
|
|
|
+ for (let i = 0; i < 4; i++) cb.recordFailure();
|
|
|
+ now += 5001;
|
|
|
+ expect(cb.getState()).toBe("half-open");
|
|
|
+ cb.recordFailure();
|
|
|
+ expect(cb.getState()).toBe("open");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("samples outside window are dropped", () => {
|
|
|
+ let now = 1_000_000;
|
|
|
+ const cb = new CircuitBreaker({
|
|
|
+ minSamples: 4,
|
|
|
+ threshold: 0.5,
|
|
|
+ windowMs: 1000,
|
|
|
+ now: () => now,
|
|
|
+ });
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ now += 1500; // window expired
|
|
|
+ cb.recordSuccess();
|
|
|
+ cb.recordSuccess();
|
|
|
+ cb.recordSuccess();
|
|
|
+ cb.recordSuccess();
|
|
|
+ // Old failures should be discarded; rate = 0/4 < threshold
|
|
|
+ expect(cb.getState()).toBe("closed");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("reset() clears state", () => {
|
|
|
+ const cb = new CircuitBreaker({ minSamples: 2, threshold: 0.5 });
|
|
|
+ cb.recordFailure();
|
|
|
+ cb.recordFailure();
|
|
|
+ expect(cb.getState()).toBe("open");
|
|
|
+ cb.reset();
|
|
|
+ expect(cb.getState()).toBe("closed");
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── HappyPath ───────────────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — happy path", () => {
|
|
|
+ test("single embed call → 200 success", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["hello"], 4),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const r = await p.embed("hello");
|
|
|
+ expect(r).not.toBeNull();
|
|
|
+ expect(r!.embedding.length).toBe(4);
|
|
|
+ expect(r!.model).toBe("embeddinggemma");
|
|
|
+ expect(p.getDimensions()).toBe(4);
|
|
|
+ expect(calls.length).toBe(1);
|
|
|
+ expect(calls[0]!.url).toBe("https://ai.example.com/v1/embeddings");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("strips trailing slashes from endpoint", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["x"], 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com////",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ await p.embed("x");
|
|
|
+ expect(calls[0]!.url).toBe("https://ai.example.com/v1/embeddings");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("batch of 3 → 1 HTTP call", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["a", "b", "c"], 3),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const result = await p.embedBatch(["a", "b", "c"]);
|
|
|
+ expect(result.length).toBe(3);
|
|
|
+ expect(result.every((r) => r !== null)).toBe(true);
|
|
|
+ expect(calls.length).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("respects custom modelId / upstreamModel in request body", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["x"], 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ modelId: "embeddinggemma",
|
|
|
+ upstreamModel: "embeddinggemma:300m",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r!.model).toBe("embeddinggemma");
|
|
|
+ const body = JSON.parse(calls[0]!.init!.body as string);
|
|
|
+ expect(body.model).toBe("embeddinggemma:300m");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("Authorization header set when apiKey provided", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["x"], 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ apiKey: "sk-test-123",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ await p.embed("x");
|
|
|
+ const headers = calls[0]!.init!.headers as Record<string, string>;
|
|
|
+ expect(headers["Authorization"]).toBe("Bearer sk-test-123");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("Authorization header omitted when apiKey not provided", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["x"], 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ await p.embed("x");
|
|
|
+ const headers = calls[0]!.init!.headers as Record<string, string>;
|
|
|
+ expect(headers["Authorization"]).toBeUndefined();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Batch chunking ──────────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — batch chunking", () => {
|
|
|
+ test("100 items at batchSize=64 → 2 HTTP calls (64 + 36)", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(Array.from({ length: 64 }, () => "x"), 4),
|
|
|
+ () => embeddingsResponse(Array.from({ length: 36 }, () => "x"), 4),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ batchSize: 64,
|
|
|
+ });
|
|
|
+ const texts = Array.from({ length: 100 }, (_, i) => `text-${i}`);
|
|
|
+ const result = await p.embedBatch(texts);
|
|
|
+ expect(result.length).toBe(100);
|
|
|
+ expect(result.every((r) => r !== null)).toBe(true);
|
|
|
+ expect(calls.length).toBe(2);
|
|
|
+
|
|
|
+ const body0 = JSON.parse(calls[0]!.init!.body as string);
|
|
|
+ const body1 = JSON.parse(calls[1]!.init!.body as string);
|
|
|
+ expect(body0.input.length).toBe(64);
|
|
|
+ expect(body1.input.length).toBe(36);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("custom batchSize=10 → multiple smaller calls", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(Array.from({ length: 10 }, () => "x"), 2),
|
|
|
+ () => embeddingsResponse(Array.from({ length: 10 }, () => "x"), 2),
|
|
|
+ () => embeddingsResponse(Array.from({ length: 5 }, () => "x"), 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ batchSize: 10,
|
|
|
+ });
|
|
|
+ const texts = Array.from({ length: 25 }, (_, i) => `t${i}`);
|
|
|
+ const result = await p.embedBatch(texts);
|
|
|
+ expect(result.length).toBe(25);
|
|
|
+ expect(result.every((r) => r !== null)).toBe(true);
|
|
|
+ expect(calls.length).toBe(3);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("empty input → no HTTP calls", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const result = await p.embedBatch([]);
|
|
|
+ expect(result).toEqual([]);
|
|
|
+ expect(calls.length).toBe(0);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Retry behavior ──────────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — retry on 429/503", () => {
|
|
|
+ test("429 → retry → success", async () => {
|
|
|
+ const sleepCalls: number[] = [];
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => mockResponse(429, { error: "rate limit" }),
|
|
|
+ () => embeddingsResponse(["x"], 4),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [10, 20, 40],
|
|
|
+ sleep: async (ms) => {
|
|
|
+ sleepCalls.push(ms);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).not.toBeNull();
|
|
|
+ expect(calls.length).toBe(2);
|
|
|
+ expect(sleepCalls).toEqual([10]);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("503 → retry → success", async () => {
|
|
|
+ const sleepCalls: number[] = [];
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => mockResponse(503, { error: "service unavailable" }),
|
|
|
+ () => embeddingsResponse(["x"], 4),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [5, 10, 20],
|
|
|
+ sleep: async (ms) => {
|
|
|
+ sleepCalls.push(ms);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).not.toBeNull();
|
|
|
+ expect(calls.length).toBe(2);
|
|
|
+ expect(sleepCalls).toEqual([5]);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("503 persistent → exhausted retries → null result", async () => {
|
|
|
+ const sleepCalls: number[] = [];
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => mockResponse(503, "down"),
|
|
|
+ () => mockResponse(503, "down"),
|
|
|
+ () => mockResponse(503, "down"),
|
|
|
+ () => mockResponse(503, "down"),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [1, 2, 4],
|
|
|
+ sleep: async (ms) => {
|
|
|
+ sleepCalls.push(ms);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).toBeNull();
|
|
|
+ expect(calls.length).toBe(4); // initial + 3 retries
|
|
|
+ expect(sleepCalls).toEqual([1, 2, 4]);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("default backoff schedule is 1s/4s/16s", () => {
|
|
|
+ expect(RETRY_BACKOFFS_MS).toEqual([1000, 4000, 16000]);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("4xx (non-429) → immediate failure, no retry", async () => {
|
|
|
+ const sleepCalls: number[] = [];
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => mockResponse(401, { error: "unauthorized" }),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [10, 20, 40],
|
|
|
+ sleep: async (ms) => {
|
|
|
+ sleepCalls.push(ms);
|
|
|
+ },
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).toBeNull();
|
|
|
+ expect(calls.length).toBe(1); // no retries
|
|
|
+ expect(sleepCalls).toEqual([]);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("404 → immediate failure, no retry", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => mockResponse(404, "not found"),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [1],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ await p.embed("x");
|
|
|
+ expect(calls.length).toBe(1);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Malformed responses ─────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — malformed responses", () => {
|
|
|
+ test("malformed JSON → null result", async () => {
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () => new Response("not-json{}", { status: 200 }),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ test("missing data array → null result", async () => {
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () => mockResponse(200, { object: "list", model: "x" }),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ test("data item index out of range → null result", async () => {
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () =>
|
|
|
+ mockResponse(200, {
|
|
|
+ object: "list",
|
|
|
+ data: [
|
|
|
+ { index: 5, embedding: [0.1, 0.2] }, // out of range for 1 input
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ const r = await p.embed("x");
|
|
|
+ expect(r).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ test("response handles out-of-order data array (sorts by index)", async () => {
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () =>
|
|
|
+ mockResponse(200, {
|
|
|
+ object: "list",
|
|
|
+ data: [
|
|
|
+ { index: 1, embedding: [0.7, 0.8] },
|
|
|
+ { index: 0, embedding: [0.1, 0.2] },
|
|
|
+ ],
|
|
|
+ }),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const result = await p.embedBatch(["zero", "one"]);
|
|
|
+ expect(result.length).toBe(2);
|
|
|
+ expect(result[0]!.embedding).toEqual([0.1, 0.2]);
|
|
|
+ expect(result[1]!.embedding).toEqual([0.7, 0.8]);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Timeout / abort ─────────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — timeout and abort", () => {
|
|
|
+ test("user abort signal → null result + no further calls", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () => embeddingsResponse(["a", "b"], 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ batchSize: 1,
|
|
|
+ });
|
|
|
+ const ctrl = new AbortController();
|
|
|
+ ctrl.abort(new Error("user cancelled"));
|
|
|
+ const result = await p.embedBatch(["a", "b"], { signal: ctrl.signal });
|
|
|
+ expect(result).toEqual([null, null]);
|
|
|
+ expect(calls.length).toBe(0); // signal aborted before first call
|
|
|
+ });
|
|
|
+
|
|
|
+ test("per-attempt timeout aborts a slow request", async () => {
|
|
|
+ let aborted = false;
|
|
|
+ const fetchImpl = (async (_url: any, init?: RequestInit) => {
|
|
|
+ return await new Promise<Response>((_resolve, reject) => {
|
|
|
+ const sig = init?.signal;
|
|
|
+ sig?.addEventListener("abort", () => {
|
|
|
+ aborted = true;
|
|
|
+ reject(new DOMException("aborted", "AbortError"));
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }) as typeof fetch;
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ timeoutMs: 50,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ const r = await p.embed("hello");
|
|
|
+ expect(r).toBeNull();
|
|
|
+ expect(aborted).toBe(true);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Circuit breaker integration ─────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — circuit breaker integration", () => {
|
|
|
+ test("repeated failures eventually trip breaker → CircuitOpenError", async () => {
|
|
|
+ // 4 chunks of size 1 = 4 sample slots. All fail with 401 → 4 failures → breaker opens.
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"), // shouldn't be reached
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ batchSize: 1,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ // First call: 4 sub-chunks, all fail, breaker opens during 4th
|
|
|
+ const result1 = await p.embedBatch(["a", "b", "c", "d"]);
|
|
|
+ expect(result1.every((x) => x === null)).toBe(true);
|
|
|
+ expect(p.breaker.getState()).toBe("open");
|
|
|
+
|
|
|
+ // Second call: breaker fails fast
|
|
|
+ await expect(p.embedBatch(["e"])).rejects.toBeInstanceOf(CircuitOpenError);
|
|
|
+ });
|
|
|
+
|
|
|
+ test("breaker recovers after openDuration → success closes it", async () => {
|
|
|
+ let now = 1_000_000;
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => embeddingsResponse(["recovered"], 2),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ batchSize: 1,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ now: () => now,
|
|
|
+ });
|
|
|
+ // Override breaker with a shorter open duration
|
|
|
+ (p as any).breaker = new CircuitBreaker({
|
|
|
+ minSamples: 4,
|
|
|
+ threshold: 0.5,
|
|
|
+ openDurationMs: 1000,
|
|
|
+ now: () => now,
|
|
|
+ });
|
|
|
+ await p.embedBatch(["a", "b", "c", "d"]);
|
|
|
+ expect((p as any).breaker.getState()).toBe("open");
|
|
|
+ now += 1500;
|
|
|
+ expect((p as any).breaker.getState()).toBe("half-open");
|
|
|
+ const r = await p.embed("recovered");
|
|
|
+ expect(r).not.toBeNull();
|
|
|
+ expect((p as any).breaker.getState()).toBe("closed");
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── Healthcheck ─────────────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — healthcheck", () => {
|
|
|
+ test("healthcheck pings GET /health when available", async () => {
|
|
|
+ const { fetchImpl, calls } = makeFetchSequence([
|
|
|
+ () =>
|
|
|
+ mockResponse(200, {
|
|
|
+ status: "ok",
|
|
|
+ model: "embeddinggemma:300m",
|
|
|
+ }),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const h = await p.healthcheck();
|
|
|
+ expect(h.ok).toBe(true);
|
|
|
+ expect(calls.length).toBe(1);
|
|
|
+ expect(calls[0]!.url).toBe("https://ai.example.com/health");
|
|
|
+ expect(calls[0]!.init!.method).toBe("GET");
|
|
|
+ });
|
|
|
+
|
|
|
+ test("healthcheck failure → falls through to embed probe", async () => {
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () => mockResponse(404, "no /health"),
|
|
|
+ // Then fall back to /v1/embeddings probe
|
|
|
+ () => embeddingsResponse(["healthcheck"], 4),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ });
|
|
|
+ const h = await p.healthcheck();
|
|
|
+ // 404 isn't an exception, it returns ok:false from the /health branch
|
|
|
+ // The fallback probe is only triggered on actual exceptions.
|
|
|
+ expect(h.ok).toBe(false);
|
|
|
+ expect(h.detail).toContain("404");
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── HttpError ───────────────────────────────────────
|
|
|
+
|
|
|
+describe("HttpError", () => {
|
|
|
+ test("preserves status and body preview", () => {
|
|
|
+ const err = new HttpError(429, "rate limit exceeded");
|
|
|
+ expect(err.status).toBe(429);
|
|
|
+ expect(err.bodyPreview).toBe("rate limit exceeded");
|
|
|
+ expect(err.message).toContain("HTTP 429");
|
|
|
+ });
|
|
|
+ test("truncates long bodies in message", () => {
|
|
|
+ const longBody = "x".repeat(500);
|
|
|
+ const err = new HttpError(500, longBody);
|
|
|
+ expect(err.message.length).toBeLessThan(longBody.length + 200);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+// ─────────────────────────── dispose ─────────────────────────────────────────
|
|
|
+
|
|
|
+describe("OpenAIEmbeddingsProvider — dispose", () => {
|
|
|
+ test("dispose resets the breaker", async () => {
|
|
|
+ const { fetchImpl } = makeFetchSequence([
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ () => mockResponse(401, "fail"),
|
|
|
+ ]);
|
|
|
+ const p = new OpenAIEmbeddingsProvider({
|
|
|
+ endpoint: "https://ai.example.com",
|
|
|
+ fetchImpl,
|
|
|
+ batchSize: 1,
|
|
|
+ retryBackoffsMs: [],
|
|
|
+ sleep: async () => {},
|
|
|
+ });
|
|
|
+ await p.embedBatch(["a", "b", "c", "d"]);
|
|
|
+ expect(p.breaker.getState()).toBe("open");
|
|
|
+ await p.dispose();
|
|
|
+ expect(p.breaker.getState()).toBe("closed");
|
|
|
+ });
|
|
|
+});
|