Skip to content

Commit f3d9557

Browse files
committed
Initial commit: embedding client on the Interchange inference primitives
`embedTexts` embeds a corpus in batches against any /v1/embeddings endpoint; `probeEmbedDims` discovers a model's output dimensionality by embedding one probe string. One wire format, no adapter layer. OpenAI, Ollama, TEI, vLLM and Jina all serve /v1/embeddings and differ only in parameters, so a provider switch would be three copies of the same function. Verified live rather than assumed: Ollama returns 768 dims there, matching its native /api/embed, and a TEI container running bge-small-en-v1.5 returns 384, matching its native /embed. This is the opposite conclusion from @corbits/reranking, and deliberately so — that protocol has no standard, this one does. Dimensionality is discovered, never assumed. It varies by provider and is changed by `dimensions` (Matryoshka truncation: OpenAI text-embedding-3 to 256, Jina v3 to 32, v4 to 128), so anything persisting vectors must probe for it. Vectors are placed by the `index` each entry echoes, never by reply position, and a reply that is not exactly one entry per input is rejected. OpenAI does not promise reply order, and callers pair vectors with their own texts positionally — so a reordered or short reply would not fail loudly, it would silently persist text/vector pairs that are wrong. The base64 encoding is decoded as a little-endian float32 buffer, so the option the config advertises actually round-trips. Built on @intx/inference rather than reimplementing it. Requests are BuiltRequests sent through the single deps.fetch path; deps.scheduler drives elapsed time and backoff so virtual-clock tests run the retry loop with no wall time; failures classify through the shared taxonomy, including a 200 whose body is not JSON (classifyProtocolMismatch), which a local model server behind a proxy will produce; createDefaultRetryPolicy decides back-off vs abort, and an abort mid-delay wakes immediately as the harness documents. A caller signal and timeoutMs compose via AbortSignal.any rather than the signal silently winning. The client this replaces had no retry at all. deps is Pick<Dependencies, "fetch" | "scheduler">: callers should not have to assemble a registry of inference providers in order to embed a string. A real Dependencies still satisfies it structurally. Batches are sequential rather than concurrent. A provider that rate-limits would otherwise see the whole corpus at once, and the retry policy is per request, not per burst. Naming follows the published @intX surface rather than assumption: baseURL (used 18 times upstream, baseUrl zero) and uppercase acronyms in identifiers. Tests: 19 pass. Typecheck, prettier and build clean, all four gated in CI. The retry, index-mapping and timeout paths were regression-verified by reintroducing each bug and confirming the tests fail. Refs CL-4539, CL-4549, CL-4550 Claude-Session: https://claude.ai/code/session_017GTgGzn5xAwvkU2GAPAHpF
0 parents  commit f3d9557

13 files changed

Lines changed: 1522 additions & 0 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
check:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: oven-sh/setup-bun@v2
14+
with:
15+
bun-version: latest
16+
- run: bun install --frozen-lockfile
17+
- run: bunx tsc --noEmit
18+
- run: bun test ./src
19+
- run: bunx prettier --check src
20+
- run: bun run build

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
dist
3+
*.tsbuildinfo

‎.prettierrc.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"trailingComma": "all",
3+
"tabWidth": 2,
4+
"useTabs": false,
5+
"semi": true,
6+
"singleQuote": false
7+
}

‎LICENSE‎

Lines changed: 501 additions & 0 deletions
Large diffs are not rendered by default.

‎README.md‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# @corbits/embedding
2+
3+
Embedding client for OpenAI-compatible `/v1/embeddings` endpoints. Given a
4+
`baseURL`, a model, and some texts, `embedTexts` batches them, posts each batch,
5+
validates the response, and returns vectors in input order.
6+
7+
One wire format, not a provider switch. OpenAI, Ollama, TEI, vLLM and Jina all
8+
serve `/v1/embeddings` — what differs between them is parameters:
9+
10+
- `dimensions` — Matryoshka truncation, supported by OpenAI text-embedding-3
11+
(down to 256), Jina v3 (32) and v4 (128). Models without MRL reject it, so it
12+
is only sent when set.
13+
- `encodingFormat` — `float` or `base64`.
14+
- `batchSize` — providers cap inputs per request; OpenAI rejects arrays over
15+
2048, and a smaller batch is also how you stay under a token ceiling.
16+
17+
Transport, error classification and retry come from `@intx/inference` rather
18+
than being reimplemented here: `deps.fetch` is the single request path,
19+
failures become an `InferenceError` through the same classifiers a chat call
20+
uses, and `createDefaultRetryPolicy` decides whether to back off or abort. A 429
21+
from an embedding endpoint therefore behaves exactly as one from a chat
22+
endpoint, and a `credential_failure` aborts immediately rather than retrying a
23+
bad key.
24+
25+
```ts
26+
import { createDefaultScheduler } from "@intx/inference";
27+
import { embedTexts, probeEmbedDims } from "@corbits/embedding";
28+
29+
// Only `fetch` and `scheduler` are needed — a full harness `Dependencies`
30+
// also satisfies this if you already have one.
31+
const deps = { fetch, scheduler: createDefaultScheduler() };
32+
33+
const config = {
34+
baseURL: "http://localhost:11434/v1", // include the provider's version prefix
35+
model: "nomic-embed-text",
36+
};
37+
38+
const dims = await probeEmbedDims(config, { deps });
39+
const [vector] = await embedTexts(["hello world"], config, { deps });
40+
```
41+
42+
## Dimensionality is discovered, never assumed
43+
44+
`probeEmbedDims` embeds one probe string and reports the length it got back.
45+
Dimensionality is not knowable from a model name — it varies by provider and is
46+
changed by `dimensions` — so a caller that persists vectors must discover it.
47+
Defaults differ enough to matter: 1536 for OpenAI `text-embedding-3-small`, 768
48+
for `nomic-embed-text`, 384 for `bge-small-en-v1.5`. Swapping models is a
49+
migration, not a config change.
50+
51+
## Errors
52+
53+
Every failure — transport, HTTP status, or a 200 whose body is not JSON —
54+
raises `ModelRequestError`, carrying the classified `InferenceError` as `reason`
55+
and the URL.
56+
57+
Note that `@corbits/embedding` and `@corbits/reranking` each carry their own
58+
copy of this class until the shared transport is upstreamed, so `instanceof`
59+
does not hold across the two. Catching both? Discriminate on
60+
`error.name === "ModelRequestError"`.
61+
62+
## Versioning
63+
64+
Semver. Released manually with `bun run build && npm publish` once CI is green.
65+
66+
## Development
67+
68+
```bash
69+
bun install
70+
bun test ./src
71+
bunx tsc --noEmit
72+
```
73+
74+
## License
75+
76+
LGPL-2.1-only — see [`LICENSE`](LICENSE).

‎bun.lock‎

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@corbits/embedding",
3+
"description": "Embedding client for OpenAI-compatible /v1/embeddings endpoints, built on the Interchange inference primitives",
4+
"version": "0.1.0",
5+
"license": "LGPL-2.1-only",
6+
"type": "module",
7+
"exports": {
8+
".": {
9+
"intx-src": "./src/index.ts",
10+
"types": "./dist/index.d.ts",
11+
"default": "./dist/index.js"
12+
}
13+
},
14+
"scripts": {
15+
"build": "tsc -p tsconfig.build.json",
16+
"typecheck": "tsc --noEmit",
17+
"test": "bun test ./src",
18+
"format": "prettier -w ."
19+
},
20+
"dependencies": {
21+
"@intx/inference": "0.2.2",
22+
"@intx/log": "0.2.2",
23+
"@intx/types": "0.2.2",
24+
"arktype": "^2.1.29"
25+
},
26+
"devDependencies": {
27+
"@intx/inference-testing": "0.2.2",
28+
"@types/bun": "1.3.9",
29+
"prettier": "3.6.2",
30+
"typescript": "5.9.3"
31+
},
32+
"files": ["dist", "README.md", "LICENSE"],
33+
"sideEffects": false,
34+
"publishConfig": {
35+
"access": "public"
36+
},
37+
"repository": {
38+
"type": "git",
39+
"url": "git+https://github.com/corbitsdev/corbits-embedding.git"
40+
},
41+
"engines": {
42+
"bun": ">=1.2.0"
43+
}
44+
}

0 commit comments

Comments
 (0)