From 5bfbedbe69e4c80e1f53f895e57f8cd366d8f1f1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 17 Sep 2026 11:27:27 -0700 Subject: [PATCH 1/3] Address pre-publish review findings for corbits-embedding Reject batchSize below 1 at the embedTexts entry boundary, document installation and transport exports, and complete the publish metadata. --- README.md | 21 +++++++++++++++++++++ package.json | 14 ++++++++++++-- src/embed.test.ts | 13 +++++++++++++ src/embed.ts | 9 ++++++++- 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5e58195..c436121 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ 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 + +```bash +npm install @corbits/embedding +``` + +Requires Node >= 20 or Bun >= 1.2. + ```ts import { createDefaultScheduler } from "@intx/inference"; import { embedTexts, probeEmbedDims } from "@corbits/embedding"; @@ -59,6 +67,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..8c862fe 100644 --- a/src/embed.test.ts +++ b/src/embed.test.ts @@ -125,6 +125,19 @@ describe("embedTexts", () => { expect(vectors.map((v) => v[0])).toEqual([0, 1, 0]); }); + it("rejects a batchSize below 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. The schema declares `number > 0`; the + // entry boundary enforces it before any request issues. + for (const batchSize of [0, -1]) { + 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 }] }), diff --git a/src/embed.ts b/src/embed.ts index 7314044..ead4f10 100644 --- a/src/embed.ts +++ b/src/embed.ts @@ -165,8 +165,15 @@ export async function embedTexts( ): Promise { if (texts.length === 0) return []; + const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE; + if (!(batchSize >= 1)) { + throw new RangeError( + `batchSize must be a number >= 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, From 40d6e85fc7396529a24eef9ea0fe8e367101edac Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 18:22:14 -0700 Subject: [PATCH 2/3] docs: install from git until @corbits/embedding is on npm npm view @corbits/embedding 404s; git is the install path. Default export is dist/; native Node does not load extensionless TypeScript source. --- README.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c436121..4d0b089 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,15 @@ bad key. ## Installation +Not published to npm yet. Until a registry publish, `npm install @corbits/embedding` +404s. Git is the install path. + ```bash -npm install @corbits/embedding +bun add github:corbitsdev/corbits-embedding ``` -Requires Node >= 20 or Bun >= 1.2. +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"; From 6ca6878c5cb984bc18cea65a116adf7b55c9c16e Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 18:40:28 -0700 Subject: [PATCH 3/3] fix: emit .js specifiers in dist and integer batchSize Node cannot import extensionless relative specifiers from packed dist. Source relative imports now carry .js so tsc emits Node-loadable ESM. EmbedConfigSchema and embedTexts both require batchSize integer >= 1, rejecting 0.5 and NaN. --- src/embed.test.ts | 36 +++++++++++++++++++++++++++++++----- src/embed.ts | 8 ++++---- src/index.ts | 4 ++-- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/embed.test.ts b/src/embed.test.ts index 8c862fe..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,11 +131,11 @@ describe("embedTexts", () => { expect(vectors.map((v) => v[0])).toEqual([0, 1, 0]); }); - it("rejects a batchSize below 1 instead of looping forever", async () => { + 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. The schema declares `number > 0`; the - // entry boundary enforces it before any request issues. - for (const batchSize of [0, -1]) { + // 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 }), @@ -148,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 ead4f10..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; @@ -166,9 +166,9 @@ export async function embedTexts( if (texts.length === 0) return []; const batchSize = config.batchSize ?? DEFAULT_BATCH_SIZE; - if (!(batchSize >= 1)) { + if (!Number.isInteger(batchSize) || batchSize < 1) { throw new RangeError( - `batchSize must be a number >= 1, got ${batchSize}: a batch size below 1 would never advance through the input`, + `batchSize must be an integer >= 1, got ${batchSize}: a batch size below 1 would never advance through the input`, ); } 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";