@sentry/roach 0.2.0 → 0.3.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/src/streams.ts DELETED
@@ -1,115 +0,0 @@
1
- /**
2
- * Recorded event streams (`text/event-stream`).
3
- *
4
- * A model stream sends a tool call or a text in many small deltas, so an id
5
- * that the model repeats can be split over two events. Before the proxy
6
- * writes changing values as placeholders (`values.ts`), it merges the
7
- * deltas of each Anthropic Messages content block into one event. A client
8
- * joins the deltas anyway, so it gets the same content.
9
- *
10
- * Thinking blocks keep their recorded text. A signature covers that text,
11
- * and the provider refuses a changed thinking block in a later request.
12
- * So a later request sends back the values of the recording run in its
13
- * thinking blocks, and `request-key.ts` must not fill placeholders from
14
- * them.
15
- */
16
-
17
- /** The text field of each delta type that the proxy merges. */
18
- const DELTA_FIELDS: Record<string, string> = {
19
- input_json_delta: "partial_json",
20
- text_delta: "text",
21
- thinking_delta: "thinking",
22
- };
23
- /** The content block types of thinking, in a stream and in a request. */
24
- export const THINKING_BLOCK_TYPES = new Set(["redacted_thinking", "thinking"]);
25
- const THINKING_TYPES = new Set([
26
- ...THINKING_BLOCK_TYPES,
27
- "signature_delta",
28
- "thinking_delta",
29
- ]);
30
-
31
- interface StreamEvent {
32
- text: string;
33
- data?: Record<string, unknown>;
34
- }
35
-
36
- /** Whether a content type is an event stream. */
37
- export const isEventStream = (contentType: string | undefined) =>
38
- /^text\/event-stream\b/i.test(contentType ?? "");
39
-
40
- function parseEvent(text: string): StreamEvent {
41
- const line = text.split("\n").find((entry) => entry.startsWith("data:"));
42
- if (!line) return { text };
43
- try {
44
- const data: unknown = JSON.parse(line.slice(5).trim());
45
- return data && typeof data === "object" && !Array.isArray(data)
46
- ? { text, data: data as Record<string, unknown> }
47
- : { text };
48
- } catch {
49
- return { text };
50
- }
51
- }
52
-
53
- /** The type and text field of a delta event that the proxy can merge. */
54
- function mergeableDelta(event: StreamEvent) {
55
- if (event.data?.type !== "content_block_delta") return undefined;
56
- const delta = event.data.delta as Record<string, unknown> | undefined;
57
- const field = DELTA_FIELDS[String(delta?.type)];
58
- if (!delta || !field || typeof delta[field] !== "string") return undefined;
59
- return { delta, field, index: event.data.index };
60
- }
61
-
62
- function splitEvents(body: string): StreamEvent[] {
63
- return body
64
- .replace(/\r\n/g, "\n")
65
- .split(/\n\n+/)
66
- .filter((text) => text.trim() !== "")
67
- .map(parseEvent);
68
- }
69
-
70
- const joinEvents = (events: StreamEvent[]) =>
71
- events.map((event) => `${event.text}\n\n`).join("");
72
-
73
- /** Merge the deltas of each content block into one event. */
74
- export function mergeStreamDeltas(body: string): string {
75
- const events: StreamEvent[] = [];
76
- for (const event of splitEvents(body)) {
77
- const current = mergeableDelta(event);
78
- const previousEvent = events.at(-1);
79
- const previous = previousEvent && mergeableDelta(previousEvent);
80
- if (
81
- current &&
82
- previous &&
83
- previous.index === current.index &&
84
- previous.delta.type === current.delta.type
85
- ) {
86
- previous.delta[previous.field] =
87
- `${previous.delta[previous.field] as string}${current.delta[current.field] as string}`;
88
- previousEvent.text = `event: content_block_delta\ndata: ${JSON.stringify(previousEvent.data)}`;
89
- continue;
90
- }
91
- events.push(event);
92
- }
93
- return joinEvents(events);
94
- }
95
-
96
- function isThinking(event: StreamEvent): boolean {
97
- const delta = event.data?.delta as { type?: unknown } | undefined;
98
- const block = event.data?.content_block as { type?: unknown } | undefined;
99
- return (
100
- THINKING_TYPES.has(String(delta?.type)) ||
101
- THINKING_TYPES.has(String(block?.type))
102
- );
103
- }
104
-
105
- /** Change each event of a stream, but keep thinking events as they are. */
106
- export function mapStreamEvents(
107
- body: string,
108
- change: (text: string) => string,
109
- ): string {
110
- return joinEvents(
111
- splitEvents(body).map((event) =>
112
- isThinking(event) ? event : { text: change(event.text) },
113
- ),
114
- );
115
- }
package/src/usage.ts DELETED
@@ -1,120 +0,0 @@
1
- /**
2
- * The token usage of a model response, as `gen_ai` span attributes.
3
- *
4
- * Sentry calculates the cost of a span in USD (`gen_ai.cost.*`) from these
5
- * attributes and its own price of the model. So Roach needs no prices, and
6
- * the client sends nothing extra. A replayed response has the usage of its
7
- * recording: the cost that the replay saved.
8
- *
9
- * The proxy reads the usage fields of two model APIs: Anthropic Messages
10
- * (`input_tokens`, `cache_read_input_tokens`) and OpenAI Chat Completions
11
- * (`prompt_tokens`, `prompt_tokens_details`). It reads the whole body, or
12
- * each event of a stream. A later event gives the total so far, so the last
13
- * value of each field wins.
14
- *
15
- * As in Sentry, `gen_ai.usage.input_tokens` includes the cache tokens.
16
- * Anthropic counts them apart, so the proxy adds them.
17
- */
18
- import { isEventStream } from "./streams.ts";
19
-
20
- type Fields = Record<string, unknown>;
21
-
22
- const isObject = (value: unknown): value is Fields =>
23
- typeof value === "object" && value !== null && !Array.isArray(value);
24
-
25
- const count = (value: unknown) =>
26
- typeof value === "number" && Number.isFinite(value) && value >= 0
27
- ? value
28
- : undefined;
29
-
30
- function parseJson(text: string): unknown {
31
- try {
32
- return JSON.parse(text);
33
- } catch {
34
- return undefined;
35
- }
36
- }
37
-
38
- /** The JSON objects of a body: the body itself, or each stream event. */
39
- function bodyObjects(contentType: string | undefined, body: string): Fields[] {
40
- const values = isEventStream(contentType)
41
- ? body
42
- .split("\n")
43
- .filter((line) => line.startsWith("data:"))
44
- .map((line) => parseJson(line.slice(5).trim()))
45
- : /json/i.test(contentType ?? "")
46
- ? [parseJson(body)]
47
- : [];
48
- return values.filter(isObject);
49
- }
50
-
51
- /** The model that a request asks for, from its JSON body. */
52
- function requestModel(body: string): string | undefined {
53
- const parsed = parseJson(body);
54
- return isObject(parsed) && typeof parsed.model === "string"
55
- ? parsed.model
56
- : undefined;
57
- }
58
-
59
- /**
60
- * The `gen_ai` attributes of one model call, or `undefined` when the
61
- * response has no usage.
62
- */
63
- export function usageAttributes(
64
- requestBody: string,
65
- contentType: string | undefined,
66
- responseBody: string,
67
- ): Record<string, string | number> | undefined {
68
- let model: string | undefined;
69
- const usage: Fields = {};
70
- for (const object of bodyObjects(contentType, responseBody)) {
71
- // An Anthropic stream sends the model and the first usage in
72
- // `message_start`.
73
- const message = isObject(object.message) ? object.message : object;
74
- if (typeof message.model === "string") model = message.model;
75
- for (const source of [message.usage, object.usage]) {
76
- if (!isObject(source)) continue;
77
- for (const [name, value] of Object.entries(source)) {
78
- if (value !== null && value !== undefined) usage[name] = value;
79
- }
80
- }
81
- }
82
-
83
- const details = (name: string, field: string) =>
84
- isObject(usage[name]) ? count(usage[name][field]) : undefined;
85
- const cacheRead =
86
- count(usage.cache_read_input_tokens) ??
87
- details("prompt_tokens_details", "cached_tokens");
88
- const cacheWrite = count(usage.cache_creation_input_tokens);
89
- const input =
90
- count(usage.prompt_tokens) ??
91
- addCounts(count(usage.input_tokens), cacheRead, cacheWrite);
92
- const output = count(usage.completion_tokens) ?? count(usage.output_tokens);
93
- const reasoning = details("completion_tokens_details", "reasoning_tokens");
94
- if (input === undefined && output === undefined) return undefined;
95
-
96
- const attributes: Record<string, string | number> = {
97
- "gen_ai.operation.name": "chat",
98
- };
99
- // Sentry prices the response model, or the request model without one. A
100
- // gateway can name the model in the response in another way than its
101
- // price list does, so the requested name wins.
102
- const requested = requestModel(requestBody);
103
- if (requested) attributes["gen_ai.request.model"] = requested;
104
- else if (model) attributes["gen_ai.response.model"] = model;
105
- const set = (name: string, value: number | undefined) => {
106
- if (value !== undefined) attributes[`gen_ai.usage.${name}`] = value;
107
- };
108
- set("input_tokens", input);
109
- set("output_tokens", output);
110
- set("cache_read.input_tokens", cacheRead);
111
- set("cache_creation.input_tokens", cacheWrite);
112
- set("reasoning.output_tokens", reasoning);
113
- return attributes;
114
- }
115
-
116
- /** The sum of the counts that are set, or `undefined` when none is. */
117
- function addCounts(...values: Array<number | undefined>): number | undefined {
118
- const set = values.filter((value) => value !== undefined);
119
- return set.length === 0 ? undefined : set.reduce((sum, v) => sum + v, 0);
120
- }