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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
39 changes: 19 additions & 20 deletions src/adapters.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 }>;
Expand Down Expand Up @@ -67,25 +71,20 @@ function headers(config: RerankRequestConfig): Record<string, string> {
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();
Expand All @@ -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];
},
};
Expand All @@ -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,
Expand All @@ -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,
Expand Down
8 changes: 1 addition & 7 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 10 additions & 13 deletions src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/

/**
Expand All @@ -36,20 +36,17 @@ import type { InferenceError, RetryPolicy } from "@intx/types/runtime";
export type RequestDependencies = Pick<Dependencies, "fetch" | "scheduler">;

/**
* 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";
}
}

Expand Down Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions src/rerank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
});

Expand All @@ -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/,
);
});
});

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});

Expand Down
37 changes: 28 additions & 9 deletions src/rerank.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type } from "arktype";
import {
classifyProtocolMismatch,
ProtocolMismatchError,
} from "@intx/inference";
import type { RetryPolicy } from "@intx/types/runtime";

import {
Expand All @@ -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. */
Expand Down Expand Up @@ -49,10 +57,7 @@ export async function rerankDocuments(
config: RerankConfig,
options: RerankOptions,
): Promise<RerankResult[]> {
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 [];

Expand All @@ -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 };
Expand Down
Loading