Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ from an embedding endpoint therefore behaves exactly as one from a chat
endpoint, and a `credential_failure` aborts immediately rather than retrying a
bad key.

## Installation

Not published to npm yet. Until a registry publish, `npm install @corbits/embedding`
404s. Git is the install path.

```bash
bun add github:corbitsdev/corbits-embedding
```

Requires Node >= 20 or Bun >= 1.2. The default export is built `dist/`; native Node
does not load this package's extensionless TypeScript source.

```ts
import { createDefaultScheduler } from "@intx/inference";
import { embedTexts, probeEmbedDims } from "@corbits/embedding";
Expand Down Expand Up @@ -59,6 +71,19 @@ copy of this class until the shared transport is upstreamed, so `instanceof`
does not hold across the two. Catching both? Discriminate on
`error.name === "ModelRequestError"`.

## Transport exports

The barrel re-exports the transport `embedTexts` is built on, for sibling
one-shot JSON clients that want the same classified, retried request path
without touching `@intx/inference` internals:

- `runJSONRequest` — one JSON POST with error classification and retry.
- `extractRetryAfterMs` — the default `Retry-After` reader, overridable per
call via `extractRetryAfterMs`.
- `ModelRequestError` — the error every failure mode raises, carrying the
classified `InferenceError` as `reason` and the URL.
- Types `RunRequestOptions` and `RetryAfterExtractor`.

## Versioning

Semver. Released manually with `bun run build && npm publish` once CI is green.
Expand Down
14 changes: 12 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"description": "Embedding client for OpenAI-compatible /v1/embeddings endpoints, built on the Interchange inference primitives",
"version": "0.1.0",
"license": "LGPL-2.1-only",
"author": "Sawyer Cutler <sawyer@dirtroad.dev>",
"type": "module",
"exports": {
".": {
Expand All @@ -29,7 +30,11 @@
"prettier": "3.6.2",
"typescript": "5.9.3"
},
"files": ["dist", "README.md", "LICENSE"],
"files": [
"dist",
"README.md",
"LICENSE"
],
"sideEffects": false,
"publishConfig": {
"access": "public"
Expand All @@ -38,7 +43,12 @@
"type": "git",
"url": "git+https://github.com/corbitsdev/corbits-embedding.git"
},
"homepage": "https://github.com/corbitsdev/corbits-embedding",
"bugs": {
"url": "https://github.com/corbitsdev/corbits-embedding/issues"
},
"engines": {
"bun": ">=1.2.0"
"bun": ">=1.2.0",
"node": ">=20"
}
}
41 changes: 40 additions & 1 deletion src/embed.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { type } from "arktype";
import { describe, expect, it } from "bun:test";
import type { Dependencies } from "@intx/inference";

import { embedTexts, probeEmbedDims, type EmbedConfig } from "./embed";
import {
embedTexts,
probeEmbedDims,
EmbedConfigSchema,
type EmbedConfig,
} from "./embed";
import { extractRetryAfterMs, ModelRequestError } from "./request";

const CONFIG: EmbedConfig = {
Expand Down Expand Up @@ -125,6 +131,19 @@ describe("embedTexts", () => {
expect(vectors.map((v) => v[0])).toEqual([0, 1, 0]);
});

it("rejects a batchSize that is not an integer >= 1 instead of looping forever", async () => {
// `batches` advances by `i += size`, so a size of 0 never advances and
// grows the batch list until OOM. Fractional sizes would also stall or
// slice wrongly. Schema and runtime both require integer >= 1.
for (const batchSize of [0, -1, 0.5, Number.NaN, 1.5]) {
const { deps: d, calls } = deps([embedding(3)]);
await expect(
embedTexts(["a"], { ...CONFIG, batchSize }, { deps: d }),
).rejects.toBeInstanceOf(RangeError);
expect(calls).toHaveLength(0);
}
});

it("rejects a malformed response rather than returning junk vectors", async () => {
const { deps: d } = deps([
Response.json({ data: [{ index: 0, embedding: true }] }),
Expand All @@ -135,6 +154,26 @@ describe("embedTexts", () => {
});
});

describe("EmbedConfigSchema", () => {
const base = { baseURL: "https://embed.example/v1", model: "m" };

it("accepts an integer batchSize >= 1 and an omitted batchSize", () => {
expect(EmbedConfigSchema({ ...base, batchSize: 1 })).toEqual({
...base,
batchSize: 1,
});
expect(EmbedConfigSchema(base)).toEqual(base);
});

it("rejects a fractional, non-integer, or sub-1 batchSize", () => {
for (const batchSize of [0, -1, 0.5, Number.NaN, 1.5]) {
expect(EmbedConfigSchema({ ...base, batchSize })).toBeInstanceOf(
type.errors,
);
}
});
});

describe("retry behaviour inherited from the inference policy", () => {
it("retries a 429 and succeeds on the follow-up", async () => {
// The hand-rolled client this replaced had no retry at all: a single 429
Expand Down
13 changes: 10 additions & 3 deletions src/embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
runJSONRequest,
type RequestDependencies,
type RetryAfterExtractor,
} from "./request";
} from "./request.js";

/**
* `/v1/embeddings` is the one wire format worth supporting — OpenAI, Ollama,
Expand All @@ -30,7 +30,7 @@ export const EmbedConfigSchema = type({
* Inputs per request. Providers cap this — OpenAI rejects arrays over 2048 —
* and a smaller batch is also how you stay under a per-request token ceiling.
*/
"batchSize?": "number > 0",
"batchSize?": "number.integer >= 1",
"timeoutMs?": "number > 0",
});
export type EmbedConfig = typeof EmbedConfigSchema.infer;
Expand Down Expand Up @@ -165,8 +165,15 @@ export async function embedTexts(
): Promise<number[][]> {
if (texts.length === 0) return [];

const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE;
if (!Number.isInteger(batchSize) || batchSize < 1) {
throw new RangeError(
`batchSize must be an integer >= 1, got ${batchSize}: a batch size below 1 would never advance through the input`,
);
}

const vectors: number[][] = [];
for (const batch of batches(texts, config.batchSize ?? DEFAULT_BATCH_SIZE)) {
for (const batch of batches(texts, batchSize)) {
const request = buildRequest(config, batch);
const body = await runJSONRequest(request, {
deps: options.deps,
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ export {
EmbedConfigSchema,
type EmbedConfig,
type EmbedOptions,
} from "./embed";
} from "./embed.js";
export {
runJSONRequest,
extractRetryAfterMs,
ModelRequestError,
type RetryAfterExtractor,
type RunRequestOptions,
} from "./request";
} from "./request.js";
Loading