diff --git a/README.md b/README.md index d8cef88..9d8c875 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ console.log(answer); Notes: -- `mode` defaults to `'system1'`; passing `'system1'` currently throws +- `mode` defaults to `'system1'`; passing `'system2'` currently throws (`Not implemented yet`). - `confidence` is derived from the distribution shape. See [under the hood](#how-it-works-under-the-hood) for the exact formula. diff --git a/package-lock.json b/package-lock.json index 4d035c2..7d7a1bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,6 @@ "name": "smart-decisions", "version": "0.0.2", "license": "MIT", - "dependencies": { - "openai": "^7.23.0" - }, "devDependencies": { "@types/node": "^22", "@vitest/coverage-v8": "^5.0.2", @@ -2445,43 +2442,6 @@ "node": ">=12.20.0" } }, - "node_modules/openai": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-7.23.0.tgz", - "integrity": "sha512-0ecOXnFSMNWZq6cUBcTV6Lf93y+fm8BH/+QzFvpoP1UGNOE5pnytRa6HAPGCw+3IS9SIAW7rc1M8yyzN1PiBAQ==", - "license": "Apache-2.0", - "engines": { - "node": ">=22.0.0" - }, - "peerDependencies": { - "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/signature-v4": ">=5.4.0 <6", - "undici": ">=5 <9", - "ws": "^8.21.0", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-provider-node": { - "optional": true - }, - "@smithy/hash-node": { - "optional": true - }, - "@smithy/signature-v4": { - "optional": true - }, - "undici": { - "optional": true - }, - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/package.json b/package.json index 9145111..dfaa202 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "lint": "eslint .", "format": "prettier --write .", "format:check": "prettier --check .", - "build": "tsc", + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc", "test": "vitest run", "test:coverage": "vitest run --coverage", "example": "node --env-file=.env --import tsx examples/basic.ts", - "prepublishOnly": "rm -rf dist && npm run lint && npm run format:check && npm run build && npm test" + "prepublishOnly": "rm -rf dist && npm run lint && npm run format:check && npm run typecheck && npm run build && npm test" }, "repository": { "type": "git", @@ -53,8 +54,5 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.70.1", "vitest": "^5.0.1" - }, - "dependencies": { - "openai": "^7.23.0" } } diff --git a/src/choice/system1-choice.ts b/src/choice/system1-choice.ts index 69ab73e..d9ce03e 100644 --- a/src/choice/system1-choice.ts +++ b/src/choice/system1-choice.ts @@ -1,4 +1,4 @@ -import OpenAI from 'openai'; +import { generateText } from '../utils/llms/generate-text.js'; import { normalizeEntropy } from '../utils/math/normalize-entropy.js'; import type { ChoiceAnswer } from '../types/choice-answer.js'; import type { Question } from '../types/question.js'; @@ -22,6 +22,18 @@ const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); * distribution over every option (sums to 1), and a 0..1 confidence * based on the distribution's entropy (flat → low, single peak → high). * @throws If there are fewer than 2 or more than 26 options (one per letter). + * @example + * ```ts + * const answer = await system1Choice({ + * apiBaseUrl: 'http://localhost:8000/v1', + * apiKey: process.env.API_KEY!, + * model: '/models/Qwen3.5-4B-Q4_K_M.gguf', + * state: "It is raining and I am at home. I'm bored.", + * instructions: 'Give me a good plan to do now', + * criteria: { walk: 'Go for a walk', movie: 'Watch a movie' }, + * }); + * console.log(answer.choice); // 'movie' + * ``` */ export async function system1Choice(question: Question): Promise { const entries = Object.entries(question.criteria); @@ -46,23 +58,22 @@ export async function system1Choice(question: Question): Promise { `Answer with exactly one letter (${LETTERS.slice(0, names.length).join(', ')}). ` + `Reply with that single letter and nothing else.`; - const client = new OpenAI({ - baseURL: question.apiBaseUrl, - apiKey: question.apiKey, - }); - // The whole decision is one forward pass generating one token. Each param below // nudges the model towards emitting just the chosen option's letter. // TODO: probably better to move instructions to system prompt for KV cache reuse - const res = await client.chat.completions.create({ - model: question.model, - messages: [{ role: 'user', content: prompt }], - max_tokens: 1, // the answer is a single letter - temperature: 0, // greedy: always the most likely letter - logprobs: true, - top_logprobs: 50, // llama.cpp server max; margin so every declared letter (and its token variants) lands in the report - chat_template_kwargs: { enable_thinking: false }, - } as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming); + const res = await generateText( + { apiBaseUrl: question.apiBaseUrl, apiKey: question.apiKey }, + { + model: question.model, + messages: [{ role: 'user', content: prompt }], + max_tokens: 1, // the answer is a single letter + temperature: 0, // greedy: always the most likely letter + logprobs: true, + top_logprobs: 50, // llama.cpp server max; margin so every declared letter (and its token variants) lands in the report + chat_template_kwargs: { enable_thinking: false }, + }, + { maxRetries: question.maxRetries, timeoutMs: question.timeoutMs }, + ); // The first generated token is the answer letter; its candidate tokens carry // the logprobs we turn into the option distribution. Missing logprobs means we diff --git a/src/types/chat-completion-choice.ts b/src/types/chat-completion-choice.ts new file mode 100644 index 0000000..476cb93 --- /dev/null +++ b/src/types/chat-completion-choice.ts @@ -0,0 +1,18 @@ +import type { ChatCompletionTokenLogprob } from './chat-completion-token-logprob.js'; + +/** + * One completion alternative as returned by the API. + * + * @example + * ```ts + * const choice: ChatCompletionChoice = { + * logprobs: { + * content: [{ token: 'A', logprob: -0.5, top_logprobs: [{ token: 'A', logprob: -0.5 }] }], + * }, + * }; + * ``` + */ +export interface ChatCompletionChoice { + /** Log probability information for the generated content, when requested */ + logprobs?: { content?: ChatCompletionTokenLogprob[] | null } | null; +} diff --git a/src/types/chat-completion-request.ts b/src/types/chat-completion-request.ts new file mode 100644 index 0000000..1449c13 --- /dev/null +++ b/src/types/chat-completion-request.ts @@ -0,0 +1,37 @@ +import type { ChatMessage } from './chat-message.js'; + +/** + * Chat completions request body, in the wire format of the OpenAI-compatible + * `/v1/chat/completions` API. + * + * Keys are kept verbatim from the API spec, so no mapping layer is needed + * between this type and the request body. + * + * @example + * ```ts + * const request: ChatCompletionRequest = { + * model: '/models/Qwen3.5-4B-Q4_K_M.gguf', + * messages: [{ role: 'user', content: 'Reply with a single letter: A or B' }], + * max_tokens: 1, + * temperature: 0, + * logprobs: true, + * top_logprobs: 20, + * }; + * ``` + */ +export interface ChatCompletionRequest { + /** The model identifier as required by the API provider */ + model: string; + /** The conversation so far */ + messages: ChatMessage[]; + /** Maximum number of tokens to generate */ + max_tokens?: number; + /** Sampling temperature; 0 makes generation (near-)greedy */ + temperature?: number; + /** Whether to return log probabilities of the generated tokens */ + logprobs?: boolean; + /** How many of the most likely tokens to report at each position (requires `logprobs`) */ + top_logprobs?: number; + /** Extra parameters forwarded to the model's chat template, i.e. `{ enable_thinking: false }` for llama.cpp */ + chat_template_kwargs?: Record; +} diff --git a/src/types/chat-completion-token-logprob.ts b/src/types/chat-completion-token-logprob.ts new file mode 100644 index 0000000..5538379 --- /dev/null +++ b/src/types/chat-completion-token-logprob.ts @@ -0,0 +1,25 @@ +import type { TopLogprob } from './top-logprob.js'; + +/** + * Log probability information for one generated content token. + * + * @example + * ```ts + * const info: ChatCompletionTokenLogprob = { + * token: 'A', + * logprob: -0.5, + * top_logprobs: [ + * { token: 'A', logprob: -0.5 }, + * { token: 'B', logprob: -1.2 }, + * ], + * }; + * ``` + */ +export interface ChatCompletionTokenLogprob { + /** The generated token */ + token: string; + /** Natural log of the probability the model assigned to this token */ + logprob: number; + /** The most likely tokens at this position; may be fewer than requested */ + top_logprobs?: TopLogprob[]; +} diff --git a/src/types/chat-completion.ts b/src/types/chat-completion.ts new file mode 100644 index 0000000..2324175 --- /dev/null +++ b/src/types/chat-completion.ts @@ -0,0 +1,21 @@ +import type { ChatCompletionChoice } from './chat-completion-choice.js'; + +/** + * A chat completions response, narrowed to the fields this library reads. + * + * Response fields we don't consume (ids, usage, timestamps, ...) are passed + * through untouched at runtime; they are just not typed. + * + * @example + * ```ts + * const completion: ChatCompletion = { + * choices: [ + * { logprobs: { content: [{ token: 'A', logprob: -0.5, top_logprobs: [] }] } }, + * ], + * }; + * ``` + */ +export interface ChatCompletion { + /** The generated choices; the first one is the answer */ + choices: ChatCompletionChoice[]; +} diff --git a/src/types/chat-message.ts b/src/types/chat-message.ts new file mode 100644 index 0000000..7c717d9 --- /dev/null +++ b/src/types/chat-message.ts @@ -0,0 +1,15 @@ +/** + * A message of the conversation, as accepted by OpenAI-compatible chat + * completions APIs. + * + * @example + * ```ts + * const message: ChatMessage = { role: 'user', content: 'Hello!' }; + * ``` + */ +export interface ChatMessage { + /** Who is speaking: the prompt author (`user`), a previous model reply (`assistant`) or standing instructions (`system`) */ + role: 'system' | 'user' | 'assistant'; + /** Message text */ + content: string; +} diff --git a/src/types/choice-answer.ts b/src/types/choice-answer.ts index 1108fa6..d663f67 100644 --- a/src/types/choice-answer.ts +++ b/src/types/choice-answer.ts @@ -1,3 +1,16 @@ +/** + * The outcome of a decision: which option won, how likely every option was and + * how decisive the win was. + * + * @example + * ```ts + * const answer: ChoiceAnswer = { + * choice: 'movie', + * probabilities: { walk: 0.00081, movie: 0.99913, beach: 0.00006 }, + * confidence: 0.9935, + * }; + * ``` + */ export interface ChoiceAnswer { /** Option name with the highest probability */ choice: string; diff --git a/src/types/choice-mode.ts b/src/types/choice-mode.ts index bf0b33d..8e196aa 100644 --- a/src/types/choice-mode.ts +++ b/src/types/choice-mode.ts @@ -1,2 +1,13 @@ -/** Which mode to answer with. Follows Kahneman's dual-process theory (Thinking, Fast and Slow): System 1 is fast, automatic and instinctive, and costs little in inference (one forward pass, one generated token); System 2 is slow, effortful and deliberate, and more costly (the LLM may reason and has to generate a full structured response). */ +/** + * Which mode to answer with, following Kahneman's dual-process theory (Thinking, + * Fast and Slow): System 1 is fast, automatic and instinctive, and costs little + * in inference (one forward pass, one generated token); System 2 is slow, + * effortful and deliberate, and more costly (the LLM may reason and has to + * generate a full structured response). + * + * @example + * ```ts + * const mode: ChoiceMode = 'system1'; + * ``` + */ export type ChoiceMode = 'system1' | 'system2'; diff --git a/src/types/generate-text-options.ts b/src/types/generate-text-options.ts new file mode 100644 index 0000000..326cb6b --- /dev/null +++ b/src/types/generate-text-options.ts @@ -0,0 +1,14 @@ +/** + * Retry and timeout settings for `generateText`. + * + * @example + * ```ts + * const options: GenerateTextOptions = { maxRetries: 3, timeoutMs: 30_000 }; + * ``` + */ +export interface GenerateTextOptions { + /** Retries after a failed attempt: network errors and 408/409/429/5xx responses (not timeouts). Defaults to 2 */ + maxRetries?: number | undefined; + /** Per-attempt timeout in milliseconds. Defaults to 600000 */ + timeoutMs?: number | undefined; +} diff --git a/src/types/question.ts b/src/types/question.ts index 4da5b59..5c2ae74 100644 --- a/src/types/question.ts +++ b/src/types/question.ts @@ -1,5 +1,20 @@ import type { ChoiceMode } from './choice-mode.js'; +/** + * The decision to make: options, state, instructions and provider settings. + * + * @example + * ```ts + * const question: Question = { + * apiBaseUrl: 'http://localhost:8000/v1', + * apiKey: 'a-super-secret-api-key', + * model: '/models/Qwen3.5-4B-Q4_K_M.gguf', + * state: "It is raining and I am at home. I'm bored.", + * instructions: 'Give me a good plan to do now', + * criteria: { walk: 'Go for a walk', movie: 'Watch a movie' }, + * }; + * ``` + */ export interface Question { /** Base url of an OpenAI compatible v1 API, i.e. `'http://localhost:8000/v1'` */ apiBaseUrl: string; @@ -7,6 +22,10 @@ export interface Question { apiKey: string; /** The model identifier as required by your API provider. i.e. `'/models/Qwen3.5-4B-Q4_K_M.gguf'` for llama.cpp */ model: string; + /** Maximum number of retries after a failed attempt (network errors, 408/409/429/5xx). Defaults to 2 */ + maxRetries?: number; + /** Per-attempt timeout in milliseconds. Defaults to 600000 */ + timeoutMs?: number; /** Choose between System 1 or System 2 mode. Defaults to `'system1'` */ mode?: ChoiceMode; /** The state to evaluate */ diff --git a/src/types/top-logprob.ts b/src/types/top-logprob.ts new file mode 100644 index 0000000..b90bb9e --- /dev/null +++ b/src/types/top-logprob.ts @@ -0,0 +1,15 @@ +/** + * A candidate token and the log probability the model assigned to it at a + * generation position. + * + * @example + * ```ts + * const candidate: TopLogprob = { token: 'A', logprob: -0.5 }; + * ``` + */ +export interface TopLogprob { + /** The candidate token */ + token: string; + /** Natural log of the token probability */ + logprob: number; +} diff --git a/src/utils/error/message-of.ts b/src/utils/error/message-of.ts new file mode 100644 index 0000000..5138f51 --- /dev/null +++ b/src/utils/error/message-of.ts @@ -0,0 +1,22 @@ +/** + * Human-readable message of any thrown value, `Error` or not. + * + * JavaScript can throw anything. `Error` instances carry a `message`, but values + * thrown by other code paths (strings, numbers, response bodies, third-party + * classes) do not. This reads the `message` property of `Error` instances and + * stringifies everything else, so the result is always a usable string for + * logging, error wrapping or user-facing messages. + * + * @param err - The thrown value, of any type. + * @returns The `message` of `Error` instances, or the string representation of + * anything else. + * @example + * ```ts + * messageOf(new Error('connection refused')); // 'connection refused' + * messageOf(new DOMException('aborted', 'AbortError')); // 'aborted' + * messageOf('boom'); // 'boom' + * messageOf(42); // '42' + * ``` + */ +export const messageOf = (err: unknown): string => + err instanceof Error ? err.message : String(err); diff --git a/src/utils/llms/generate-text.ts b/src/utils/llms/generate-text.ts new file mode 100644 index 0000000..4654f20 --- /dev/null +++ b/src/utils/llms/generate-text.ts @@ -0,0 +1,119 @@ +import { messageOf } from '../error/message-of.js'; +import { truncate } from '../text/truncate.js'; +import { sleep } from '../time/sleep.js'; +import { backoffDelay } from '../network/backoff-delay.js'; +import { isTimeoutError } from '../network/is-timeout-error.js'; +import { retryDelayFromHeaders } from '../network/retry-delay-from-headers.js'; +import { shouldRetryStatus } from '../network/should-retry-status.js'; +import { VERSION } from '../../version.js'; +import type { ChatCompletion } from '../../types/chat-completion.js'; +import type { ChatCompletionRequest } from '../../types/chat-completion-request.js'; +import type { GenerateTextOptions } from '../../types/generate-text-options.js'; + +/** Path of the OpenAI-compatible chat completions endpoint, relative to the base URL. */ +const PATH = '/chat/completions'; + +// Transport defaults: 2 retries, 10 minutes per attempt. +const DEFAULT_MAX_RETRIES = 2; +const DEFAULT_TIMEOUT_MS = 600_000; +/** How many characters of an error body end up in thrown error messages. */ +const MAX_ERROR_BODY_CHARS = 500; + +/** + * Generates a chat completion from an OpenAI-compatible v1 API and returns its parsed + * response — the thin, dependency-free transport layer shared by all the library modes + * (System 1 today, System 2 later). + * + * The request is always a single non-streaming POST to `{apiBaseUrl}/chat/completions` + * with Bearer authentication. Failed attempts — network errors, plus 408/409/429/5xx + * responses or any `x-should-retry` answer — back off exponentially and are retried; + * timeouts are not. + * + * @param connection - API endpoint and credentials, mirroring `Question`'s provider settings. + * @param request - Chat completions request body in the API's own wire format. + * @param options - Retry and timeout settings; defaults to 2 retries with a 10-minute + * timeout per attempt. + * @returns The parsed response body. Response fields we don't consume are passed through untouched. + * @throws If the API answers with a failing status, an unparsable body, or keeps failing + * after every attempt. + * @example + * ```ts + * const completion = await generateText( + * { apiBaseUrl: 'http://localhost:8000/v1', apiKey: 'no-key' }, + * { + * model: '/models/Qwen3.5-4B-Q4_K_M.gguf', + * messages: [{ role: 'user', content: 'Reply with a single letter: A or B' }], + * max_tokens: 1, + * temperature: 0, + * logprobs: true, + * top_logprobs: 20, + * }, + * { maxRetries: 1, timeoutMs: 30_000 }, + * ); + * console.log(completion.choices[0]?.logprobs?.content?.[0]?.top_logprobs); + * ``` + */ +export async function generateText( + connection: { apiBaseUrl: string; apiKey: string }, + request: ChatCompletionRequest, + options: GenerateTextOptions = {}, +): Promise { + // Not validated on purpose: 0 retries means exactly one attempt, a tiny timeout aborts + // immediately, and negative values degrade monotonically in the same direction. + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + // A trailing slash on the base URL must not double when appending the path. + const url = new URL( + connection.apiBaseUrl.endsWith('/') + ? connection.apiBaseUrl + PATH.slice(1) + : connection.apiBaseUrl + PATH, + ); + + // One full request per attempt, so maxRetries counts *retries* + // (total attempts = maxRetries + 1). + for (let attempt = 0; ; attempt++) { + let response: Response; + let text: string; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${connection.apiKey}`, + 'User-Agent': `smart-decisions/${VERSION}`, + }, + body: JSON.stringify({ ...request, stream: false }), + signal: AbortSignal.timeout(timeoutMs), + }); + // Read before the status check: error responses carry their explanation in the + // body, and it can only be consumed once. + text = await response.text(); + } catch (err) { + // Timeouts are not retried: the full per-attempt budget was already spent, so + // another wait just multiplies it. Any other fetch failure (connection refused, + // reset, ...) is worth another attempt. + if (isTimeoutError(err) || attempt >= maxRetries) { + throw new Error(`LLM request to ${url} failed: ${messageOf(err)}`, { cause: err }); + } + await sleep(backoffDelay(attempt)); + continue; + } + if (!response.ok) { + if (attempt >= maxRetries || !shouldRetryStatus(response)) { + throw new Error( + `LLM API returned HTTP ${response.status}: ${truncate(text, MAX_ERROR_BODY_CHARS)}`, + ); + } + // The server's requested wait wins over our own backoff when it sends one. + await sleep(retryDelayFromHeaders(response) ?? backoffDelay(attempt)); + continue; + } + try { + return JSON.parse(text) as ChatCompletion; + } catch { + throw new Error(`LLM API returned invalid JSON: ${truncate(text, MAX_ERROR_BODY_CHARS)}`); + } + } +} diff --git a/src/utils/network/backoff-delay.ts b/src/utils/network/backoff-delay.ts new file mode 100644 index 0000000..894c7ab --- /dev/null +++ b/src/utils/network/backoff-delay.ts @@ -0,0 +1,33 @@ +/** Base delay of the first retry, in milliseconds. */ +const BACKOFF_BASE_MS = 500; +/** Upper bound of the backoff delay, in milliseconds. */ +const BACKOFF_MAX_MS = 8_000; + +/** + * Exponential backoff delay to wait before the retry that follows the given + * attempt. + * + * The delay doubles with every failed attempt and is capped at 8 seconds, so a + * struggling server is not hammered again immediately while short hiccups still + * converge fast: + * + * | attempt | delay | + * | ------- | -------- | + * | 0 | 500ms | + * | 1 | 1s | + * | 2 | 2s | + * | 3 | 4s | + * | 4+ | 8s (cap) | + * + * @param attempt - Zero-based index of the attempt that just failed. + * @returns Milliseconds to wait before the next attempt. + * @example + * ```ts + * backoffDelay(0); // 500 + * backoffDelay(1); // 1000 + * backoffDelay(3); // 4000 + * backoffDelay(10); // 8000 — capped + * ``` + */ +export const backoffDelay = (attempt: number): number => + Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS); diff --git a/src/utils/network/is-timeout-error.ts b/src/utils/network/is-timeout-error.ts new file mode 100644 index 0000000..7d2948b --- /dev/null +++ b/src/utils/network/is-timeout-error.ts @@ -0,0 +1,22 @@ +/** + * Whether the thrown value is a timeout abort, as opposed to a caller abort or + * any other failure. + * + * When a request's `AbortSignal.timeout(ms)` fires, the pending promise rejects + * with a `TimeoutError`; an explicit `AbortController.abort()` rejects with an + * `AbortError`, and network failures reject with a `TypeError`. Telling them + * apart lets callers decide what a failure means — i.e. that a timeout already + * consumed its whole budget and must not be retried. + * + * @param err - The thrown value, of any type. + * @returns Whether the value is an `Error` whose `name` is `TimeoutError`. + * @example + * ```ts + * isTimeoutError(new DOMException('timed out', 'TimeoutError')); // true + * isTimeoutError(new DOMException('user abort', 'AbortError')); // false + * isTimeoutError(new Error('fetch failed')); // false + * isTimeoutError('boom'); // false + * ``` + */ +export const isTimeoutError = (err: unknown): boolean => + err instanceof Error && err.name === 'TimeoutError'; diff --git a/src/utils/network/numeric-header.ts b/src/utils/network/numeric-header.ts new file mode 100644 index 0000000..82a8b55 --- /dev/null +++ b/src/utils/network/numeric-header.ts @@ -0,0 +1,38 @@ +/** + * Numeric value of an HTTP header, scaled by `multiplier`. + * + * Duration headers express themselves in different units (`retry-after-ms` in + * milliseconds, `Retry-After` in seconds), and servers send junk: nothing at + * all, HTTP-dates, negatives, absurdly large waits. This parses the raw value + * with `Number.parseFloat`, scales it by `multiplier`, and only accepts the + * result when it falls inside the `[0, maxMs]` range; everything else becomes + * undefined so the caller can fall back to its own policy. + * + * @param value - Raw header value, as returned by `Headers.get()` (null when the + * header is absent). + * @param multiplier - Scale applied to the parsed number (1 for a milliseconds + * header, 1000 for a seconds one). + * @param maxMs - Inclusive upper bound, in milliseconds, the scaled value must + * not exceed. + * @returns The scaled numeric value inside `[0, maxMs]`, or undefined when the + * value is absent, non-numeric, negative or above `maxMs`. + * @example + * ```ts + * numericHeader('250', 1, 60_000); // 250 + * numericHeader('2', 1000, 60_000); // 2000 — 2 seconds scaled to milliseconds + * numericHeader(null, 1, 60_000); // undefined — header absent + * numericHeader('Sat, 26 Sep 2026 12:00:00 GMT', 1000, 60_000); // undefined — HTTP-date + * numericHeader('-5', 1, 60_000); // undefined — negative + * numericHeader('120', 1000, 60_000); // undefined — above the cap + * ``` + */ +export const numericHeader = ( + value: string | null, + multiplier: number, + maxMs: number, +): number | undefined => { + if (value === null) return undefined; + const parsed = Number.parseFloat(value); + const delayMs = parsed * multiplier; + return delayMs >= 0 && delayMs <= maxMs ? delayMs : undefined; +}; diff --git a/src/utils/network/retry-delay-from-headers.ts b/src/utils/network/retry-delay-from-headers.ts new file mode 100644 index 0000000..b38c50d --- /dev/null +++ b/src/utils/network/retry-delay-from-headers.ts @@ -0,0 +1,28 @@ +import { numericHeader } from './numeric-header.js'; + +/** `Retry-After` values above 60 seconds are discarded as unreasonable. */ +const MAX_RETRY_AFTER_MS = 60_000; + +/** + * Milliseconds to wait before a retry, as requested by the response headers. + * + * Follows the de-facto retry conventions of OpenAI-compatible APIs: the + * non-standard `retry-after-ms` (milliseconds) wins when present, otherwise the + * standard `Retry-After` (seconds) is used. The HTTP-date form of `Retry-After` + * is not supported, and waits above 60 seconds are discarded. + * + * @param response - The failed HTTP response whose headers may carry retry hints. + * @returns Milliseconds to wait, or undefined when no header carries a usable + * numeric value. + * @example + * ```ts + * retryDelayFromHeaders(new Response(null, { headers: { 'retry-after-ms': '250' } })); // 250 + * retryDelayFromHeaders(new Response(null, { headers: { 'retry-after': '2' } })); // 2000 + * retryDelayFromHeaders(new Response(null)); // undefined — no usable hint + * ``` + */ +export const retryDelayFromHeaders = (response: Response): number | undefined => { + const ms = numericHeader(response.headers.get('retry-after-ms'), 1, MAX_RETRY_AFTER_MS); + if (ms !== undefined) return ms; + return numericHeader(response.headers.get('retry-after'), 1000, MAX_RETRY_AFTER_MS); +}; diff --git a/src/utils/network/should-retry-status.ts b/src/utils/network/should-retry-status.ts new file mode 100644 index 0000000..8e6a22c --- /dev/null +++ b/src/utils/network/should-retry-status.ts @@ -0,0 +1,32 @@ +/** + * Whether an HTTP response is worth retrying. + * + * The non-standard `x-should-retry` header wins when the server sends it (`true` + * or `false`), otherwise only request timeouts (408), lock timeouts (409), rate + * limits (429) and server errors (>= 500) are considered retryable. Everything + * else is final: validation errors, auth failures and the like would fail again + * the same way. + * + * @param response - The failed HTTP response to classify. + * @returns Whether the request should be attempted again. + * @example + * ```ts + * shouldRetryStatus(new Response(null, { status: 503 })); // true + * shouldRetryStatus(new Response(null, { status: 400 })); // false + * shouldRetryStatus( + * new Response(null, { status: 400, headers: { 'x-should-retry': 'true' } }), + * ); // true — the server explicitly asks for a retry + * ``` + */ +export const shouldRetryStatus = (response: Response): boolean => { + // Not a standard header, but when a server bothers to send it, obey it. + const shouldRetry = response.headers.get('x-should-retry'); + if (shouldRetry === 'true') return true; + if (shouldRetry === 'false') return false; + return ( + response.status === 408 || + response.status === 409 || + response.status === 429 || + response.status >= 500 + ); +}; diff --git a/src/utils/text/truncate.ts b/src/utils/text/truncate.ts new file mode 100644 index 0000000..9b41977 --- /dev/null +++ b/src/utils/text/truncate.ts @@ -0,0 +1,19 @@ +/** + * Shortens a string to at most `maxChars` characters, ending with an ellipsis + * (`…`) when it gets trimmed. + * + * Keeps oversized content readable inside longer texts, i.e. error messages or + * log lines that would otherwise swallow whole HTML pages. + * + * @param body - The string to cap. + * @param maxChars - Maximum number of characters to keep before the ellipsis. + * @returns The string unchanged while within the limit, or its first `maxChars` + * characters followed by `…` otherwise. + * @example + * ```ts + * truncate('short', 500); // 'short' + * truncate('x'.repeat(600), 500); // 500 x's followed by '…' + * ``` + */ +export const truncate = (body: string, maxChars: number): string => + body.length > maxChars ? `${body.slice(0, maxChars)}…` : body; diff --git a/src/utils/time/sleep.ts b/src/utils/time/sleep.ts new file mode 100644 index 0000000..5c0ae11 --- /dev/null +++ b/src/utils/time/sleep.ts @@ -0,0 +1,14 @@ +/** + * Waits for the given amount of time. + * + * A promise-based `setTimeout`, for pausing between the steps of an async flow. + * + * @param ms - Milliseconds to wait before the promise resolves. + * @returns A promise that resolves once the delay has elapsed. + * @example + * ```ts + * await sleep(500); // resumes half a second later + * ``` + */ +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..ceb3b05 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,7 @@ +/** + * Library version, as published in package.json. + * + * Shown to servers in the `User-Agent` header of every request. Kept in sync + * with package.json by `test/version.test.ts`; bump both on release. + */ +export const VERSION = '0.0.2'; diff --git a/test/choice/choice.test.ts b/test/choice/choice.test.ts index 6467cb2..2133002 100644 --- a/test/choice/choice.test.ts +++ b/test/choice/choice.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { choice } from '../../src/index.js'; import { system1Choice } from '../../src/choice/system1-choice.js'; +import type { Question } from '../../src/types/question.js'; vi.mock('../../src/choice/system1-choice.js', () => ({ system1Choice: vi.fn().mockResolvedValue({ choice: 'mocked', @@ -12,11 +13,12 @@ vi.mock('../../src/choice/system1-choice.js', () => ({ const question = ( mode?: 'system1' | 'system2', criteria: Record = { a: 'A', b: 'B' }, -) => ({ +): Question => ({ apiBaseUrl: 'https://example.com/v1', apiKey: 'key', model: 'model', - mode, + // Omitted when undefined: exactOptionalPropertyTypes forbids an explicit `mode: undefined`. + ...(mode !== undefined && { mode }), criteria, state: 'state', instructions: 'instructions', diff --git a/test/choice/system1-choice.test.ts b/test/choice/system1-choice.test.ts index 537f4cc..e4f5c9c 100644 --- a/test/choice/system1-choice.test.ts +++ b/test/choice/system1-choice.test.ts @@ -1,16 +1,19 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { system1Choice } from '../../src/choice/system1-choice.js'; -// Mock the OpenAI client at module level: system1Choice is its direct user. -// All mocked responses below return logprobs-shaped bodies, so tests only need +// system1Choice is exercised through the global fetch: each mocked response below is a +// real Response carrying a logprobs-shaped body, so tests only need // `choices[0].logprobs.content[0].top_logprobs` to exist. -const createMock = vi.hoisted(() => vi.fn()); +const fetchMock = vi.fn(); -vi.mock('openai', () => ({ - default: class { - chat = { completions: { create: createMock } }; - }, -})); +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + fetchMock.mockReset(); +}); // Running example: "It is raining and I am at home. I'm bored." — choose a plan // for right now (walking or a movie fit a rainy day at home, the beach does not); @@ -29,20 +32,17 @@ const question = () => ({ }); const logprobToken = (token: string, logprob: number) => ({ token, logprob }); -const response = (tops: { token: string; logprob: number }[]) => ({ - choices: [{ logprobs: { content: [{ top_logprobs: tops }] } }], -}); - -beforeEach(() => { - createMock.mockReset(); -}); +const response = (tops: { token: string; logprob: number }[]) => + new Response(JSON.stringify({ choices: [{ logprobs: { content: [{ top_logprobs: tops }] } }] }), { + status: 200, + }); describe('system1Choice', () => { it('throws with fewer than 2 options', async () => { await expect( system1Choice({ ...question(), criteria: { only: 'one option' } }), ).rejects.toThrow('choice() supports 2..26 options, got 1'); - expect(createMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('throws with more than 26 options', async () => { @@ -50,41 +50,59 @@ describe('system1Choice', () => { await expect(system1Choice({ ...question(), criteria })).rejects.toThrow( 'choice() supports 2..26 options, got 27', ); - expect(createMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); }); it('throws when the provider response has no logprobs', async () => { - createMock.mockResolvedValueOnce({ choices: [{ logprobs: null }] }); + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ choices: [{ logprobs: null }] }), { status: 200 }), + ); await expect(system1Choice(question())).rejects.toThrow('system1 requires logprobs'); }); it('throws when the provider response has an empty top logprobs list', async () => { - createMock.mockResolvedValueOnce(response([])); + fetchMock.mockResolvedValueOnce(response([])); await expect(system1Choice(question())).rejects.toThrow('system1 requires logprobs'); - expect(createMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledOnce(); }); it('makes a single-token greedy logprobs request', async () => { - createMock.mockResolvedValueOnce(response([logprobToken('A', Math.log(0.6))])); + fetchMock.mockResolvedValueOnce(response([logprobToken('A', Math.log(0.6))])); await system1Choice(question()); - expect(createMock).toHaveBeenCalledOnce(); - const params = createMock.mock.calls[0][0] as Record; + const [url, init] = fetchMock.mock.calls[0] as [URL, RequestInit]; + expect(String(url)).toBe('https://example.com/v1/chat/completions'); + expect(init.method).toBe('POST'); + const params = JSON.parse(init.body as string); expect(params.model).toBe('model'); expect(params.max_tokens).toBe(1); // one forward pass, one generated token expect(params.temperature).toBe(0); expect(params.logprobs).toBe(true); expect(params.top_logprobs).toBe(50); expect(params.chat_template_kwargs).toEqual({ enable_thinking: false }); + expect(params.stream).toBe(false); // Prompt includes the lettered options and the single-letter instruction. - expect((params.messages as { content: string }[])[0].content).toContain( - 'A: walk — Go for a walk', - ); - expect((params.messages as { content: string }[])[0].content).toContain('exactly one letter'); + expect(params.messages[0].content).toContain('A: walk — Go for a walk'); + expect(params.messages[0].content).toContain('exactly one letter'); + }); + + it('forwards retry and timeout settings to the transport', async () => { + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + try { + // maxRetries: 0 → the retryable 500 is not retried, proving forwarding end to end. + fetchMock.mockResolvedValueOnce(new Response('down', { status: 500 })); + await expect( + system1Choice({ ...question(), maxRetries: 0, timeoutMs: 123456 }), + ).rejects.toThrow('LLM API returned HTTP 500: down'); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(timeoutSpy).toHaveBeenCalledWith(123456); + } finally { + timeoutSpy.mockRestore(); + } }); it('groups token variants (case, leading space) and keeps the highest probability', async () => { - createMock.mockResolvedValueOnce( + fetchMock.mockResolvedValueOnce( response([ logprobToken(' A', Math.log(0.3)), logprobToken('a', Math.log(0.2)), // same letter, lower → dropped @@ -101,7 +119,7 @@ describe('system1Choice', () => { it('normalizes to 1 when letters only account for part of the mass; tie keeps first option', async () => { // letters A and B at 0.25 each, another token has 0.5 → z = 0.5, so 0.5/0.5 - createMock.mockResolvedValueOnce( + fetchMock.mockResolvedValueOnce( response([ logprobToken('A', Math.log(0.25)), logprobToken('B', Math.log(0.25)), @@ -115,7 +133,7 @@ describe('system1Choice', () => { }); it('falls back to uniform when no letter appears in the top logprobs', async () => { - createMock.mockResolvedValueOnce( + fetchMock.mockResolvedValueOnce( response([logprobToken('2', Math.log(1.0)), logprobToken('!', Math.log(0.5))]), ); const answer = await system1Choice(question()); @@ -127,7 +145,7 @@ describe('system1Choice', () => { }); it('matches confidence to the normalized entropy of the returned distribution', async () => { - createMock.mockResolvedValueOnce( + fetchMock.mockResolvedValueOnce( response([ logprobToken(' A', Math.log(0.6)), logprobToken(' B', Math.log(0.3)), @@ -149,7 +167,7 @@ describe('system1Choice', () => { // only reduce whose callback declares (accumulator, value, index) — gets its // result overridden with an index beyond `names`. it('throws the internal invariant error if the argmax returns an out-of-range index', async () => { - createMock.mockResolvedValueOnce(response([logprobToken('A', Math.log(0.6))])); + fetchMock.mockResolvedValueOnce(response([logprobToken('A', Math.log(0.6))])); const native = Array.prototype.reduce; const impl = function ( this: unknown[], @@ -178,7 +196,7 @@ describe('system1Choice', () => { // array whose first slot is null. null coerces to 0 in the sum, so execution // stays on the normal path while both `?? 0` fallbacks fire. it('treats nullish probability slots as 0 instead of letting NaN propagate', async () => { - createMock.mockResolvedValueOnce(response([logprobToken('B', Math.log(0.4))])); + fetchMock.mockResolvedValueOnce(response([logprobToken('B', Math.log(0.4))])); const nativeMap = Array.prototype.map; const impl = function (this: unknown[], cb: unknown, ...args: unknown[]) { // entries.map receivers hold [name, description] pairs → run natively. @@ -190,7 +208,7 @@ describe('system1Choice', () => { spy.mockImplementation(impl as unknown as typeof Array.prototype.map); try { const answer = await system1Choice(question()); - expect(createMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledOnce(); // z = null + 0.6 + 0.4 = 1 exactly, so normalization is a no-op; // the null slot surfaces as walk: 0 instead of NaN-poisoning the run. expect(answer.probabilities).toEqual({ walk: 0, movie: 0.6, beach: 0.4 }); diff --git a/test/types/chat-completion-choice.test.ts b/test/types/chat-completion-choice.test.ts new file mode 100644 index 0000000..efacf10 --- /dev/null +++ b/test/types/chat-completion-choice.test.ts @@ -0,0 +1,14 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ChatCompletionChoice } from '../../src/types/chat-completion-choice.js'; +import type { ChatCompletionTokenLogprob } from '../../src/types/chat-completion-token-logprob.js'; + +// src/types/chat-completion-choice.ts contains only types, so these are compile-time +// assertions: vitest type-checks them with expectTypeOf and the tests pass +// trivially at runtime. +describe('chatCompletionChoice', () => { + it('has the expected fields and optionality', () => { + expectTypeOf().toEqualTypeOf< + { content?: ChatCompletionTokenLogprob[] | null } | null | undefined + >(); + }); +}); diff --git a/test/types/chat-completion-request.test.ts b/test/types/chat-completion-request.test.ts new file mode 100644 index 0000000..ec4aa74 --- /dev/null +++ b/test/types/chat-completion-request.test.ts @@ -0,0 +1,21 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ChatCompletionRequest } from '../../src/types/chat-completion-request.js'; + +// src/types/chat-completion-request.ts contains only types, so these are compile-time +// assertions: vitest type-checks them with expectTypeOf and the tests pass +// trivially at runtime. +describe('chatCompletionRequest', () => { + it('has the expected fields and optionality', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + import('../../src/types/chat-message.js').ChatMessage[] + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + Record | undefined + >(); + }); +}); diff --git a/test/types/chat-completion-token-logprob.test.ts b/test/types/chat-completion-token-logprob.test.ts new file mode 100644 index 0000000..c65d02c --- /dev/null +++ b/test/types/chat-completion-token-logprob.test.ts @@ -0,0 +1,16 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ChatCompletionTokenLogprob } from '../../src/types/chat-completion-token-logprob.js'; +import type { TopLogprob } from '../../src/types/top-logprob.js'; + +// src/types/chat-completion-token-logprob.ts contains only types, so these are +// compile-time assertions: vitest type-checks them with expectTypeOf and the tests +// pass trivially at runtime. +describe('chatCompletionTokenLogprob', () => { + it('has the expected fields and optionality', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + TopLogprob[] | undefined + >(); + }); +}); diff --git a/test/types/chat-completion.test.ts b/test/types/chat-completion.test.ts new file mode 100644 index 0000000..9f6a7df --- /dev/null +++ b/test/types/chat-completion.test.ts @@ -0,0 +1,12 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ChatCompletion } from '../../src/types/chat-completion.js'; +import type { ChatCompletionChoice } from '../../src/types/chat-completion-choice.js'; + +// src/types/chat-completion.ts contains only types, so these are compile-time +// assertions: vitest type-checks them with expectTypeOf and the tests pass +// trivially at runtime. +describe('chatCompletion', () => { + it('has the expected fields and optionality', () => { + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/test/types/chat-message.test.ts b/test/types/chat-message.test.ts new file mode 100644 index 0000000..861172b --- /dev/null +++ b/test/types/chat-message.test.ts @@ -0,0 +1,12 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { ChatMessage } from '../../src/types/chat-message.js'; + +// src/types/chat-message.ts contains only types, so these are compile-time +// assertions: vitest type-checks them with expectTypeOf and the tests pass +// trivially at runtime. +describe('chatMessage', () => { + it('has the expected fields and optionality', () => { + expectTypeOf().toEqualTypeOf<'system' | 'user' | 'assistant'>(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/test/types/generate-text-options.test.ts b/test/types/generate-text-options.test.ts new file mode 100644 index 0000000..feb7d31 --- /dev/null +++ b/test/types/generate-text-options.test.ts @@ -0,0 +1,12 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { GenerateTextOptions } from '../../src/types/generate-text-options.js'; + +// src/types/generate-text-options.ts contains only types, so these are compile-time +// assertions: vitest type-checks them with expectTypeOf and the tests pass +// trivially at runtime. +describe('generateTextOptions', () => { + it('has the expected fields and optionality', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/test/types/question.test.ts b/test/types/question.test.ts index a2fb2d4..da81bf9 100644 --- a/test/types/question.test.ts +++ b/test/types/question.test.ts @@ -1,5 +1,6 @@ import { describe, expectTypeOf, it } from 'vitest'; -import type { ChoiceMode, Question } from '../../src/types/question.js'; +import type { ChoiceMode } from '../../src/types/choice-mode.js'; +import type { Question } from '../../src/types/question.js'; // src/types/question.ts contains only types, so these are compile-time // assertions: vitest type-checks them with expectTypeOf and the tests pass @@ -9,6 +10,8 @@ describe('question', () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); diff --git a/test/types/top-logprob.test.ts b/test/types/top-logprob.test.ts new file mode 100644 index 0000000..18672ec --- /dev/null +++ b/test/types/top-logprob.test.ts @@ -0,0 +1,12 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { TopLogprob } from '../../src/types/top-logprob.js'; + +// src/types/top-logprob.ts contains only types, so these are compile-time +// assertions: vitest type-checks them with expectTypeOf and the tests pass +// trivially at runtime. +describe('topLogprob', () => { + it('has the expected fields', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); +}); diff --git a/test/utils/error/message-of.test.ts b/test/utils/error/message-of.test.ts new file mode 100644 index 0000000..d8c595c --- /dev/null +++ b/test/utils/error/message-of.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { messageOf } from '../../../src/utils/error/message-of.js'; + +describe('messageOf', () => { + it('extracts the message from Error instances', () => { + expect(messageOf(new Error('nope'))).toBe('nope'); + }); + + it('stringifies non-Error throwables', () => { + expect(messageOf('boom')).toBe('boom'); + expect(messageOf(42)).toBe('42'); + }); +}); diff --git a/test/utils/llms/generate-text.test.ts b/test/utils/llms/generate-text.test.ts new file mode 100644 index 0000000..c78f3e7 --- /dev/null +++ b/test/utils/llms/generate-text.test.ts @@ -0,0 +1,224 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { generateText } from '../../../src/utils/llms/generate-text.js'; +import type { ChatCompletionRequest } from '../../../src/types/chat-completion-request.js'; + +// The transport is exercised through the global fetch with real Response objects, so +// every test runs against the same status/headers/body contract the server answers with. +const fetchMock = vi.fn(); +// Observes per-attempt timeouts; spying on the static calls through to the real one. +const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + +const connection = () => ({ apiBaseUrl: 'https://example.com/v1', apiKey: 'secret' }); +const request = (): ChatCompletionRequest => ({ + model: 'model', + messages: [{ role: 'user', content: 'hi' }], +}); +const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); +const fail = (status: number, body: string, headers: Record = {}) => + new Response(body, { status, headers }); + +interface FetchInit { + method: string; + headers: Record; + body: string; + signal: AbortSignal; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + fetchMock.mockReset(); + timeoutSpy.mockClear(); +}); + +describe('generateText', () => { + it('posts a non-streaming request with auth headers and a timeout signal', async () => { + fetchMock.mockResolvedValueOnce(ok({ choices: [{ logprobs: null }] })); + + const answer = await generateText(connection(), request()); + + expect(answer).toEqual({ choices: [{ logprobs: null }] }); + const [url, init] = fetchMock.mock.calls[0] as [URL, FetchInit]; + expect(String(url)).toBe('https://example.com/v1/chat/completions'); + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/json'); + expect(init.headers.Accept).toBe('application/json'); + expect(init.headers.Authorization).toBe('Bearer secret'); + expect(init.headers['User-Agent']).toMatch(/^smart-decisions\//); + expect(JSON.parse(init.body)).toEqual({ + model: 'model', + messages: [{ role: 'user', content: 'hi' }], + stream: false, + }); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it('joins base URLs with and without a trailing slash without doubling it', async () => { + fetchMock.mockResolvedValueOnce(ok({ choices: [] })); + await generateText(connection(), request()); + + fetchMock.mockResolvedValueOnce(ok({ choices: [] })); + await generateText({ apiBaseUrl: 'https://example.com/v1/', apiKey: 'k' }, request()); + + expect(String(fetchMock.mock.calls[0]![0])).toBe('https://example.com/v1/chat/completions'); + expect(String(fetchMock.mock.calls[1]![0])).toBe('https://example.com/v1/chat/completions'); + }); + + it('applies the default timeout and honors an explicit one, per attempt', async () => { + fetchMock.mockResolvedValueOnce(ok({ choices: [] })); + await generateText(connection(), request()); + expect(timeoutSpy).toHaveBeenCalledWith(600_000); // 10-minute default + + fetchMock.mockResolvedValueOnce(ok({ choices: [] })); + await generateText(connection(), request(), { timeoutMs: 42 }); + expect(timeoutSpy).toHaveBeenCalledWith(42); + }); + + it('throws immediately on a non-retryable status, without retrying', async () => { + fetchMock.mockResolvedValueOnce(fail(400, '{"error":{"message":"bad"}}')); + + await expect(generateText(connection(), request())).rejects.toThrow( + 'LLM API returned HTTP 400: {"error":{"message":"bad"}}', + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('honors maxRetries: 0 by making exactly one attempt, and truncates long bodies', async () => { + const long = 'x'.repeat(600); + fetchMock.mockResolvedValue(fail(408, long)); + + await expect(generateText(connection(), request(), { maxRetries: 0 })).rejects.toThrow( + `LLM API returned HTTP 408: ${'x'.repeat(500)}…`, // exactly one ellipsis shows the cut + ); + // 408 is retryable per status, but maxRetries: 0 means no second attempt. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('retries 408 and 409 once each on the default backoff, then succeeds', async () => { + fetchMock + .mockResolvedValueOnce(fail(408, 'timeout')) + .mockResolvedValueOnce(fail(409, 'locked')) + .mockResolvedValueOnce(ok({ choices: [] })); + + const pending = generateText(connection(), request()); + // Default backoff: 500ms after the first failure, 1000ms after the second. + await vi.advanceTimersByTimeAsync(1500); + await expect(pending).resolves.toEqual({ choices: [] }); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('retries a 429 waiting for the retry-after-ms header, not the default backoff', async () => { + fetchMock + .mockResolvedValueOnce(fail(429, 'rate limited', { 'retry-after-ms': '250' })) + .mockResolvedValueOnce(ok({ choices: [] })); + + const pending = generateText(connection(), request()); + await vi.advanceTimersByTimeAsync(249); // default backoff would be 500ms, header says 250 + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).resolves.toEqual({ choices: [] }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('retries a 5xx honoring retry-after until attempts run out, reporting the last body', async () => { + // A factory, not mockResolvedValue: each attempt must get a fresh Response, + // because a real response body can only be read once. + let attempt = 0; + fetchMock.mockImplementation(async () => fail(503, `body${++attempt}`, { 'retry-after': '2' })); + + // The rejection handler attaches at creation, so the pending rejection is never unhandled. + const pending = expect(generateText(connection(), request())).rejects.toThrow( + // The thrown message comes from the LAST attempt's body. + 'LLM API returned HTTP 503: body3', + ); + // retry-after: 2s before each retry; 2 retries → 3 attempts. + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchMock).toHaveBeenCalledTimes(3); + await pending; + }); + + it('obeys x-should-retry over its own status-based policy, both ways', async () => { + // Not retryable per status, but the server explicitly asks to retry: 400 retried. + fetchMock + .mockResolvedValueOnce(fail(400, 'weird policy', { 'x-should-retry': 'true' })) + .mockResolvedValueOnce(ok({ choices: [] })); + const first = generateText(connection(), request()); + await vi.advanceTimersByTimeAsync(500); + await expect(first).resolves.toEqual({ choices: [] }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + // Retryable per status, but the server explicitly says don't: 500 not retried. + fetchMock.mockResolvedValueOnce(fail(500, 'explicitly final', { 'x-should-retry': 'false' })); + await expect(generateText(connection(), request())).rejects.toThrow( + 'LLM API returned HTTP 500: explicitly final', + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('falls back to the exponential backoff for absent, non-numeric and too-long retry-afters', async () => { + fetchMock + .mockResolvedValueOnce(fail(503, 'a', { 'retry-after-ms': 'later' })) // NaN text → backoff + .mockResolvedValueOnce(fail(503, 'b', { 'retry-after-ms': '-5' })) // negative → backoff + .mockResolvedValueOnce(fail(503, 'c', { 'retry-after': 'Sat, 26 Sep 2026 12:00:00 GMT' })) // HTTP-date → backoff + .mockResolvedValueOnce(fail(503, 'd', { 'retry-after': '120' })) // > 60s → backoff + .mockResolvedValueOnce(ok({ choices: [] })); + + const pending = generateText(connection(), request(), { maxRetries: 4 }); + // All fallback backoff delays: 500, 1000, 2000, 4000. + await vi.advanceTimersByTimeAsync(7500); + await expect(pending).resolves.toEqual({ choices: [] }); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + + it('throws on a 200 response with an unparsable body', async () => { + fetchMock.mockResolvedValueOnce(new Response('gateway error', { status: 200 })); + + await expect(generateText(connection(), request())).rejects.toThrow( + 'LLM API returned invalid JSON: gateway error', + ); + }); + + it('retries network errors, and wraps the cause in the final error message', async () => { + const boom = new Error('connection refused'); + fetchMock.mockRejectedValueOnce(boom).mockResolvedValueOnce(ok({ choices: [] })); + + const pending = generateText(connection(), request()); + await vi.advanceTimersByTimeAsync(500); + await expect(pending).resolves.toEqual({ choices: [] }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('never retries timeouts, throwing with the cause after the first attempt', async () => { + const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError'); + fetchMock.mockRejectedValue(timeout); + + const err = await generateText(connection(), request()).catch((err) => err); + expect(err.message).toBe( + 'LLM request to https://example.com/v1/chat/completions failed: The operation was aborted due to timeout', + ); + expect(err.cause).toBe(timeout); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('wraps non-Error rejections too, exhausting all attempts', async () => { + fetchMock.mockRejectedValue('boom'); + + const caught = generateText(connection(), request(), { maxRetries: 2 }).catch( + (err) => err as Error, + ); + await vi.advanceTimersByTimeAsync(3500); // 500 + 1000 + 2000 + const err = await caught; + // @ts-expect-error test + expect(err.message).toBe('LLM request to https://example.com/v1/chat/completions failed: boom'); + // @ts-expect-error test + expect(err.cause).toBe('boom'); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); +}); diff --git a/test/utils/network/backoff-delay.test.ts b/test/utils/network/backoff-delay.test.ts new file mode 100644 index 0000000..5f93c05 --- /dev/null +++ b/test/utils/network/backoff-delay.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { backoffDelay } from '../../../src/utils/network/backoff-delay.js'; + +describe('backoffDelay', () => { + it('doubles per attempt and caps at 8s', () => { + expect(backoffDelay(0)).toBe(500); + expect(backoffDelay(1)).toBe(1000); + expect(backoffDelay(2)).toBe(2000); + expect(backoffDelay(3)).toBe(4000); + expect(backoffDelay(4)).toBe(8000); + expect(backoffDelay(5)).toBe(8000); // capped + expect(backoffDelay(99)).toBe(8000); + }); +}); diff --git a/test/utils/network/is-timeout-error.test.ts b/test/utils/network/is-timeout-error.test.ts new file mode 100644 index 0000000..4776fa8 --- /dev/null +++ b/test/utils/network/is-timeout-error.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isTimeoutError } from '../../../src/utils/network/is-timeout-error.js'; + +describe('isTimeoutError', () => { + it('recognizes AbortSignal.timeout aborts', () => { + expect( + isTimeoutError(new DOMException('The operation was aborted due to timeout', 'TimeoutError')), + ).toBe(true); + }); + + it('rejects other errors and non-Error values', () => { + expect(isTimeoutError(new Error('fetch failed'))).toBe(false); + expect(isTimeoutError('boom')).toBe(false); + expect(isTimeoutError(undefined)).toBe(false); + }); +}); diff --git a/test/utils/network/numeric-header.test.ts b/test/utils/network/numeric-header.test.ts new file mode 100644 index 0000000..4a2a73c --- /dev/null +++ b/test/utils/network/numeric-header.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { numericHeader } from '../../../src/utils/network/numeric-header.js'; + +describe('numericHeader', () => { + it('returns undefined when the header is absent', () => { + expect(numericHeader(null, 1, 60_000)).toBeUndefined(); + }); + + it('parses numeric values scaled by the multiplier', () => { + expect(numericHeader('250', 1, 60_000)).toBe(250); // milliseconds + expect(numericHeader('2', 1000, 60_000)).toBe(2000); // seconds + }); + + it('returns undefined for non-numeric values (e.g. an HTTP-date)', () => { + expect(numericHeader('Sat, 26 Sep 2026 12:00:00 GMT', 1000, 60_000)).toBeUndefined(); + }); + + it('rejects negative and above-cap values', () => { + expect(numericHeader('-5', 1, 60_000)).toBeUndefined(); + expect(numericHeader('120', 1000, 60_000)).toBeUndefined(); // 120s > 60s cap + }); + + it('accepts the boundary value at the cap', () => { + expect(numericHeader('60', 1000, 60_000)).toBe(60_000); + }); +}); diff --git a/test/utils/network/retry-delay-from-headers.test.ts b/test/utils/network/retry-delay-from-headers.test.ts new file mode 100644 index 0000000..040470e --- /dev/null +++ b/test/utils/network/retry-delay-from-headers.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { retryDelayFromHeaders } from '../../../src/utils/network/retry-delay-from-headers.js'; + +describe('retryDelayFromHeaders', () => { + it('prefers retry-after-ms over retry-after', () => { + const res = new Response(null, { headers: { 'retry-after-ms': '250', 'retry-after': '9' } }); + expect(retryDelayFromHeaders(res)).toBe(250); + }); + + it('falls back to retry-after seconds', () => { + const res = new Response(null, { headers: { 'retry-after': '2' } }); + expect(retryDelayFromHeaders(res)).toBe(2000); + }); + + it('returns undefined without retry headers', () => { + expect(retryDelayFromHeaders(new Response(null))).toBeUndefined(); + }); +}); diff --git a/test/utils/network/should-retry-status.test.ts b/test/utils/network/should-retry-status.test.ts new file mode 100644 index 0000000..be80330 --- /dev/null +++ b/test/utils/network/should-retry-status.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { shouldRetryStatus } from '../../../src/utils/network/should-retry-status.js'; + +describe('shouldRetryStatus', () => { + it('obeys x-should-retry when present, both ways', () => { + expect( + shouldRetryStatus(new Response(null, { status: 400, headers: { 'x-should-retry': 'true' } })), + ).toBe(true); + expect( + shouldRetryStatus( + new Response(null, { status: 500, headers: { 'x-should-retry': 'false' } }), + ), + ).toBe(false); + }); + + it('retries timeouts, lock timeouts, rate limits and any 5xx', () => { + for (const status of [408, 409, 429, 500, 503, 599]) { + expect(shouldRetryStatus(new Response(null, { status }))).toBe(true); + } + }); + + it('does not retry other statuses', () => { + for (const status of [200, 301, 400, 401, 404, 418, 499]) { + expect(shouldRetryStatus(new Response(null, { status }))).toBe(false); + } + }); +}); diff --git a/test/utils/text/truncate.test.ts b/test/utils/text/truncate.test.ts new file mode 100644 index 0000000..b51818b --- /dev/null +++ b/test/utils/text/truncate.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { truncate } from '../../../src/utils/text/truncate.js'; + +describe('truncate', () => { + it('keeps short bodies as-is', () => { + expect(truncate('short', 500)).toBe('short'); + }); + + it('keeps bodies exactly at the limit untouched', () => { + expect(truncate('x'.repeat(500), 500)).toBe('x'.repeat(500)); + }); + + it('caps long bodies at the limit with an ellipsis', () => { + expect(truncate('x'.repeat(501), 500)).toBe(`${'x'.repeat(500)}…`); + expect(truncate('x'.repeat(600), 500)).toBe(`${'x'.repeat(500)}…`); + }); +}); diff --git a/test/utils/time/sleep.test.ts b/test/utils/time/sleep.test.ts new file mode 100644 index 0000000..5d2a357 --- /dev/null +++ b/test/utils/time/sleep.test.ts @@ -0,0 +1,18 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sleep } from '../../../src/utils/time/sleep.js'; + +describe('sleep', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('resolves only after the requested delay', async () => { + const pending = sleep(500); + const watcher = pending.then(() => true); + await vi.advanceTimersByTimeAsync(499); + // Not resolved yet: the wait is a real timer, honored through fake timers. + expect(await Promise.race([watcher, Promise.resolve(false)])).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(await watcher).toBe(true); + await pending; + }); +}); diff --git a/test/version.test.ts b/test/version.test.ts new file mode 100644 index 0000000..b1faefb --- /dev/null +++ b/test/version.test.ts @@ -0,0 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { VERSION } from '../src/version.js'; + +// The User-Agent header exposes this constant to servers, and publishing gates on +// tests: a drift between the constant and package.json fails here, before a stale +// version lands in the header. +describe('version', () => { + it('matches the package.json version', () => { + const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + version: string; + }; + expect(VERSION).toBe(pkg.version); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..7250d7b --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "sourceMap": true, + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index 9fd8664..d31f263 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,9 +2,7 @@ "compilerOptions": { "module": "nodenext", "target": "es2023", - "types": [], - "sourceMap": true, - "declaration": true, + "types": ["node"], "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "strict": true, @@ -13,8 +11,7 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": false, - "rootDir": "./src", - "outDir": "./dist" + "noEmit": true }, - "include": ["src"] + "include": ["src", "test", "examples"] }