From a9aa3d80cd73b3e7cc3efece80dc535dd21a75fb Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 24 Sep 2026 22:54:24 -0700 Subject: [PATCH] refactor!: replace ModelRequestError with RerankRequestError Failures now raise RerankRequestError, carrying the classified InferenceError as reason plus the request URL. Malformed replies and out-of-range indices classify as protocol_mismatch through the same class; adapters signal them with ProtocolMismatchError per the @intx/inference parser contract. Config errors, including a missing model for cohere and voyage, throw arktype's TraversalError. The JSON request helper and its Retry-After extractor are no longer exported from the package. --- README.md | 4 +--- src/adapters.ts | 39 +++++++++++++++++++-------------------- src/index.ts | 8 +------- src/request.ts | 23 ++++++++++------------- src/rerank.test.ts | 31 +++++++++++++++++++++++-------- src/rerank.ts | 37 ++++++++++++++++++++++++++++--------- 6 files changed, 82 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index d32840e..f3e0fe2 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,7 @@ Every protocol addresses documents by position in the request array, and TEI rep This package raises on failure, leaving fallback policy at the call site. Retrieval pipelines commonly fall back to their fused ordering when the reranker is unavailable and carry on serving. -Every failure mode — transport, HTTP status, or a 200 with an unexpected body — raises `ModelRequestError`, carrying the classified `InferenceError` as `reason` plus the request URL. Transport, classification, and retry come from `@intx/inference`: built requests travel the shared `deps.fetch` path with `createDefaultRetryPolicy` guiding backoff. The embedding and reranking packages each carry their own copy of this class while the shared transport is upstreamed, so code catching both discriminates on `error.name === "ModelRequestError"`. - -Transport helpers (`runJSONRequest`, `extractRetryAfterMs`, `ModelRequestError`, `RunRequestOptions`, `RetryAfterExtractor`) are also re-exported for sibling one-shot JSON clients. +Every failure mode — transport, HTTP status, or a reply in the wrong shape — raises `RerankRequestError`, carrying the classified `InferenceError` as `reason` plus the request URL. Transport, classification, and retry come from `@intx/inference`: built requests travel the shared `deps.fetch` path with `createDefaultRetryPolicy` guiding backoff. An invalid config raises arktype's `TraversalError` before any request is sent. Interchange retrieval pipelines call `rerankDocuments` after hybrid search to lift the strongest candidates to the top. The shared `@intx/inference` transport keeps rerank retries and error taxonomy consistent with chat and embeddings across the hub. diff --git a/src/adapters.ts b/src/adapters.ts index ef8ceb6..3b982d3 100644 --- a/src/adapters.ts +++ b/src/adapters.ts @@ -1,5 +1,5 @@ import { type } from "arktype"; -import type { BuiltRequest } from "@intx/inference"; +import { ProtocolMismatchError, type BuiltRequest } from "@intx/inference"; import type { RetryAfterExtractor } from "./request.js"; @@ -30,7 +30,11 @@ export type RerankRequestBuilder = ( config: RerankRequestConfig, ) => BuiltRequest; -/** Positions into the `docs` array the request was built from. */ +/** + * Positions into the `docs` array the request was built from. Throws + * `ProtocolMismatchError` on a reply it cannot read, per the + * `@intx/inference` `ResponseParser` contract. + */ export type RerankResponseParser = ( body: unknown, ) => Array<{ index: number; score: number }>; @@ -67,25 +71,20 @@ function headers(config: RerankRequestConfig): Record { return out; } -function invalid(style: RerankAPIStyle, summary: string): never { - throw new Error(`malformed ${style} rerank response — ${summary}`); +function invalid(style: RerankAPIStyle, body: unknown, summary: string): never { + throw new ProtocolMismatchError( + `malformed ${style} rerank response — ${summary}`, + body, + ); } /** * Cohere and Voyage both reject a request without a model. `model` is optional * on the config because TEI serves whatever it was started with, so the - * requirement is enforced per adapter — here, by name, rather than as a 400 - * discovered in production. + * requirement is enforced per adapter rather than as a 400 discovered in + * production. */ -function requireModel( - style: RerankAPIStyle, - model: string | undefined, -): string { - if (model === undefined) { - throw new Error(`${style} rerank requires config.model`); - } - return model; -} +const WithModel = type({ model: "string" }); /** TEI: `{query, texts}` -> a bare array of `{index, score}`, unordered. */ const TeiResponse = type({ index: "number", score: "number" }).array(); @@ -98,7 +97,7 @@ const tei: RerankAdapter = { }), parseResponse: (body) => { const parsed = TeiResponse(body); - if (parsed instanceof type.errors) invalid("tei", parsed.summary); + if (parsed instanceof type.errors) invalid("tei", body, parsed.summary); return [...parsed]; }, }; @@ -117,14 +116,14 @@ const cohere: RerankAdapter = { url: `${config.baseURL}/v2/rerank`, headers: headers(config), body: JSON.stringify({ - model: requireModel("cohere", config.model), + model: WithModel.assert(config).model, query, documents: docs.map((doc) => doc.text), }), }), parseResponse: (body) => { const parsed = CohereResponse(body); - if (parsed instanceof type.errors) invalid("cohere", parsed.summary); + if (parsed instanceof type.errors) invalid("cohere", body, parsed.summary); return parsed.results.map((r) => ({ index: r.index, score: r.relevance_score, @@ -142,14 +141,14 @@ const voyage: RerankAdapter = { url: `${config.baseURL}/v1/rerank`, headers: headers(config), body: JSON.stringify({ - model: requireModel("voyage", config.model), + model: WithModel.assert(config).model, query, documents: docs.map((doc) => doc.text), }), }), parseResponse: (body) => { const parsed = VoyageResponse(body); - if (parsed instanceof type.errors) invalid("voyage", parsed.summary); + if (parsed instanceof type.errors) invalid("voyage", body, parsed.summary); return parsed.data.map((r) => ({ index: r.index, score: r.relevance_score, diff --git a/src/index.ts b/src/index.ts index 2a91d9b..ed01d42 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,10 +17,4 @@ export { type RerankResponseParser, type RerankResult, } from "./adapters.js"; -export { - runJSONRequest, - extractRetryAfterMs, - ModelRequestError, - type RetryAfterExtractor, - type RunRequestOptions, -} from "./request.js"; +export { RerankRequestError } from "./request.js"; diff --git a/src/request.ts b/src/request.ts index 150e1f6..ba9e30d 100644 --- a/src/request.ts +++ b/src/request.ts @@ -19,10 +19,10 @@ import type { InferenceError, RetryPolicy } from "@intx/types/runtime"; * a 429 from a rerank endpoint is classified and backed off exactly as one from * a chat endpoint. * - * XXX: this file is duplicated, modulo the wording of this comment, in `@corbits/embedding`. It is not - * shared infrastructure yet — the intended home is `@intx/inference` itself, as - * a non-streaming sibling of `runInference`, pending that upstream - * conversation. Fix both copies or neither. + * XXX: duplicated in `@corbits/embedding` (src/request.ts), which throws + * `EmbeddingRequestError` where this throws `RerankRequestError`. The blocker + * is that `@intx/inference` has no non-streaming JSON request helper; that is + * the intended home. Fix both copies or neither. */ /** @@ -36,20 +36,17 @@ import type { InferenceError, RetryPolicy } from "@intx/types/runtime"; export type RequestDependencies = Pick; /** - * Raised for every failure mode: transport, HTTP status, and a 200 whose body - * is not the JSON the protocol promises. - * - * XXX: duplicated alongside this module, so `instanceof` does not hold across - * the embedding and reranking copies. Discriminate on - * `error.name === "ModelRequestError"` when catching both. + * Raised for every failure mode: transport, HTTP status, and a reply that is + * not the shape the protocol promises. `reason` is the classified + * `InferenceError`, carried as data the way `@intx/inference` does. */ -export class ModelRequestError extends Error { +export class RerankRequestError extends Error { constructor( readonly reason: InferenceError, readonly url: string, ) { super(`${url}: ${reason.message}`); - this.name = "ModelRequestError"; + this.name = "RerankRequestError"; } } @@ -227,7 +224,7 @@ export async function runJSONRequest( elapsedMs: deps.scheduler.now() - startedAt, }); if (decision.kind === "abort") { - throw new ModelRequestError(result.error, request.url); + throw new RerankRequestError(result.error, request.url); } // Aborting mid-delay wakes immediately; the next attempt then fails its diff --git a/src/rerank.test.ts b/src/rerank.test.ts index 484cc1b..43a0eb1 100644 --- a/src/rerank.test.ts +++ b/src/rerank.test.ts @@ -8,7 +8,7 @@ import { rerankAdapters, type RerankAPIStyle, } from "./adapters"; -import { extractRetryAfterMs, ModelRequestError } from "./request"; +import { extractRetryAfterMs, RerankRequestError } from "./request"; const DOCS = [ { id: "a", text: "alpha" }, @@ -104,7 +104,7 @@ describe("wire formats", () => { { baseURL: "https://rerank.example", apiStyle: style }, { deps: d }, ), - ).rejects.toThrow(`${style} rerank requires config.model`); + ).rejects.toThrow("model must be a string (was undefined)"); } expect(calls).toHaveLength(0); }); @@ -143,7 +143,7 @@ describe("wire formats", () => { { ...config("tei"), timeoutMs: 0 }, { deps: d }, ), - ).rejects.toThrow(/invalid rerank config/); + ).rejects.toThrow("timeoutMs must be positive (was 0)"); expect(calls).toHaveLength(0); }); @@ -153,9 +153,16 @@ describe("wire formats", () => { const { deps: d } = deps([ Response.json({ results: [{ index: 0, relevance_score: 0.5 }] }), ]); - await expect( - rerankDocuments("q", DOCS, config("tei"), { deps: d }), - ).rejects.toThrow(/malformed tei rerank response/); + const error = await rerankDocuments("q", DOCS, config("tei"), { + deps: d, + }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(RerankRequestError); + expect((error as RerankRequestError).reason.category).toBe( + "protocol_mismatch", + ); + expect((error as RerankRequestError).message).toMatch( + /malformed tei rerank response/, + ); }); }); @@ -177,7 +184,15 @@ describe("result mapping", () => { const { deps: d } = deps([Response.json([{ index: 99, score: 0.5 }])]); await expect( rerankDocuments("q", DOCS, config("tei"), { deps: d }), - ).rejects.toThrow(/index 99 out of bounds for 3 documents/); + ).rejects.toThrow( + new RerankRequestError( + { + category: "protocol_mismatch", + message: "rerank response index 99 out of bounds for 3 documents", + }, + "https://rerank.example/rerank", + ), + ); }); it("returns nothing and issues no request for empty input", async () => { @@ -207,7 +222,7 @@ describe("retry behaviour inherited from the inference policy", () => { const { deps: d, calls } = deps([new Response("nope", { status: 401 })]); await expect( rerankDocuments("q", DOCS, config("tei"), { deps: d }), - ).rejects.toBeInstanceOf(ModelRequestError); + ).rejects.toBeInstanceOf(RerankRequestError); expect(calls).toHaveLength(1); }); diff --git a/src/rerank.ts b/src/rerank.ts index 22eeca3..b455e99 100644 --- a/src/rerank.ts +++ b/src/rerank.ts @@ -1,4 +1,8 @@ import { type } from "arktype"; +import { + classifyProtocolMismatch, + ProtocolMismatchError, +} from "@intx/inference"; import type { RetryPolicy } from "@intx/types/runtime"; import { @@ -8,7 +12,11 @@ import { type RerankDoc, type RerankResult, } from "./adapters.js"; -import { runJSONRequest, type RequestDependencies } from "./request.js"; +import { + RerankRequestError, + runJSONRequest, + type RequestDependencies, +} from "./request.js"; export const RerankConfigSchema = type({ /** Provider root, e.g. `http://localhost:8085` for a TEI server. */ @@ -49,10 +57,7 @@ export async function rerankDocuments( config: RerankConfig, options: RerankOptions, ): Promise { - const parsed = RerankConfigSchema(config); - if (parsed instanceof type.errors) { - throw new Error(`invalid rerank config — ${parsed.summary}`); - } + const parsed = RerankConfigSchema.assert(config); if (docs.length === 0) return []; @@ -76,13 +81,27 @@ export async function rerankDocuments( ...(options.signal !== undefined ? { signal: options.signal } : {}), }); - return adapter - .parseResponse(body) + let scored: Array<{ index: number; score: number }>; + try { + scored = adapter.parseResponse(body); + } catch (cause) { + if (!(cause instanceof ProtocolMismatchError)) throw cause; + throw new RerankRequestError( + classifyProtocolMismatch(cause.message, cause.raw), + request.url, + ); + } + + return scored .map(({ index, score }) => { const doc = docs[index]; if (doc === undefined) { - throw new Error( - `${request.url}: rerank response index ${index} out of bounds for ${docs.length} documents`, + throw new RerankRequestError( + classifyProtocolMismatch( + `rerank response index ${index} out of bounds for ${docs.length} documents`, + body, + ), + request.url, ); } return { id: doc.id, score };