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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
5 changes: 1 addition & 4 deletions packages/service/src/bodies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,14 @@
* 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
* `POST /runs`, so the service reads no more before it checks the token.
*/
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
Expand Down
15 changes: 12 additions & 3 deletions packages/service/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
30 changes: 21 additions & 9 deletions packages/service/src/json-strings.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
json !== null && typeof json === "object" && !Array.isArray(json);

const QUOTE = 0x22;

const BACKSLASH = 0x5c;
Expand Down
20 changes: 8 additions & 12 deletions packages/service/src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -818,9 +816,7 @@ export function createRecorder(config: RecorderConfig, store: RecordingStore) {
async endSession(name: string, passed: boolean): Promise<number> {
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);
},
Expand Down
9 changes: 1 addition & 8 deletions packages/service/src/request-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down
24 changes: 10 additions & 14 deletions packages/service/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> =>
json !== null && typeof json === "object" && !Array.isArray(json);

/** Tells whether the field `name` of `json` holds a minted value. */
const mintedValue = (json: Record<string, unknown>, name: string) => {
const value = json[name];
Expand All @@ -691,7 +689,7 @@ const mintedValue = (json: Record<string, unknown>, 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.
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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) };
}
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 1 addition & 11 deletions packages/service/src/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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)
Expand Down
Loading