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
8 changes: 6 additions & 2 deletions src/llama-cpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/ollama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 6 additions & 0 deletions src/validation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
Expand Down
79 changes: 79 additions & 0 deletions tests/http-errors.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
});
}
12 changes: 11 additions & 1 deletion tests/types/api.cts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
12 changes: 11 additions & 1 deletion tests/types/api.mts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Loading