caliper-timings 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +10 -3
- package/SPEC.md +16 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/protocol.d.ts +8 -0
- package/dist/protocol.js +4 -0
- package/dist/run.d.ts +1 -0
- package/dist/run.js +44 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — 2026-11-12
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`run.prompt(messages)`: prompt sizes.** Records each message's role and the size of its text in bytes (UTF-8), in order, in the record's new optional `prompt` field (`{ "messages": [{ "role": "system", "bytes": 2310 }, …] }`). Never the text: a role that isn't a lowercase name of up to 32 letters or `_` is recorded as `"?"`, content that isn't a string gets `bytes: null` (never estimated), and problems name the message by number only. At most 1000 messages. Never throws.
|
|
8
|
+
- **SPEC v1: optional `prompt` field** ([Prompt sizes](SPEC.md#prompt-sizes)). Not breaking: a record without it is unchanged. caliper-ai 0.4.0's record reader (from the release commit) was run once on a record with `prompt`: it read the marks and ignored the field.
|
|
9
|
+
- `isValidRole` and the `PromptMessageSize` type are exported.
|
|
10
|
+
- **Known limit:** a role of up to 32 lowercase letters or `_` is recorded as it is, so text an app puts in a role in that form is recorded too. Message content never is.
|
|
11
|
+
- **Tests that `run.prompt()` never carries text or changes the app** (a marker in every part of a prompt; a Proxy that records writes; lists that throw when read; byte-identical responses over HTTP; no reads when unmarked), each checked by breaking the code.
|
|
12
|
+
- **Measured cost of `run.prompt()`:** median 2.5 µs per call for a Quill-sized prompt (8184 bytes), 10.0 µs for 40 messages, on Node 22.22 (Apple M2).
|
|
13
|
+
|
|
3
14
|
## 0.1.0 — 2026-11-04
|
|
4
15
|
|
|
5
16
|
### Added
|
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ createServer(async (req, res) => {
|
|
|
52
52
|
run?.mark("dbStart");
|
|
53
53
|
const row = await db.query(/* … */);
|
|
54
54
|
run?.mark("dbEnd");
|
|
55
|
+
const messages = [{ role: "system", content: SYSTEM }, { role: "user", content: row.text }];
|
|
56
|
+
run?.prompt(messages); // each message's role and size in bytes, never its text
|
|
55
57
|
run?.mark("modelCallSent");
|
|
56
58
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
57
59
|
for await (const chunk of model.stream(/* … */)) res.write(chunk); // the first write with bytes: firstChunk
|
|
@@ -65,6 +67,7 @@ createServer(async (req, res) => {
|
|
|
65
67
|
- **`handle(req, res)`** answers `GET /__caliper/timings/:id` in SPEC.md's check order and returns `true`. For any other request it returns `false` and doesn't touch the response. With no token set it returns `false` for every request, so your own 404 answers.
|
|
66
68
|
- **`start(req, res)`** returns a `CaliperRun` only when the request carries a valid `x-caliper-run` and the right `x-caliper-token`. Otherwise it returns `null` and the request and response are left exactly as they were. Call it first thing in the route: marks are measured from the moment `start()` was called.
|
|
67
69
|
- **`run.mark(name)`** records ms since then. Name spans with `<name>Start` / `<name>End` (`dbStart` / `dbEnd`); Caliper shows each pair as a span called `<name>`. `modelCallSent` and `firstChunk` are reserved. A name outside `^[A-Za-z][A-Za-z0-9_]{0,63}$` is skipped, not thrown, and Caliper prints it under "problems reported by the server".
|
|
70
|
+
- **`run.prompt(messages)`** records each message's role and its size in bytes (UTF-8), in order, for Caliper's prompt sizes. Pass the same list you send to the model, as `{ role, content }` objects, just before the model call. With a separate system text (Anthropic's API), pass it as the first message. It never records the text. A role that isn't a lowercase name (`system`, `user`, `assistant`, …) is recorded as `"?"`, and content that isn't a string gets no size (`null`), with a problem saying which message, by number. It reads the list and never changes it. A later call replaces an earlier one. See [SPEC.md](SPEC.md#prompt-sizes).
|
|
68
71
|
- **When the record is saved:** at the first `res.write` with bytes, with `firstChunk` marked then. Otherwise when the response closes, whether it finished or the client left. `status` is `res.statusCode`, or `null` if no headers were sent. Every write goes through unchanged.
|
|
69
72
|
|
|
70
73
|
### It never changes your app
|
|
@@ -88,11 +91,11 @@ A marked request succeeds or fails exactly as it would unmarked; that's the prot
|
|
|
88
91
|
|
|
89
92
|
The core works in any runtime with Web Streams and `crypto.subtle`. It exports:
|
|
90
93
|
|
|
91
|
-
- `CaliperRun`, with `mark`, `save`, and `watchFirstChunk(stream)` for frameworks that return a `ReadableStream`;
|
|
94
|
+
- `CaliperRun`, with `mark`, `prompt`, `save`, and `watchFirstChunk(stream)` for frameworks that return a `ReadableStream`;
|
|
92
95
|
- `MemoryStore` and the `TimingStore` interface;
|
|
93
|
-
- `tokenMatches`, `isValidRunId`, `isValidMarkName`;
|
|
96
|
+
- `tokenMatches`, `isValidRunId`, `isValidMarkName`, `isValidRole`;
|
|
94
97
|
- the header and path constants;
|
|
95
|
-
- the `TimingRecord`
|
|
98
|
+
- the `TimingRecord` and `PromptMessageSize` types.
|
|
96
99
|
|
|
97
100
|
There's no framework adapter other than Node yet.
|
|
98
101
|
|
|
@@ -101,6 +104,7 @@ There's no framework adapter other than Node yet.
|
|
|
101
104
|
The rule covers status, headers, bytes and errors, not time. This is what the reference implementation (`caliper-timings/node`) measured in time, on [`examples/node-memory`](https://github.com/Jia-A/caliper/tree/main/examples/node-memory): a single-process `node:http` app on 127.0.0.1, Node 22.22, one macOS machine. The client is `examples/node-memory/measure.mjs`, timing on its own clock from sending the request to the first body chunk arriving, one request at a time, each on a new connection.
|
|
102
105
|
|
|
103
106
|
- **Marked requests: about 1.2 ms slower before the first byte.** 20 marked and 20 unmarked requests to the same server, alternating, compared in pairs: the marked one was later by median 1.2 ms (min 0.1, max 4.1). The difference is already there when the headers arrive (paired median 1.4 ms); between headers and first chunk it's 0.1 ms. What causes it hasn't been measured.
|
|
107
|
+
- **`run.prompt()`: microseconds per call, measured on its own.** On Node 22.22 (Apple M2), 50 batches of 1000 calls each: a Quill-sized prompt (2 messages, 8184 bytes) took median 2.5 µs per call (min 2.4, max 4.6), and a 40-message chat (500 bytes each) median 10.0 µs (min 9.7, max 10.5). That's the call alone, not a request's time end to end, and it was measured on one machine, not on Workers. An unmarked request doesn't call it: with `run?.prompt(messages)`, `run` is `null`, so the list isn't even built (tested).
|
|
104
108
|
- **Unmarked requests: no difference measured.** 20 unmarked requests to the wired server (`CALIPER_TOKEN` set) alternated with 20 to the same app before it was wired: paired difference median 0.0 ms (min −2.3, max 5.4).
|
|
105
109
|
|
|
106
110
|
That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stores and loads haven't been measured.
|
|
@@ -114,6 +118,8 @@ That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stor
|
|
|
114
118
|
- **When the record is saved:** at the first write with bytes (an empty write doesn't count), at close for `res.end(body)` replies, and at close with `status: null` when the client leaves before headers.
|
|
115
119
|
- **Never changing the app:** with a store whose `put` throws, or one whose `put` rejects, the marked response is byte-identical to the unmarked one and nothing escapes as an unhandled error. With a broken `get`, the endpoint answers 500 and app requests are untouched. A URL that doesn't parse is left to the app. A `save()` with a throwing store or `defer` neither throws nor rejects. Each of these was checked by removing the guard, and the test then fails.
|
|
116
120
|
- **Marks and mark names:** marks in ms from `start()`. Bad names are skipped and reported in `problems`, each once, at most 20, then one line saying more were dropped. There's no `problems` field when nothing went wrong.
|
|
121
|
+
- **Prompt sizes never carry text, never change the app:** a marker string put in a system prompt, a role, a list of parts and an extra key is nowhere in the record, its problems, the error log or the endpoint's answer. The messages see no write of any kind (a Proxy records every write; none), and a deeply frozen list reads fine. Lists that throw when read (a revoked Proxy, a throwing `length`, item, `role` or `content`, a `toString` that throws, a list containing itself, 100,000 messages) never throw out of `prompt()`, and the record still saves. Over HTTP, a marked request that calls `prompt()` with the marker list, a revoked Proxy or a throwing role gets the same status, headers and bytes as an unmarked one, with nothing escaping. An unmarked request reads the messages 0 times. Each was checked by breaking the code (storing the text, removing the try/catch, writing a tag on each message): the matching test then fails.
|
|
122
|
+
- **Prompt sizes (`run.prompt`):** role and UTF-8 bytes per message, in order, matching `TextEncoder` (lone surrogates included); a later call replaces an earlier one; no `prompt` field without a call. Roles that aren't lowercase names are recorded as `"?"`, content that isn't a string as `bytes: null`, and something that isn't a list as no `prompt`, each with a problem naming the message by number. At most 1000 messages.
|
|
117
123
|
- **`runtime`:** `"workers"` only for `navigator.userAgent === "Cloudflare-Workers"`, and absent on Node.
|
|
118
124
|
- **`watchFirstChunk`:** chunks unchanged, saved once.
|
|
119
125
|
- **A failed save** is logged and not thrown.
|
|
@@ -121,6 +127,7 @@ That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stor
|
|
|
121
127
|
|
|
122
128
|
## Known limits
|
|
123
129
|
|
|
130
|
+
- **Prompt sizes: a role can still carry a little text.** `run.prompt()` keeps a role that is up to 32 lowercase letters or `_` as it is, so an app that puts text there (`ignoreallpreviousinstructions`) has it recorded and shown. Anything with a space, a capital, a digit or punctuation is recorded as `"?"`. Message content is never recorded.
|
|
124
131
|
- **Saved once, at the first chunk.** Marks made after the first chunk of a streamed reply aren't in the record.
|
|
125
132
|
- **`res.end(body)` doesn't mark `firstChunk`.** A reply sent only that way gets no `firstChunk` mark; only `res.write` is watched.
|
|
126
133
|
- **One process only.** `MemoryStore` is for single-process servers. There's no shared store for Node yet. The Durable Object store for Workers comes with the Hono adapter.
|
package/SPEC.md
CHANGED
|
@@ -17,6 +17,7 @@ A failure on Caliper's own read of the timings endpoint answers that request wit
|
|
|
17
17
|
The rule covers status, headers, bytes and errors, not time. This is what the reference implementation (`caliper-timings/node`) measured in time, on [`examples/node-memory`](https://github.com/Jia-A/caliper/tree/main/examples/node-memory): a single-process `node:http` app on 127.0.0.1, Node 22.22, one macOS machine. The client is `examples/node-memory/measure.mjs`, timing on its own clock from sending the request to the first body chunk arriving, one request at a time, each on a new connection.
|
|
18
18
|
|
|
19
19
|
- **Marked requests: about 1.2 ms slower before the first byte.** 20 marked and 20 unmarked requests to the same server, alternating, compared in pairs: the marked one was later by median 1.2 ms (min 0.1, max 4.1). The difference is already there when the headers arrive (paired median 1.4 ms); between headers and first chunk it's 0.1 ms. What causes it hasn't been measured.
|
|
20
|
+
- **`run.prompt()`: microseconds per call, measured on its own.** On Node 22.22 (Apple M2), 50 batches of 1000 calls each: a Quill-sized prompt (2 messages, 8184 bytes) took median 2.5 µs per call (min 2.4, max 4.6), and a 40-message chat (500 bytes each) median 10.0 µs (min 9.7, max 10.5). That's the call alone, not a request's time end to end, and it was measured on one machine, not on Workers. An unmarked request doesn't call it: with `run?.prompt(messages)`, `run` is `null`, so the list isn't even built (tested).
|
|
20
21
|
- **Unmarked requests: no difference measured.** 20 unmarked requests to the wired server (`CALIPER_TOKEN` set) alternated with 20 to the same app before it was wired: paired difference median 0.0 ms (min −2.3, max 5.4).
|
|
21
22
|
|
|
22
23
|
That is one app, one machine and 20 pairs each. Other runtimes, frameworks, stores and loads haven't been measured.
|
|
@@ -48,7 +49,7 @@ A mark is a name and the number of milliseconds since the server's route receive
|
|
|
48
49
|
|
|
49
50
|
- **Names** are case-sensitive and match `^[A-Za-z][A-Za-z0-9_]{0,63}$`: a letter, then up to 63 letters, digits or `_`. A mark with any other name is skipped, never thrown, and reported in `problems`.
|
|
50
51
|
- **Spans** are chosen by the app. Any pair `<name>Start` / `<name>End` is one span, called `<name>`: `dbStart` / `dbEnd` is the span `db`, and `vectorSearchStart` / `vectorSearchEnd` is the span `vectorSearch`. `<name>` must be at least one character, and the suffix must be exactly `Start` or `End`.
|
|
51
|
-
- **Reserved:** `modelCallSent` (just before the model call is sent) and `firstChunk` (the first response chunk with bytes leaves the server). Caliper shows the span between them as "model
|
|
52
|
+
- **Reserved:** `modelCallSent` (just before the model call is sent) and `firstChunk` (the first response chunk with bytes leaves the server). Caliper's report shows `firstChunk` as "server: first bytes sent" and the span between them as "model call → first bytes sent". `firstChunk` is the server's first write to the browser: if the app writes anything before the model's reply (a heartbeat, an envelope), it marks that write. Only the labels changed; the field names are as above.
|
|
52
53
|
- **No span** when only one of the pair is recorded, or when `<name>End` is earlier than `<name>Start`. Caliper then shows the marks as they are and says why there's no span. It never estimates one.
|
|
53
54
|
- **A step the request never reached has no mark.** Marks are never zero-filled or estimated. Marking the same name twice keeps the later value.
|
|
54
55
|
- Other names are allowed, and Caliper shows them under their own name. Caliper computes spans only from pairs and the reserved model pair, never between unrelated marks.
|
|
@@ -70,12 +71,26 @@ A mark is a name and the number of milliseconds since the server's route receive
|
|
|
70
71
|
| `status` | the response status, or `null` when it isn't known (e.g. the client left before headers were sent) |
|
|
71
72
|
| `marks` | name → ms, as above; may be empty |
|
|
72
73
|
| `runtime` | optional, see below |
|
|
74
|
+
| `prompt` | optional, added in caliper-timings 0.2.0: the sizes of the prompt's messages, see [Prompt sizes](#prompt-sizes). Leave it out when the app didn't report a prompt. |
|
|
73
75
|
| `problems` | optional: a list of strings, in words, saying what the implementation skipped or couldn't do for this request. The reference writes `mark name "db-start" rejected — use a letter, then up to 63 letters, digits or _ (e.g. dbStart)`, keeps each one once and at most 20, then adds one line saying more were dropped. Leave the field out when there's nothing to report. Caliper prints each one as given, with control characters escaped and anything past 200 characters cut. A `problems` that isn't a list of strings makes Caliper reject the record. |
|
|
74
76
|
|
|
75
77
|
**When it's saved:** for a streamed response, at the first chunk with bytes, so marks made after that aren't in the record. For any other response, when it finishes or the connection closes.
|
|
76
78
|
|
|
77
79
|
Records should expire. The reference keeps them for 10 minutes after the last write.
|
|
78
80
|
|
|
81
|
+
### Prompt sizes
|
|
82
|
+
|
|
83
|
+
`prompt` is `{ "messages": [ { "role": "system", "bytes": 2310 }, { "role": "user", "bytes": 5874 } ] }`: one entry per message the app passed to the model, in order, as the app reported them just before the model call.
|
|
84
|
+
|
|
85
|
+
- **`role`** is the message's role when it matches `^[a-z][a-z_]{0,31}$` (`system`, `user`, `assistant`, `tool`, …), and `"?"` otherwise, so text can never reach the record through it.
|
|
86
|
+
- **`bytes`** is the length of the message's text in UTF-8, or `null` when the text wasn't a string (a list of parts, an image). It's never estimated. It's the size of the text, not of the request to the model, and not tokens.
|
|
87
|
+
- **A role can still carry a little text.** A role is recorded when it matches the pattern above, so up to 32 lowercase letters or `_` that the app puts in a role (`ignoreallpreviousinstructions`) are recorded and shown as they are. Anything with a space, a capital, a digit or punctuation becomes `"?"`.
|
|
88
|
+
- **Never the text.** No part of a message's content is recorded, and `problems` about the prompt name a message by its number only (`prompt message 2: …`), never by its role or text.
|
|
89
|
+
- At most 1000 messages are recorded; `problems` then says how many weren't.
|
|
90
|
+
- When the app passes something that isn't a list, there's no `prompt`, and `problems` says so.
|
|
91
|
+
|
|
92
|
+
Older readers ignore the field, and a record without it is still valid: v1 is unchanged for servers that don't report a prompt.
|
|
93
|
+
|
|
79
94
|
### `runtime`
|
|
80
95
|
|
|
81
96
|
Include it only when the server has detected where it runs. Never guess or default it. The only value defined so far is `"workers"`: Cloudflare Workers, detected by `navigator.userAgent === "Cloudflare-Workers"`. On Workers, `Date.now()` only advances at I/O, so a span with only computation inside can read 0 ms. When the record says `"workers"`, Caliper prints a note saying so. Without the field, Caliper prints no runtime note.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, type TimingRecord, type TimingStore, } from "./protocol.ts";
|
|
1
|
+
export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, isValidRole, type TimingRecord, type TimingStore, type PromptMessageSize, } from "./protocol.ts";
|
|
2
2
|
export { CaliperRun, type RunOptions } from "./run.ts";
|
|
3
3
|
export { MemoryStore } from "./memoryStore.ts";
|
|
4
4
|
export { tokenMatches } from "./token.ts";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The framework-free core: only Date.now(), Web Streams and crypto.subtle.
|
|
2
|
-
export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, } from "./protocol.js";
|
|
2
|
+
export { RUN_HEADER, TOKEN_HEADER, TIMINGS_PATH, RECORD_TTL_MS, isValidRunId, isValidMarkName, isValidRole, } from "./protocol.js";
|
|
3
3
|
export { CaliperRun } from "./run.js";
|
|
4
4
|
export { MemoryStore } from "./memoryStore.js";
|
|
5
5
|
export { tokenMatches } from "./token.js";
|
package/dist/protocol.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ export type TimingRecord = {
|
|
|
7
7
|
status: number | null;
|
|
8
8
|
marks: Record<string, number>;
|
|
9
9
|
problems?: string[];
|
|
10
|
+
prompt?: {
|
|
11
|
+
messages: PromptMessageSize[];
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type PromptMessageSize = {
|
|
15
|
+
role: string;
|
|
16
|
+
bytes: number | null;
|
|
10
17
|
};
|
|
11
18
|
export interface TimingStore {
|
|
12
19
|
put(id: string, record: TimingRecord): Promise<void>;
|
|
@@ -15,3 +22,4 @@ export interface TimingStore {
|
|
|
15
22
|
export declare const RECORD_TTL_MS: number;
|
|
16
23
|
export declare const isValidRunId: (id: string) => boolean;
|
|
17
24
|
export declare const isValidMarkName: (name: unknown) => name is string;
|
|
25
|
+
export declare const isValidRole: (role: unknown) => role is string;
|
package/dist/protocol.js
CHANGED
|
@@ -9,3 +9,7 @@ export const isValidRunId = (id) => RUN_ID.test(id);
|
|
|
9
9
|
// are reserved for the model call.
|
|
10
10
|
const MARK_NAME = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
11
11
|
export const isValidMarkName = (name) => typeof name === "string" && MARK_NAME.test(name);
|
|
12
|
+
// Lowercase letters and _, up to 32: system, user, assistant, tool, function_call. Anything else
|
|
13
|
+
// could be text, so it's never recorded.
|
|
14
|
+
const ROLE = /^[a-z][a-z_]{0,31}$/;
|
|
15
|
+
export const isValidRole = (role) => typeof role === "string" && ROLE.test(role);
|
package/dist/run.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export declare class CaliperRun {
|
|
|
10
10
|
status: number | null;
|
|
11
11
|
constructor(id: string, store: TimingStore, { start, defer }?: RunOptions);
|
|
12
12
|
mark(name: string): void;
|
|
13
|
+
prompt(messages: unknown): void;
|
|
13
14
|
watchFirstChunk<T>(stream: ReadableStream<T>): ReadableStream<T>;
|
|
14
15
|
save(): Promise<void>;
|
|
15
16
|
get savesOnStream(): boolean;
|
package/dist/run.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isValidMarkName } from "./protocol.js";
|
|
1
|
+
import { isValidMarkName, isValidRole } from "./protocol.js";
|
|
2
2
|
// Cloudflare documents navigator.userAgent === "Cloudflare-Workers" as the way
|
|
3
3
|
// to tell code is running on Workers (global_navigator flag, on by default
|
|
4
4
|
// since compatibility date 2022-03-21):
|
|
@@ -9,6 +9,8 @@ export const hasBytes = (chunk) => typeof chunk === "string" ? chunk.length > 0
|
|
|
9
9
|
: ArrayBuffer.isView(chunk) ? chunk.byteLength > 0
|
|
10
10
|
: chunk !== undefined && chunk !== null;
|
|
11
11
|
const MAX_PROBLEMS = 20;
|
|
12
|
+
const MAX_PROMPT_MESSAGES = 1000;
|
|
13
|
+
const utf8 = new TextEncoder();
|
|
12
14
|
// Logged, never thrown: nothing the wrapper does may reach the app's request (SPEC.md).
|
|
13
15
|
function logFailure(err) {
|
|
14
16
|
try {
|
|
@@ -27,6 +29,7 @@ export class CaliperRun {
|
|
|
27
29
|
#start;
|
|
28
30
|
#marks = {};
|
|
29
31
|
#problems = [];
|
|
32
|
+
#prompt = null;
|
|
30
33
|
#store;
|
|
31
34
|
#defer;
|
|
32
35
|
#watchingStream = false;
|
|
@@ -44,6 +47,45 @@ export class CaliperRun {
|
|
|
44
47
|
}
|
|
45
48
|
this.#marks[name] = Date.now() - this.#start;
|
|
46
49
|
}
|
|
50
|
+
// Each message's role and UTF-8 size, never its text; reads the messages and never changes them.
|
|
51
|
+
// Call it just before the model call. A later call replaces an earlier one. Never throws.
|
|
52
|
+
prompt(messages) {
|
|
53
|
+
try {
|
|
54
|
+
if (!Array.isArray(messages)) {
|
|
55
|
+
this.#problem("prompt not recorded: run.prompt() takes a list of { role, content } messages");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const sizes = [];
|
|
59
|
+
const kept = Math.min(messages.length, MAX_PROMPT_MESSAGES);
|
|
60
|
+
for (let i = 0; i < kept; i++)
|
|
61
|
+
sizes.push(this.#size(messages[i], i + 1));
|
|
62
|
+
if (messages.length > kept) {
|
|
63
|
+
this.#problem(`prompt: ${messages.length - kept} more messages not recorded (only the first ${MAX_PROMPT_MESSAGES} are kept)`);
|
|
64
|
+
}
|
|
65
|
+
this.#prompt = sizes;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
this.#problem("prompt not recorded: reading the messages failed");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Problems name the message by number only: its role or text could be anything.
|
|
72
|
+
#size(message, n) {
|
|
73
|
+
let role;
|
|
74
|
+
let content;
|
|
75
|
+
try {
|
|
76
|
+
({ role, content } = (message ?? {}));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
this.#problem(`prompt message ${n}: reading it failed; recorded as "?" with no size`);
|
|
80
|
+
return { role: "?", bytes: null };
|
|
81
|
+
}
|
|
82
|
+
if (!isValidRole(role)) {
|
|
83
|
+
this.#problem(`prompt message ${n}: role isn't a lowercase name of up to 32 letters or _ (e.g. system, user, assistant); recorded as "?"`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof content !== "string")
|
|
86
|
+
this.#problem(`prompt message ${n}: content isn't a string; size not measured`);
|
|
87
|
+
return { role: isValidRole(role) ? role : "?", bytes: typeof content === "string" ? utf8.encode(content).length : null };
|
|
88
|
+
}
|
|
47
89
|
// Each problem once; after MAX_PROBLEMS, one line says the rest were dropped.
|
|
48
90
|
#problem(text) {
|
|
49
91
|
if (this.#problems.includes(text) || this.#problems.length > MAX_PROBLEMS)
|
|
@@ -81,6 +123,7 @@ export class CaliperRun {
|
|
|
81
123
|
status: this.status,
|
|
82
124
|
marks: { ...this.#marks },
|
|
83
125
|
...(this.#problems.length && { problems: [...this.#problems] }),
|
|
126
|
+
...(this.#prompt && { prompt: { messages: this.#prompt.map((m) => ({ ...m })) } }),
|
|
84
127
|
};
|
|
85
128
|
saved = Promise.resolve(this.#store.put(this.id, record)).catch(logFailure);
|
|
86
129
|
}
|
package/package.json
CHANGED