diff --git a/README.md b/README.md index 5e58195..4d0b089 100644 --- a/README.md +++ b/README.md @@ -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"; @@ -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. diff --git a/package.json b/package.json index df0dd99..be2a006 100644 --- a/package.json +++ b/package.json @@ -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 ", "type": "module", "exports": { ".": { @@ -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" @@ -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" } } diff --git a/src/embed.test.ts b/src/embed.test.ts index 01f8696..22cb770 100644 --- a/src/embed.test.ts +++ b/src/embed.test.ts @@ -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 = { @@ -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 }] }), @@ -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 diff --git a/src/embed.ts b/src/embed.ts index 7314044..7ceccbd 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -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, @@ -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; @@ -165,8 +165,15 @@ export async function embedTexts( ): Promise { 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, diff --git a/src/index.ts b/src/index.ts index 902be73..5847f42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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";