diff --git a/README.md b/README.md index ad4afc3..4bd8ede 100644 --- a/README.md +++ b/README.md @@ -1049,7 +1049,8 @@ In `packages/service/src/`: - `clients.ts`: the PROXY protocol header and the address of a client. - `run-config.ts`: the check of the run config of a client, with its limits. -- `errors.ts`: the log line and the client text of an unexpected error. +- `errors.ts`: the errors that are the fault of the client, and the log + line and the client text of an unexpected error. - `addresses.ts`: the public addresses that the proxy can send requests to. - `recorder.ts`: modes, sessions, replay, and misses. - `recording-file.ts`: a recording file from a live response, the response @@ -1066,6 +1067,6 @@ In `packages/service/src/`: - `streams.ts`: merges the deltas of a recorded model stream. - `usage.ts`: the token usage of a model response, for Sentry. - `secrets.ts`: redaction of credentials. -- `json-strings.ts`: the one-pass scan of the JSON strings of a body, for - `secrets.ts`. +- `json-strings.ts`: the parse of a JSON body, and the one-pass scan of + the JSON strings of a body, for `secrets.ts`. - `certificates.ts`: the certificate authority for HTTPS. diff --git a/packages/service/src/bodies.ts b/packages/service/src/bodies.ts index 0c0f0e0..8509bbe 100644 --- a/packages/service/src/bodies.ts +++ b/packages/service/src/bodies.ts @@ -4,6 +4,7 @@ * the time that a body has to come. */ import type http from "node:http"; +import { httpError } from "./errors.ts"; /** * A control API body over this size gets HTTP 413. Anyone can call @@ -11,10 +12,6 @@ import type http from "node:http"; */ const MAX_CONTROL_BODY_BYTES = 1024 * 1024; -/** An error that answers a request with its own status. */ -const httpError = (status: number, message: string) => - Object.assign(new Error(message), { status }); - /** * The bytes of request bodies that the requests of many targets can hold * at one time (`ProxyTarget.bodyBudget`). `used` counts the bodies of the diff --git a/packages/service/src/errors.ts b/packages/service/src/errors.ts index 83a3a24..0ae9e67 100644 --- a/packages/service/src/errors.ts +++ b/packages/service/src/errors.ts @@ -1,9 +1,18 @@ /** - * The log line of an unexpected error and the text that goes back to the - * client. Neither has the message of the error, because it can hold a - * response body. Only the Sentry event has the full error. + * The errors of the service: an error that is the fault of the client, + * with its HTTP status (`httpError()`), and the log line and the client + * text of an unexpected error. Neither of these has the message of an + * unexpected error, because it can hold a response body. Only the Sentry + * event has the full error. */ +/** + * An error that is the fault of the client. The client gets its message + * with the HTTP status `status`. + */ +export const httpError = (status: number, message: string) => + Object.assign(new Error(message), { status }); + /** * The class, code, and HTTP status of an error, for the log. The message * can hold a response body, so the log does not get it. diff --git a/packages/service/src/json-strings.ts b/packages/service/src/json-strings.ts index c7b97bd..8868a20 100644 --- a/packages/service/src/json-strings.ts +++ b/packages/service/src/json-strings.ts @@ -1,23 +1,35 @@ /** - * The JSON reads of the redaction of credentials (`secrets.ts`). + * The JSON reads of the service. * - * `scanStrings()` reads JSON text in one pass and gives each string with - * its depth, whether it is a field name, and its escapes. `decodeString()` - * decodes one string. `parseJson()` parses a whole body. + * `parseJson()` parses a whole body, for the key (`request-key.ts`) and + * the token usage (`usage.ts`). `parseJsonSkipBom()` also skips a byte + * order mark at the start, for the redaction of credentials + * (`secrets.ts`). The key does not skip it, so that no key changes. + * `scanStrings()` reads JSON text in one pass and gives each + * string with its depth, whether it is a field name, and its escapes. + * `decodeString()` decodes one string. */ -/** - * The parsed value of a JSON text, or `undefined` when it is not JSON. A - * byte order mark at the start does not count. - */ +/** The parsed value of a JSON text, or `undefined` when it is not JSON. */ export const parseJson = (text: string): unknown => { try { - return JSON.parse(text.startsWith("\uFEFF") ? text.slice(1) : text); + return JSON.parse(text); } catch { return undefined; } }; +/** + * The parsed value of a JSON text, or `undefined` when it is not JSON. A + * byte order mark at the start does not count. + */ +export const parseJsonSkipBom = (text: string): unknown => + parseJson(text.startsWith("\uFEFF") ? text.slice(1) : text); + +/** Tells whether a parsed JSON value is an object that is not an array. */ +export const isObject = (json: unknown): json is Record => + json !== null && typeof json === "object" && !Array.isArray(json); + const QUOTE = 0x22; const BACKSLASH = 0x5c; diff --git a/packages/service/src/recorder.ts b/packages/service/src/recorder.ts index 76e7bb6..14c59f6 100644 --- a/packages/service/src/recorder.ts +++ b/packages/service/src/recorder.ts @@ -32,7 +32,7 @@ */ import { randomBytes } from "node:crypto"; import type { IncomingHttpHeaders } from "node:http"; -import { reportInternal } from "./errors.ts"; +import { httpError, reportInternal } from "./errors.ts"; import { firstChange, sampleKey, toSample, withheldSample } from "./misses.ts"; import { keyInWorker, @@ -296,11 +296,9 @@ export function createRecorder(config: RecorderConfig, store: RecordingStore) { knownTotal.chars + chars > maxKnown.chars ) { if (!closed) config.endRun("its sessions have too many changing values"); - throw Object.assign( - new Error( - `the sessions of this run have over ${maxKnown.values} changing values or ${maxKnown.chars} characters of them. This run has ended.`, - ), - { status: 400 }, + throw httpError( + 400, + `the sessions of this run have over ${maxKnown.values} changing values or ${maxKnown.chars} characters of them. This run has ended.`, ); } for (const [value, name] of added) session.known.set(value, name); @@ -784,9 +782,9 @@ export function createRecorder(config: RecorderConfig, store: RecordingStore) { await finish(same, false); } if (open().length >= MAX_OPEN_SESSIONS) { - throw Object.assign( - new Error(`A run can have ${MAX_OPEN_SESSIONS} open sessions`), - { status: 429 }, + throw httpError( + 429, + `A run can have ${MAX_OPEN_SESSIONS} open sessions`, ); } const ended = [...sessions.values()].filter( @@ -818,9 +816,7 @@ export function createRecorder(config: RecorderConfig, store: RecordingStore) { async endSession(name: string, passed: boolean): Promise { const ended = open().find((other) => other.name === name); if (!ended) { - throw Object.assign(new Error(`Session "${name}" is not open`), { - status: 409, - }); + throw httpError(409, `Session "${name}" is not open`); } return finish(ended, passed); }, diff --git a/packages/service/src/request-key.ts b/packages/service/src/request-key.ts index f970469..7478ed7 100644 --- a/packages/service/src/request-key.ts +++ b/packages/service/src/request-key.ts @@ -14,6 +14,7 @@ */ import { createHash } from "node:crypto"; import type { IncomingHttpHeaders } from "node:http"; +import { parseJson } from "./json-strings.ts"; import { THINKING_BLOCK_TYPES } from "./streams.ts"; import type { RecordingRule } from "@sentry/roach/types"; import { @@ -92,14 +93,6 @@ function stableStringify( .join(",")}}`; } -function parseJson(body: string): unknown { - try { - return body ? JSON.parse(body) : undefined; - } catch { - return undefined; - } -} - /** Marks the place of a thinking block in the text of a body. */ const THINKING_MARK = /"\uE000(\d+)"/g; diff --git a/packages/service/src/secrets.ts b/packages/service/src/secrets.ts index b3d23d1..6ae9417 100644 --- a/packages/service/src/secrets.ts +++ b/packages/service/src/secrets.ts @@ -21,8 +21,10 @@ * or a copy of a value inside other text. */ import type { IncomingHttpHeaders } from "node:http"; +import { httpError } from "./errors.ts"; import { - parseJson, + parseJsonSkipBom, + isObject, isOpen, isJsonSpace, type Escapes, @@ -672,10 +674,6 @@ function bodyStart(text: string, href: string, keyHeaders: string[]): number { return at; } -/** Tells whether a parsed JSON value is an object that is not an array. */ -const isObject = (json: unknown): json is Record => - json !== null && typeof json === "object" && !Array.isArray(json); - /** Tells whether the field `name` of `json` holds a minted value. */ const mintedValue = (json: Record, name: string) => { const value = json[name]; @@ -691,7 +689,7 @@ const mintedValue = (json: Record, name: string) => { * result, so the caller does not record the body. */ function replaceMinted(text: string): { text: string; minted: string[] } { - const json = parseJson(text); + const json = parseJsonSkipBom(text); const none = { text, minted: [] }; if (!isObject(json)) return none; // The fields of the top object that can hold a minted value. @@ -726,7 +724,7 @@ function replaceMinted(text: string): { text: string; minted: string[] } { // Parse the result again, and fail when a minted value stays. The pass // replaces only whole strings with strings, so the names of the fields // do not change. Thus only the names from the first parse can hold one. - const again = parseJson(result); + const again = parseJsonSkipBom(result); if (!isObject(again) || names.some((name) => mintedValue(again, name))) { throw new Error("a minted credential stayed in the body"); } @@ -892,7 +890,7 @@ export function toRedact( */ function bodyFields(request: RequestToRedact) { const form = isForm(request.contentType); - const json = form ? undefined : parseJson(request.body); + const json = form ? undefined : parseJsonSkipBom(request.body); const fields = form ? formFields(request.body) : jsonFields(json); return { form, json, fields, isValue: valueTest(fields.values) }; } @@ -1001,7 +999,7 @@ export function redactRequestWith( } const known = new Set(fields.values); const body = redactWith(values, replaceStrings(result.slice(at), known)); - const parsed = parseJson(body); + const parsed = parseJsonSkipBom(body); if ( parsed === undefined || !showsNoField(parsed, known) || @@ -1045,11 +1043,9 @@ export function createSecrets(initial: string[], limit: LearnLimit) { next.length += part.length; } if (next.values > limit.values || next.length > limit.length) { - throw Object.assign( - new Error( - `this run cannot learn more credentials (${limit.values} values or ${limit.length} characters, with the parts of each value). Start a new run.`, - ), - { status }, + throw httpError( + status, + `this run cannot learn more credentials (${limit.values} values or ${limit.length} characters, with the parts of each value). Start a new run.`, ); } learned = next; diff --git a/packages/service/src/usage.ts b/packages/service/src/usage.ts index a1f8388..338eee6 100644 --- a/packages/service/src/usage.ts +++ b/packages/service/src/usage.ts @@ -15,26 +15,16 @@ * As in Sentry, `gen_ai.usage.input_tokens` includes the cache tokens. * Anthropic counts them apart, so the proxy adds them. */ +import { isObject, parseJson } from "./json-strings.ts"; import { isEventStream } from "./streams.ts"; type Fields = Record; -const isObject = (value: unknown): value is Fields => - typeof value === "object" && value !== null && !Array.isArray(value); - const count = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -function parseJson(text: string): unknown { - try { - return JSON.parse(text); - } catch { - return undefined; - } -} - /** The JSON objects of a body: the body itself, or each stream event. */ function bodyObjects(contentType: string | undefined, body: string): Fields[] { const values = isEventStream(contentType)