From 65910076c241fa17731c6472a38b35365b9b1976 Mon Sep 17 00:00:00 2001 From: NotXf1le <89696340+NotXf1le@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:48:47 +0100 Subject: [PATCH] fix: preserve backend HTTP status in scoring errors --- src/llama-cpp.ts | 8 +++- src/ollama.ts | 4 +- src/openrouter.ts | 4 +- src/validation.ts | 6 +++ tests/http-errors.test.mjs | 79 ++++++++++++++++++++++++++++++++++++++ tests/types/api.cts | 12 +++++- tests/types/api.mts | 12 +++++- 7 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 tests/http-errors.test.mjs diff --git a/src/llama-cpp.ts b/src/llama-cpp.ts index ec3468b..cc4d290 100644 --- a/src/llama-cpp.ts +++ b/src/llama-cpp.ts @@ -109,7 +109,9 @@ async function post(fetchImpl: typeof globalThis.fetch, url: string, signal?.throwIfAborted(); if (isBatchFormatRejection(message)) throw new UnsupportedBatchTokenization(); } - throw new ScoringError(`llama.cpp returned HTTP ${response.status} for ${new URL(url).pathname}.`); + throw new ScoringError(`llama.cpp returned HTTP ${response.status} for ${new URL(url).pathname}.`, { + httpStatus: response.status, + }); } try { const value: unknown = await response.json(); @@ -133,7 +135,9 @@ async function get(fetchImpl: typeof globalThis.fetch, url: string, } signal?.throwIfAborted(); if (!response.ok) { - throw new ScoringError(`llama.cpp returned HTTP ${response.status} for ${new URL(url).pathname}.`); + throw new ScoringError(`llama.cpp returned HTTP ${response.status} for ${new URL(url).pathname}.`, { + httpStatus: response.status, + }); } try { const value: unknown = await response.json(); diff --git a/src/ollama.ts b/src/ollama.ts index 379c21a..6bd1078 100644 --- a/src/ollama.ts +++ b/src/ollama.ts @@ -41,7 +41,9 @@ async function post(fetchImpl: typeof globalThis.fetch, url: string, body: unkno } signal?.throwIfAborted(); if (!response.ok) { - throw new ScoringError(`Ollama returned HTTP ${response.status} for ${new URL(url).pathname}.`); + throw new ScoringError(`Ollama returned HTTP ${response.status} for ${new URL(url).pathname}.`, { + httpStatus: response.status, + }); } try { const value: unknown = await response.json(); diff --git a/src/openrouter.ts b/src/openrouter.ts index 6f2b9bf..f54a0b2 100644 --- a/src/openrouter.ts +++ b/src/openrouter.ts @@ -34,7 +34,9 @@ async function post(fetchImpl: typeof globalThis.fetch, apiKey: string, body: un } signal?.throwIfAborted(); if (!response.ok) { - throw new ScoringError(`OpenRouter returned HTTP ${response.status} for /chat/completions.`); + throw new ScoringError(`OpenRouter returned HTTP ${response.status} for /chat/completions.`, { + httpStatus: response.status, + }); } try { const value: unknown = await response.json(); diff --git a/src/validation.ts b/src/validation.ts index 69b3582..bb91b0c 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,5 +1,11 @@ export class ScoringError extends Error { override readonly name = "ScoringError"; + readonly httpStatus?: number; + + constructor(message?: string, options?: ErrorOptions & { readonly httpStatus?: number }) { + super(message, options); + if (options?.httpStatus !== undefined) this.httpStatus = options.httpStatus; + } } export function isRecord(value: unknown): value is Record { diff --git a/tests/http-errors.test.mjs b/tests/http-errors.test.mjs new file mode 100644 index 0000000..db8c6ad --- /dev/null +++ b/tests/http-errors.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; +import { ScoringError } from "../dist/esm/index.js"; +import { fromOpenRouter } from "../dist/esm/openrouter.js"; +import { fromOllama } from "../dist/esm/ollama.js"; +import { fromLlamaCpp } from "../dist/esm/llama-cpp.js"; +import { encode, request } from "./helpers.mjs"; + +const { ScoringError: CjsScoringError } = createRequire(import.meta.url)("choosekit"); + +for (const [format, ErrorClass] of [["ESM", ScoringError], ["CJS", CjsScoringError]]) { + test(`${format} ScoringError preserves Error construction and optional HTTP metadata`, () => { + const cause = new Error("upstream"); + const empty = new ErrorClass(); + assert.ok(empty instanceof Error); + assert.ok(empty instanceof ErrorClass); + assert.equal(empty.name, "ScoringError"); + assert.equal(empty.message, ""); + assert.equal(empty.httpStatus, undefined); + + const legacy = new ErrorClass("legacy", { cause }); + assert.equal(legacy.message, "legacy"); + assert.equal(legacy.cause, cause); + assert.equal(legacy.httpStatus, undefined); + + const httpError = new ErrorClass(undefined, { cause, httpStatus: 429 }); + assert.ok(httpError instanceof ErrorClass); + assert.equal(httpError.message, ""); + assert.equal(httpError.cause, cause); + assert.equal(httpError.httpStatus, 429); + }); +} + +const httpCases = [ + ["OpenRouter POST", (fetch) => fromOpenRouter({ apiKey: "fixture-key", model: "fixture/model", fetch }), request], + ["Ollama POST", (fetch) => fromOllama({ model: "fixture-model", fetch }), request], + ["llama.cpp POST", (fetch) => fromLlamaCpp({ baseURL: "http://fixture.test", fetch }), request], + ["llama.cpp GET", (fetch) => fromLlamaCpp({ baseURL: "http://fixture.test", fetch }), + { ...request, images: [{ mediaType: "image/png", base64: "cG5n" }] }], +]; + +function createFetchFixture(boundary, failure) { + return async (url, init) => { + if (boundary === "llama.cpp GET" && new URL(url).pathname === "/tokenize") { + const { content } = JSON.parse(init.body); + return Response.json({ + tokens: Array.isArray(content) + ? content.flatMap((part) => part === -1 ? [-1] : encode(part)) + : encode(content), + }); + } + return failure(); + }; +} + +for (const [boundary, create, input] of httpCases) { + test(`${boundary} preserves the final failing HTTP status`, async () => { + for (const status of [302, 401, 403, 429, 503]) { + const choose = create(createFetchFixture(boundary, + () => Response.json({ error: "secret upstream details" }, { status }))); + await assert.rejects(choose(input), (error) => { + assert.ok(error instanceof ScoringError); + assert.equal(error.httpStatus, status); + assert.doesNotMatch(error.message, /secret/); + return true; + }); + } + }); + + test(`${boundary} does not label invalid JSON as an HTTP failure`, async () => { + const choose = create(createFetchFixture(boundary, () => new Response("{"))); + await assert.rejects(choose(input), (error) => { + assert.ok(error instanceof ScoringError); + assert.equal(error.httpStatus, undefined); + return true; + }); + }); +} diff --git a/tests/types/api.cts b/tests/types/api.cts index 0f27660..3e10163 100644 --- a/tests/types/api.cts +++ b/tests/types/api.cts @@ -1,4 +1,4 @@ -import { chooseMany, createChooser, type Scorer } from "choosekit"; +import { chooseMany, createChooser, ScoringError, type Scorer } from "choosekit"; import { fromLlamaCpp } from "choosekit/llama-cpp"; import { fromOllama } from "choosekit/ollama"; import { fromOpenRouter } from "choosekit/openrouter"; @@ -15,3 +15,13 @@ void chooseMany(choose, { const key: "test" | "done" = decisions.route.choice; void key; }); + +const httpError = new ScoringError(undefined, { httpStatus: 401, cause: new Error("upstream") }); +const httpStatus: number | undefined = httpError.httpStatus; +void httpStatus; +void new ScoringError(); +void new ScoringError("legacy", { cause: new Error("cause") }); +// @ts-expect-error HTTP status must be numeric. +new ScoringError("HTTP failed", { httpStatus: "401" }); +// @ts-expect-error HTTP metadata is read-only. +httpError.httpStatus = 429; diff --git a/tests/types/api.mts b/tests/types/api.mts index 6d35f0e..923d1fa 100644 --- a/tests/types/api.mts +++ b/tests/types/api.mts @@ -1,4 +1,4 @@ -import { chooseMany, createChooser, type Scorer, type Decision, type ImageInput } from "choosekit"; +import { chooseMany, createChooser, ScoringError, type Scorer, type Decision, type ImageInput } from "choosekit"; import { fromLlamaCpp } from "choosekit/llama-cpp"; import { fromOllama } from "choosekit/ollama"; import { fromOpenRouter } from "choosekit/openrouter"; @@ -77,3 +77,13 @@ void wrongUrgency; void chooseMany(choose, { context: "", questions: { route: { choices: { a: "A", b: "B" } } } }); // @ts-expect-error Choice descriptions must be strings. void chooseMany(choose, { context: "", questions: { route: { question: "?", choices: { a: 1, b: "B" } } } }); + +const httpError = new ScoringError(undefined, { httpStatus: 401, cause: new Error("upstream") }); +const httpStatus: number | undefined = httpError.httpStatus; +void httpStatus; +void new ScoringError(); +void new ScoringError("legacy", { cause: new Error("cause") }); +// @ts-expect-error HTTP status must be numeric. +new ScoringError("HTTP failed", { httpStatus: "401" }); +// @ts-expect-error HTTP metadata is read-only. +httpError.httpStatus = 429;