Skip to content

Commit 7f8a442

Browse files
committed
Cover wire shape, auth, batchSize, Retry-After dates, override and timeout
1 parent c774b8f commit 7f8a442

6 files changed

Lines changed: 132 additions & 15 deletions

File tree

‎IMPLEMENTATION.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ package depends only on the published surface).
108108
- `tests/retry.test.ts` — 429 retried once after `Retry-After` on the
109109
harness clock; 401 rejects without retry.
110110
- `tests/base64.test.ts` — `base64` and `float` agree over a real HTTP stub.
111-
- `tests/live.test.ts` — round trip against a real `/v1/embeddings`
112-
(`EMBEDDING_E2E_BASE_URL`, default local Ollama; `EMBEDDING_E2E_MODEL`,
113-
default `nomic-embed-text`). Skips when unreachable.
111+
- `live/embeddings.test.ts` (`bun run test:live`, not in `test` or CI) —
112+
round trip against a real `/v1/embeddings` (`EMBEDDING_E2E_BASE_URL`,
113+
required; `EMBEDDING_E2E_MODEL`, default `nomic-embed-text`).
114+
Skips when unset or unreachable.
Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@ import { describe, expect, test } from "bun:test";
22

33
import { embedTexts, probeEmbedDims } from "../src/index";
44

5-
// Any OpenAI-compatible `/v1/embeddings` server; defaults to local Ollama.
6-
const config = {
7-
baseURL: process.env.EMBEDDING_E2E_BASE_URL ?? "http://localhost:11434/v1",
8-
model: process.env.EMBEDDING_E2E_MODEL ?? "nomic-embed-text",
9-
};
5+
// Any OpenAI-compatible `/v1/embeddings` server. Skips unless
6+
// EMBEDDING_E2E_BASE_URL is set, so it never loads a model on this machine.
7+
const baseURL = process.env.EMBEDDING_E2E_BASE_URL;
8+
const model = process.env.EMBEDDING_E2E_MODEL ?? "nomic-embed-text";
109

1110
// Skips unless the server answers and lists the model.
12-
const reachable = await fetch(`${config.baseURL}/models`, {
13-
signal: AbortSignal.timeout(1_000),
14-
}).then(
15-
async (res) => res.ok && (await res.text()).includes(`"${config.model}`),
16-
() => false,
17-
);
11+
const reachable =
12+
baseURL !== undefined &&
13+
(await fetch(`${baseURL}/models`, {
14+
signal: AbortSignal.timeout(1_000),
15+
}).then(
16+
async (res) => res.ok && (await res.text()).includes(`"${model}`),
17+
() => false,
18+
));
19+
const config = { baseURL: baseURL ?? "", model };
1820

1921
function cosine(a: number[], b: number[]): number {
2022
let dot = 0;

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"prepack": "bun run build",
1919
"typecheck": "tsc --noEmit",
2020
"test": "bun test ./src ./tests",
21+
"test:live": "bun test ./live",
2122
"format": "prettier -w ."
2223
},
2324
"dependencies": {

‎src/embed.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ test("rejects duplicate indices and undecodable base64", async () => {
8787
],
8888
[
8989
{ index: 0, embedding: "!!!notbase64" },
90+
{ index: 1, embedding: "AAAAAA==" },
91+
],
92+
[
93+
{ index: 0, embedding: "AAAAAA==" },
9094
{ index: 1, embedding: "AAA=" },
9195
],
9296
]) {
@@ -111,3 +115,56 @@ test("classifies a 200 that is not JSON instead of leaking a SyntaxError", async
111115
reason: { category: "protocol_mismatch" },
112116
});
113117
});
118+
119+
test("sends only configured fields to {baseURL}/embeddings", async () => {
120+
harness = setupHarness();
121+
const stream = harness.scenario.createStream();
122+
harness.scenario.whenRequestBodyMatches(
123+
(body, req) =>
124+
req.url === "https://embed.example/v1/embeddings" &&
125+
!req.headers.has("authorization") &&
126+
body === JSON.stringify({ model: CONFIG.model, input: ["a"] }),
127+
stream,
128+
);
129+
stream.enqueueAll(
130+
[new TextEncoder().encode('{"data":[{"index":0,"embedding":[1]}]}')],
131+
{ startAt: 1 },
132+
);
133+
const pending = embedTexts(["a"], CONFIG, { deps: harness.deps });
134+
expect(await run(harness, pending)).toEqual([[1]]);
135+
});
136+
137+
test("sends a bearer token only when apiKey is set", async () => {
138+
harness = setupHarness();
139+
const stream = harness.scenario.createStream();
140+
harness.scenario.whenRequestMatches(
141+
(req) => req.headers.get("authorization") === "Bearer sk-test",
142+
stream,
143+
);
144+
stream.enqueueAll(
145+
[new TextEncoder().encode('{"data":[{"index":0,"embedding":[1]}]}')],
146+
{ startAt: 1 },
147+
);
148+
const pending = embedTexts(
149+
["a"],
150+
{ ...CONFIG, apiKey: "sk-test" },
151+
{ deps: harness.deps },
152+
);
153+
expect(await run(harness, pending)).toEqual([[1]]);
154+
});
155+
156+
test("returns [] for empty input without a request", async () => {
157+
harness = setupHarness();
158+
const pending = embedTexts([], CONFIG, { deps: harness.deps });
159+
expect(await run(harness, pending)).toEqual([]);
160+
});
161+
162+
test("rejects a non-positive batchSize before any request", async () => {
163+
harness = setupHarness();
164+
for (const batchSize of [0, -1, 1.5]) {
165+
await expect(
166+
embedTexts(["a"], { ...CONFIG, batchSize }, { deps: harness.deps }),
167+
).rejects.toThrow(/batchSize/);
168+
}
169+
await harness.run();
170+
});

‎tests/retry.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,59 @@ test("rejects a 401 without retrying", async () => {
5353
reason: { category: "credential_failure", statusCode: 401 },
5454
});
5555
});
56+
57+
test("waits until an HTTP-date Retry-After", async () => {
58+
harness = setupHarness({ enableInferenceTimers: true });
59+
const at = new Date(Date.now() + 20_000).toUTCString();
60+
reply(harness, "rate limited", 429, { "retry-after": at });
61+
reply(harness, JSON.stringify({ data: [{ index: 0, embedding: [1] }] }), 200);
62+
63+
const pending = embedTexts(["a"], CONFIG, { deps: harness.deps });
64+
await harness.run();
65+
66+
expect(await pending).toEqual([[1]]);
67+
expect(harness.clock.now()).toBeGreaterThanOrEqual(19_000);
68+
});
69+
70+
test("treats a past HTTP-date Retry-After as no wait", async () => {
71+
harness = setupHarness({ enableInferenceTimers: true });
72+
const at = new Date(Date.now() - 60_000).toUTCString();
73+
reply(harness, "rate limited", 429, { "retry-after": at });
74+
reply(harness, JSON.stringify({ data: [{ index: 0, embedding: [1] }] }), 200);
75+
76+
const pending = embedTexts(["a"], CONFIG, { deps: harness.deps });
77+
await harness.run();
78+
79+
expect(await pending).toEqual([[1]]);
80+
expect(harness.clock.now()).toBeLessThan(1_000);
81+
});
82+
83+
test("honors a caller-supplied extractRetryAfterMs", async () => {
84+
harness = setupHarness({ enableInferenceTimers: true });
85+
reply(harness, "rate limited", 429);
86+
reply(harness, JSON.stringify({ data: [{ index: 0, embedding: [1] }] }), 200);
87+
88+
const pending = embedTexts(["a"], CONFIG, {
89+
deps: harness.deps,
90+
extractRetryAfterMs: () => 45_000,
91+
});
92+
await harness.run();
93+
94+
expect(await pending).toEqual([[1]]);
95+
expect(harness.clock.now()).toBeGreaterThanOrEqual(45_000);
96+
});
97+
98+
test("applies timeoutMs even when the caller passes a signal", async () => {
99+
harness = setupHarness();
100+
const stream = harness.scenario.createStream();
101+
harness.scenario.whenRequestMatches(() => true, stream);
102+
103+
const pending = embedTexts(
104+
["a"],
105+
{ ...CONFIG, timeoutMs: 50 },
106+
{ deps: harness.deps, signal: new AbortController().signal },
107+
);
108+
await harness.run();
109+
110+
await expect(pending).rejects.toBeInstanceOf(EmbeddingRequestError);
111+
});

‎tsconfig.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@
2525

2626
"lib": ["ESNext"]
2727
},
28-
"include": ["src/**/*.ts", "tests/**/*.ts"]
28+
"include": ["src/**/*.ts", "tests/**/*.ts", "live/**/*.ts"]
2929
}

0 commit comments

Comments
 (0)