@shpitdev/convex-cliproxy-observability 0.3.6 → 0.3.8

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -2
  3. package/dist/capture/content-health.d.ts +4 -1
  4. package/dist/capture/content-health.d.ts.map +1 -1
  5. package/dist/capture/content-health.js +6 -5
  6. package/dist/capture/content-health.js.map +1 -1
  7. package/dist/capture/index.d.ts +21 -0
  8. package/dist/capture/index.d.ts.map +1 -1
  9. package/dist/capture/index.js +53 -0
  10. package/dist/capture/index.js.map +1 -1
  11. package/dist/component/_generated/api.d.ts.map +1 -1
  12. package/dist/component/_generated/api.js.map +1 -1
  13. package/dist/component/_generated/component.d.ts.map +1 -1
  14. package/dist/component/ingest.d.ts +1 -0
  15. package/dist/component/ingest.d.ts.map +1 -1
  16. package/dist/projection.d.ts +6 -3
  17. package/dist/projection.d.ts.map +1 -1
  18. package/dist/projection.js +55 -7
  19. package/dist/projection.js.map +1 -1
  20. package/dist/protocols/anthropic-messages.d.ts +5 -0
  21. package/dist/protocols/anthropic-messages.d.ts.map +1 -1
  22. package/dist/protocols/anthropic-messages.js +38 -4
  23. package/dist/protocols/anthropic-messages.js.map +1 -1
  24. package/dist/protocols/body-parts.d.ts +19 -0
  25. package/dist/protocols/body-parts.d.ts.map +1 -0
  26. package/dist/protocols/body-parts.js +56 -0
  27. package/dist/protocols/body-parts.js.map +1 -0
  28. package/dist/protocols/chat-completions.d.ts.map +1 -1
  29. package/dist/protocols/chat-completions.js +16 -4
  30. package/dist/protocols/chat-completions.js.map +1 -1
  31. package/dist/protocols/checkpoint.d.ts +17 -1
  32. package/dist/protocols/checkpoint.d.ts.map +1 -1
  33. package/dist/protocols/checkpoint.js +59 -15
  34. package/dist/protocols/checkpoint.js.map +1 -1
  35. package/dist/protocols/index.d.ts +2 -1
  36. package/dist/protocols/index.d.ts.map +1 -1
  37. package/dist/protocols/index.js +6 -1
  38. package/dist/protocols/index.js.map +1 -1
  39. package/dist/protocols/responses.d.ts +8 -0
  40. package/dist/protocols/responses.d.ts.map +1 -1
  41. package/dist/protocols/responses.js +43 -1
  42. package/dist/protocols/responses.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/capture/content-health.ts +8 -5
  45. package/src/capture/index.ts +74 -0
  46. package/src/component/_generated/api.ts +5 -6
  47. package/src/component/_generated/component.ts +6 -6
  48. package/src/projection.ts +71 -16
  49. package/src/protocols/anthropic-messages.ts +39 -8
  50. package/src/protocols/body-parts.ts +58 -0
  51. package/src/protocols/chat-completions.ts +13 -4
  52. package/src/protocols/checkpoint.ts +64 -14
  53. package/src/protocols/index.ts +8 -1
  54. package/src/protocols/responses.ts +50 -1
@@ -7,6 +7,8 @@ export const contentHealthValidator = v.object({
7
7
  lostBytes: v.number(),
8
8
  unconfirmedBytes: v.number(),
9
9
  unknownLosses: v.number(),
10
+ /** Native 0.2.5: enrolled calls that could not start a scope. Optional for older senders. */
11
+ missedCalls: v.optional(v.number()),
10
12
  streamCallbacks: v.number(),
11
13
  incompleteCalls: v.number(),
12
14
  capacityRejections: v.number(),
@@ -18,12 +20,13 @@ export function validContentHealth(value: unknown): boolean {
18
20
  if (value === undefined) return true;
19
21
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
20
22
  const counters = value as Record<string, unknown>;
23
+ const fields = Object.keys(contentHealthValidator.fields);
21
24
  return (
22
- Object.keys(counters).length === Object.keys(contentHealthValidator.fields).length &&
23
- Object.keys(contentHealthValidator.fields).every((key) => {
24
- const n = counters[key];
25
- return typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
26
- }) &&
25
+ Object.keys(counters).every((key) => fields.includes(key)) &&
26
+ fields.every((key) => key === "missedCalls" || key in counters) &&
27
+ Object.values(counters).every(
28
+ (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0,
29
+ ) &&
27
30
  (counters.retainedChargedBytes as number) <= (counters.peakChargedBytes as number)
28
31
  );
29
32
  }
@@ -2,6 +2,8 @@ import { expandStockHookBlock } from "../protocols/stock-block.js";
2
2
  import type { ExactCorrelation } from "../model-call/index.js";
3
3
  export const MAX_ENVELOPE_BYTES = 2 * 1024 * 1024;
4
4
  export const MAX_CONTENT_BYTES = 1024 * 1024;
5
+ /** One split body spans at most the producer's 256 shared data-record slots. */
6
+ export const MAX_BODY_PARTS = 256;
5
7
  const id = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/;
6
8
  const routes = new Set([
7
9
  "GET /v1/models",
@@ -10,6 +12,20 @@ const routes = new Set([
10
12
  "POST /v1/responses",
11
13
  "POST /v1/chat/completions",
12
14
  ]);
15
+ /**
16
+ * Native 0.2.5 splits a body larger than `MAX_CONTENT_BYTES` into exact ordered
17
+ * parts with consecutive sequences. Every part but the last holds exactly
18
+ * `MAX_CONTENT_BYTES`; `offset` is `index * MAX_CONTENT_BYTES`. Concatenate in
19
+ * index order and verify `totalBytes`/`totalSha256` (see `/protocols`
20
+ * `BodyPartAssembler`). A split stream callback is one stock callback.
21
+ */
22
+ export type BodyPartV1 = {
23
+ index: number;
24
+ count: number;
25
+ offset: number;
26
+ totalBytes: number;
27
+ totalSha256: string;
28
+ };
13
29
  export type CaptureObservationV1 = {
14
30
  schemaVersion: 1;
15
31
  pluginVersion: string;
@@ -49,6 +65,7 @@ export type CaptureObservationV1 = {
49
65
  bodyFraming?:
50
66
  "stock_json_chunk" | "stock_sse_candidate" | "stock_hook_chunk" | "stock_hook_block";
51
67
  stockHookChunkLengths?: number[];
68
+ bodyPart?: BodyPartV1;
52
69
  lostContentBytes?: number;
53
70
  captureIncomplete?: boolean;
54
71
  contentSha256: string;
@@ -141,6 +158,7 @@ export async function validateObservation(
141
158
  "correlationConflicts",
142
159
  "stockChunkIndex",
143
160
  "stockHookChunkLengths",
161
+ "bodyPart",
144
162
  "lostContentBytes",
145
163
  "captureIncomplete",
146
164
  "body",
@@ -300,6 +318,7 @@ export async function validateObservation(
300
318
  )
301
319
  throw new Error("invalid body framing");
302
320
  const body = decodeBody(o);
321
+ if (o.bodyPart !== undefined) validateBodyPart(o, body);
303
322
  if (o.bodyFraming === "stock_hook_block") {
304
323
  if (o.capturePolicy !== "hook-content-block-v1" || o.kind !== "stream_chunk")
305
324
  throw new Error("stock block policy required");
@@ -354,6 +373,53 @@ export async function validateObservation(
354
373
  };
355
374
  }
356
375
 
376
+ const partKinds = new Set(["request", "request_after_auth", "response", "stream_chunk"]);
377
+ function validateBodyPart(o: CaptureObservationV1, body: Uint8Array) {
378
+ const part: unknown = o.bodyPart;
379
+ const keys = ["index", "count", "offset", "totalBytes", "totalSha256"];
380
+ if (
381
+ !part ||
382
+ typeof part !== "object" ||
383
+ Array.isArray(part) ||
384
+ Object.keys(part).length !== keys.length ||
385
+ !keys.every((key) => key in part)
386
+ )
387
+ throw new Error("invalid body part");
388
+ const { index, count, offset, totalBytes, totalSha256 } = part as BodyPartV1;
389
+ if (
390
+ o.capturePolicy !== "hook-content-block-v1" ||
391
+ !partKinds.has(o.kind) ||
392
+ ![index, count, offset, totalBytes].every(Number.isSafeInteger) ||
393
+ count < 2 ||
394
+ count > MAX_BODY_PARTS ||
395
+ index < 0 ||
396
+ index >= count ||
397
+ offset !== index * MAX_CONTENT_BYTES ||
398
+ totalBytes <= (count - 1) * MAX_CONTENT_BYTES ||
399
+ totalBytes > count * MAX_CONTENT_BYTES ||
400
+ body.byteLength !== Math.min(MAX_CONTENT_BYTES, totalBytes - offset) ||
401
+ typeof totalSha256 !== "string" ||
402
+ !/^[a-f0-9]{64}$/.test(totalSha256)
403
+ )
404
+ throw new Error("invalid body part");
405
+ }
406
+ type PartPosition = Pick<CaptureObservationV1, "kind" | "sequence" | "bodyPart">;
407
+ /** True when `next` is the part that must follow `previous` in one split body. */
408
+ export function continuesBodyPart(previous: PartPosition, next: PartPosition) {
409
+ const a = previous.bodyPart;
410
+ const b = next.bodyPart;
411
+ return (
412
+ !!a &&
413
+ !!b &&
414
+ next.kind === previous.kind &&
415
+ next.sequence === previous.sequence + 1 &&
416
+ b.index === a.index + 1 &&
417
+ b.count === a.count &&
418
+ b.totalBytes === a.totalBytes &&
419
+ b.totalSha256 === a.totalSha256
420
+ );
421
+ }
422
+
357
423
  /** One immutable NDJSON segment batches observations: no per-token Convex writes. */
358
424
  export type CaptureSegmentV1 = {
359
425
  schemaVersion: 1;
@@ -424,6 +490,14 @@ export async function validateSegment(
424
490
  )
425
491
  )
426
492
  throw new Error("inconsistent segment lifecycle");
493
+ // Parts of one split body are consecutive; neighbors within a segment must chain.
494
+ for (const [index, o] of observations.entries()) {
495
+ const previous = observations[index - 1];
496
+ if (!previous) continue;
497
+ const open = previous.bodyPart && previous.bodyPart.index < previous.bodyPart.count - 1;
498
+ if ((open || (o.bodyPart && o.bodyPart.index > 0)) && !continuesBodyPart(previous, o))
499
+ throw new Error("body part order mismatch");
500
+ }
427
501
  const callId = await callIdentity(first);
428
502
  const identity = await sha256(
429
503
  new TextEncoder().encode(
@@ -2,11 +2,7 @@
2
2
 
3
3
  import type * as ingest from "../ingest.js";
4
4
  import type * as queries from "../queries.js";
5
- import type {
6
- ApiFromModules,
7
- FilterApi,
8
- FunctionReference,
9
- } from "convex/server";
5
+ import type { ApiFromModules, FilterApi, FunctionReference } from "convex/server";
10
6
  import { anyApi, componentsGeneric } from "convex/server";
11
7
 
12
8
  const fullApi: ApiFromModules<{
@@ -15,5 +11,8 @@ const fullApi: ApiFromModules<{
15
11
  }> = anyApi as any;
16
12
 
17
13
  export const api: FilterApi<typeof fullApi, FunctionReference<any, "public">> = anyApi as any;
18
- export const internal: FilterApi<typeof fullApi, FunctionReference<any, "internal">> = anyApi as any;
14
+ export const internal: FilterApi<
15
+ typeof fullApi,
16
+ FunctionReference<any, "internal">
17
+ > = anyApi as any;
19
18
  export const components = componentsGeneric() as unknown as {};
@@ -10,9 +10,9 @@ type ComponentQuery<Registered, Name extends string | undefined> =
10
10
  : never;
11
11
 
12
12
  type PublicQueries<Module, Name extends string | undefined> = {
13
- [Key in keyof Module as Module[Key] extends RegisteredQuery<"public", any, any>
14
- ? Key
15
- : never]: ComponentQuery<Module[Key], Name>;
13
+ [
14
+ Key in keyof Module as Module[Key] extends RegisteredQuery<"public", any, any> ? Key : never
15
+ ]: ComponentQuery<Module[Key], Name>;
16
16
  };
17
17
 
18
18
  type ComponentMutation<Registered, Name extends string | undefined> =
@@ -21,9 +21,9 @@ type ComponentMutation<Registered, Name extends string | undefined> =
21
21
  : never;
22
22
 
23
23
  type PublicMutations<Module, Name extends string | undefined> = {
24
- [Key in keyof Module as Module[Key] extends RegisteredMutation<"public", any, any>
25
- ? Key
26
- : never]: ComponentMutation<Module[Key], Name>;
24
+ [
25
+ Key in keyof Module as Module[Key] extends RegisteredMutation<"public", any, any> ? Key : never
26
+ ]: ComponentMutation<Module[Key], Name>;
27
27
  };
28
28
 
29
29
  export type ComponentApi<Name extends string | undefined = string | undefined> = {
package/src/projection.ts CHANGED
@@ -1,19 +1,65 @@
1
1
  import type { GenericActionCtx, GenericDataModel } from "convex/server";
2
2
  import type { CliproxyObservability } from "./client.js";
3
3
  import { resolveCallBlob, type PrivateCaptureStorage } from "./content/index.js";
4
- import { sha256, type CaptureObservationV1 } from "./capture/index.js";
5
- import { applyObservation, type ProjectionCheckpoint } from "./protocols/checkpoint.js";
4
+ import { decodeBody, sha256, type CaptureObservationV1 } from "./capture/index.js";
5
+ import {
6
+ MAX_PROJECTED_SPLIT_BYTES,
7
+ applyObservation,
8
+ type ProjectionCheckpoint,
9
+ } from "./protocols/checkpoint.js";
10
+ import { BodyPartAssembler } from "./protocols/body-parts.js";
6
11
  import type { ModelCallV1 } from "./model-call/index.js";
12
+ type ProjectionContext = Pick<GenericActionCtx<GenericDataModel>, "runQuery" | "runMutation">;
13
+ type ProjectionOptions = {
14
+ client: CliproxyObservability;
15
+ storage: PrivateCaptureStorage;
16
+ destinationId: string;
17
+ callId: string;
18
+ };
19
+ const segmentLines = (content: Uint8Array) =>
20
+ new TextDecoder("utf-8", { fatal: true }).decode(content).split("\n").filter(Boolean);
21
+ /**
22
+ * A split body spans several immutable segments and projection pages. Re-read
23
+ * its owned segments from the first part and return the exact verified body.
24
+ */
25
+ async function reassembleSplitBody(
26
+ ctx: ProjectionContext,
27
+ options: ProjectionOptions,
28
+ fromSegment: number,
29
+ final: CaptureObservationV1,
30
+ ): Promise<Uint8Array> {
31
+ const parts = new BodyPartAssembler();
32
+ const first = final.sequence - final.bodyPart!.index;
33
+ let after = fromSegment - 1;
34
+ while (after < final.sequence) {
35
+ const page = await ctx.runQuery(options.client.component.queries.pageEventSegments, {
36
+ destinationId: options.destinationId,
37
+ callId: options.callId,
38
+ afterSequence: after,
39
+ limit: 8,
40
+ });
41
+ if (!page.segments.length || page.segments[0]!.sequence !== after + 1)
42
+ throw new Error("split body segment unavailable");
43
+ for (const segment of page.segments) {
44
+ if (segment.sequence > final.sequence) break;
45
+ const content = await resolveCallBlob(
46
+ options.storage,
47
+ [segment.reference],
48
+ segment.reference,
49
+ );
50
+ for (const line of segmentLines(content)) {
51
+ const o = JSON.parse(line) as CaptureObservationV1;
52
+ if (o.sequence < first || o.sequence > final.sequence) continue;
53
+ const whole = await parts.push(o, decodeBody(o));
54
+ if (whole) return whole;
55
+ }
56
+ after = segment.throughSequence;
57
+ }
58
+ }
59
+ throw new Error("split body incomplete");
60
+ }
7
61
  /** Project at most two immutable segments. Host schedules the next page when `more` is true. */
8
- export async function projectPendingSegments(
9
- ctx: Pick<GenericActionCtx<GenericDataModel>, "runQuery" | "runMutation">,
10
- options: {
11
- client: CliproxyObservability;
12
- storage: PrivateCaptureStorage;
13
- destinationId: string;
14
- callId: string;
15
- },
16
- ) {
62
+ export async function projectPendingSegments(ctx: ProjectionContext, options: ProjectionOptions) {
17
63
  const { client, destinationId, callId } = options;
18
64
  const state = await ctx.runQuery(client.component.queries.getProcessingState, {
19
65
  destinationId,
@@ -51,11 +97,20 @@ export async function projectPendingSegments(
51
97
  [segment.reference],
52
98
  segment.reference,
53
99
  );
54
- for (const line of new TextDecoder("utf-8", { fatal: true })
55
- .decode(content)
56
- .split("\n")
57
- .filter(Boolean))
58
- applyObservation(call, checkpoint, JSON.parse(line) as CaptureObservationV1);
100
+ for (const line of segmentLines(content)) {
101
+ const o = JSON.parse(line) as CaptureObservationV1;
102
+ const part = o.bodyPart;
103
+ if (part?.index === 0) checkpoint.bodyPartSegment = segment.sequence;
104
+ if (part && part.index === part.count - 1) {
105
+ if (checkpoint.bodyPartSegment === undefined) throw new Error("split body start missing");
106
+ const whole =
107
+ part.totalBytes > MAX_PROJECTED_SPLIT_BYTES
108
+ ? null
109
+ : await reassembleSplitBody(ctx, options, checkpoint.bodyPartSegment, o);
110
+ applyObservation(call, checkpoint, o, whole);
111
+ delete checkpoint.bodyPartSegment;
112
+ } else applyObservation(call, checkpoint, o);
113
+ }
59
114
  through = segment.throughSequence;
60
115
  }
61
116
  if (through === state.projectedThroughSequence)
@@ -42,14 +42,46 @@ export function anthropicMessages(messages: unknown): RecordValue[] {
42
42
  content: block.content,
43
43
  is_error: block.is_error,
44
44
  });
45
- } else if (block.type === "text") content.push({ type: "text", text: block.text });
46
- else content.push(block);
45
+ } else content.push(block);
47
46
  }
48
47
  flush();
49
48
  }
50
49
  return result;
51
50
  }
52
51
 
52
+ /** Event-local validation shared by full replay and bounded scalar checkpoints. */
53
+ export function inspectAnthropicEvent(event: RecordValue) {
54
+ const type = event.type;
55
+ let invalid = false;
56
+ let unsupported = ![
57
+ "message_start",
58
+ "message_delta",
59
+ "message_stop",
60
+ "content_block_start",
61
+ "content_block_delta",
62
+ "content_block_stop",
63
+ "ping",
64
+ "error",
65
+ ].includes(String(type));
66
+ if (String(type).startsWith("content_block_")) invalid = count(event.index) === undefined;
67
+ if (type === "content_block_delta") {
68
+ const delta = object(event.delta);
69
+ unsupported ||= ![
70
+ "text_delta",
71
+ "thinking_delta",
72
+ "signature_delta",
73
+ "input_json_delta",
74
+ "citations_delta",
75
+ ].includes(String(delta.type));
76
+ if (delta.type === "citations_delta")
77
+ invalid ||=
78
+ delta.citation === null ||
79
+ typeof delta.citation !== "object" ||
80
+ Array.isArray(delta.citation);
81
+ }
82
+ return { invalid, unsupported };
83
+ }
84
+
53
85
  export function assembleAnthropicStream(events: RecordValue[]) {
54
86
  let response: RecordValue = {};
55
87
  let invalid = false,
@@ -60,6 +92,9 @@ export function assembleAnthropicStream(events: RecordValue[]) {
60
92
  const blocks = new Map<number, RecordValue>();
61
93
  const argumentsByIndex = new Map<number, string>();
62
94
  for (const event of events) {
95
+ const inspected = inspectAnthropicEvent(event);
96
+ invalid ||= inspected.invalid;
97
+ unsupported ||= inspected.unsupported;
63
98
  const index = count(event.index);
64
99
  if (event.type === "message_start") {
65
100
  response = { ...object(event.message) };
@@ -74,12 +109,8 @@ export function assembleAnthropicStream(events: RecordValue[]) {
74
109
  invalid = true;
75
110
  continue;
76
111
  }
77
- if (
78
- !["text_delta", "thinking_delta", "signature_delta", "input_json_delta"].includes(
79
- String(delta.type),
80
- )
81
- )
82
- unsupported = true;
112
+ if (delta.type === "citations_delta" && !inspected.invalid)
113
+ block.citations = [...list(block.citations), delta.citation];
83
114
  for (const key of ["text", "thinking", "signature"]) append(block, key, delta[key]);
84
115
  if (typeof delta.partial_json === "string")
85
116
  argumentsByIndex.set(index, (argumentsByIndex.get(index) ?? "") + delta.partial_json);
@@ -0,0 +1,58 @@
1
+ import {
2
+ MAX_BODY_PARTS,
3
+ MAX_CONTENT_BYTES,
4
+ continuesBodyPart,
5
+ sha256,
6
+ type CaptureObservationV1,
7
+ } from "../capture/index.js";
8
+ import { expandStockHookBlock } from "./stock-block.js";
9
+
10
+ /**
11
+ * Reassembles native 0.2.5 split bodies from validated observations read in
12
+ * sequence order. `push` returns a whole observation's body unchanged, nothing
13
+ * for an earlier part, and the exact original body at the final part once its
14
+ * length and SHA-256 match. Interleaved, missing or reordered parts throw.
15
+ */
16
+ export class BodyPartAssembler {
17
+ #open?: { previous: CaptureObservationV1; body: Uint8Array; written: number };
18
+ /** `maxBytes` bounds the buffer one split body may allocate. */
19
+ constructor(readonly maxBytes = MAX_BODY_PARTS * MAX_CONTENT_BYTES) {}
20
+ /** A split body has started but its final part has not been pushed. */
21
+ get pending(): boolean {
22
+ return this.#open !== undefined;
23
+ }
24
+ async push(o: CaptureObservationV1, body: Uint8Array): Promise<Uint8Array | undefined> {
25
+ const part = o.bodyPart;
26
+ const open = this.#open;
27
+ if (!part) {
28
+ if (open) throw new Error("body part interrupted");
29
+ return body;
30
+ }
31
+ if (part.index === 0) {
32
+ if (open) throw new Error("body part interrupted");
33
+ if (part.totalBytes > this.maxBytes) throw new Error("body part read limit");
34
+ this.#open = { previous: o, body: new Uint8Array(part.totalBytes), written: 0 };
35
+ } else if (!open || !continuesBodyPart(open.previous, o)) {
36
+ throw new Error("body part order mismatch");
37
+ }
38
+ const current = this.#open!;
39
+ if (part.offset !== current.written || body.byteLength > part.totalBytes - current.written)
40
+ throw new Error("body part offset mismatch");
41
+ current.body.set(body, part.offset);
42
+ current.written += body.byteLength;
43
+ current.previous = o;
44
+ if (part.index < part.count - 1) return undefined;
45
+ this.#open = undefined;
46
+ if (current.written !== part.totalBytes || (await sha256(current.body)) !== part.totalSha256)
47
+ throw new Error("body part digest mismatch");
48
+ return current.body;
49
+ }
50
+ }
51
+
52
+ /** Stock callbacks in a block, or the one callback a split stream body carries. */
53
+ export function stockHookPieces(
54
+ o: Pick<CaptureObservationV1, "bodyPart" | "stockHookChunkLengths">,
55
+ body: Uint8Array,
56
+ ): readonly Uint8Array[] {
57
+ return o.bodyPart ? [body] : expandStockHookBlock(body, o.stockHookChunkLengths ?? []);
58
+ }
@@ -11,15 +11,15 @@ export function assembleChatStream(events: RecordValue[], done: boolean, stockCo
11
11
  const tools = new Map<number, Map<number, RecordValue>>();
12
12
  for (const event of events) {
13
13
  if (Array.isArray(event.choices) || event.error) recognized = true;
14
- for (const key of ["id", "model", "object"])
14
+ else unsupported = true;
15
+ for (const key of ["id", "model", "object", "created", "service_tier", "system_fingerprint"])
15
16
  if (event[key] !== undefined) response[key] = event[key];
16
17
  if (event.usage !== undefined && event.usage !== null) response.usage = event.usage;
17
18
  // Final usage is emitted on an empty choices chunk or with finished choices.
18
19
  // A later [DONE] marker cannot turn an earlier running count into final usage.
19
- if (Array.isArray(event.choices))
20
+ if (Array.isArray(event.choices) && (event.choices.length > 0 || event.usage != null))
20
21
  terminalUsage =
21
- event.usage !== undefined &&
22
- event.usage !== null &&
22
+ event.usage != null &&
23
23
  event.choices.every((choice) => typeof object(choice).finish_reason === "string");
24
24
  if (event.error) {
25
25
  response.error = event.error;
@@ -35,6 +35,15 @@ export function assembleChatStream(events: RecordValue[], done: boolean, stockCo
35
35
  const saved = choices.get(index) ?? { index, message: { role: "assistant" } };
36
36
  const message = object(saved.message);
37
37
  const delta = object(choice.delta);
38
+ if (typeof delta.role === "string") message.role = delta.role;
39
+ if (choice.logprobs !== undefined && choice.logprobs !== null) {
40
+ const logprobs = object(saved.logprobs);
41
+ for (const key of ["content", "refusal"]) {
42
+ const tokens = object(choice.logprobs)[key];
43
+ if (Array.isArray(tokens)) logprobs[key] = [...list(logprobs[key]), ...tokens];
44
+ }
45
+ saved.logprobs = logprobs;
46
+ }
38
47
  if (
39
48
  Object.keys(delta).some(
40
49
  (key) =>
@@ -1,4 +1,4 @@
1
- import { expandStockHookBlock } from "./stock-block.js";
1
+ import { stockHookPieces } from "./body-parts.js";
2
2
  import {
3
3
  providerIdentity,
4
4
  TOKEN_FIELDS,
@@ -7,8 +7,10 @@ import {
7
7
  type PrivateContentReference,
8
8
  } from "../model-call/index.js";
9
9
  import type { CaptureObservationV1 } from "../capture/index.js";
10
- import { decodeBody } from "../capture/index.js";
11
- import { assembleResponsesStream } from "./responses.js";
10
+ import { continuesBodyPart, decodeBody } from "../capture/index.js";
11
+ import { assembleResponsesStream, observeResponsesEnvelope } from "./responses.js";
12
+ import { assembleChatStream } from "./chat-completions.js";
13
+ import { inspectAnthropicEvent } from "./anthropic-messages.js";
12
14
  import { SSEReader, type SSECheckpoint } from "./framing.js";
13
15
  import { count, list, nanoTime, object, parseObject } from "./values.js";
14
16
  import { extractUsage } from "./usage.js";
@@ -28,6 +30,11 @@ export type ProjectionCheckpoint = {
28
30
  streamed?: boolean;
29
31
  inputSeen?: boolean;
30
32
  chatFinished?: boolean;
33
+ responseId?: string;
34
+ /** First segment of an unfinished split body; projection reassembles from there. */
35
+ bodyPartSegment?: number;
36
+ /** Last part of an unfinished split body; the next observation must continue it. */
37
+ openBodyPart?: Pick<CaptureObservationV1, "kind" | "sequence" | "bodyPart">;
31
38
  };
32
39
  const usageFields = [
33
40
  "input_tokens",
@@ -97,8 +104,17 @@ function observeProtocol(
97
104
  if (stream && protocol === "responses") {
98
105
  // Shared assembler determines whether the event vocabulary is supported; sequence
99
106
  // validation belongs to selected-call content replay, not this scalar checkpoint.
107
+ observeResponsesEnvelope(state, event);
100
108
  if (assembleResponsesStream([event]).unsupported) state.unsupported = true;
101
109
  }
110
+ if (stream && protocol !== "responses") {
111
+ const inspected =
112
+ protocol === "chat_completions"
113
+ ? assembleChatStream([event], false)
114
+ : inspectAnthropicEvent(event);
115
+ state.invalid ||= inspected.invalid;
116
+ state.unsupported ||= inspected.unsupported;
117
+ }
102
118
  if (typeof event.model === "string") call.responseModel = event.model.slice(0, 256);
103
119
  if (protocol === "responses") {
104
120
  const response = stream ? object(event.response) : event;
@@ -186,10 +202,22 @@ function observeProtocol(
186
202
  }
187
203
  }
188
204
  }
205
+ /**
206
+ * Split bodies up to this size are reassembled for scalar projection. Larger ones
207
+ * stay complete in raw segments; projection does not allocate them in a 64 MiB
208
+ * Convex action.
209
+ */
210
+ export const MAX_PROJECTED_SPLIT_BYTES = 8 * 1024 * 1024;
211
+ /**
212
+ * Earlier parts of a split body only advance the sequence. The final part needs
213
+ * the exact reassembled body (`BodyPartAssembler`) as `assembled`, or `null` when
214
+ * the body exceeds `MAX_PROJECTED_SPLIT_BYTES` and its semantics are not projected.
215
+ */
189
216
  export function applyObservation(
190
217
  call: ModelCallV1,
191
218
  state: ProjectionCheckpoint,
192
219
  o: CaptureObservationV1,
220
+ assembled?: Uint8Array | null,
193
221
  ): void {
194
222
  if (o.sequence !== call.capture.projectedThroughSequence + 1)
195
223
  throw new Error("projection sequence gap");
@@ -204,7 +232,25 @@ export function applyObservation(
204
232
  if (!call.capture.gaps.includes(o.gap) && call.capture.gaps.length < 8)
205
233
  call.capture.gaps.push(o.gap.slice(0, 128));
206
234
  }
207
- const body = decodeBody(o);
235
+ const part = o.bodyPart;
236
+ // Segments validate only their own neighbors; continuity across them is checked here.
237
+ if (
238
+ (state.openBodyPart || (part && part.index > 0)) &&
239
+ !(state.openBodyPart && continuesBodyPart(state.openBodyPart, o))
240
+ )
241
+ throw new Error("body part interrupted");
242
+ const deferred = part !== undefined && part.index < part.count - 1;
243
+ state.openBodyPart = deferred
244
+ ? { kind: o.kind, sequence: o.sequence, bodyPart: part }
245
+ : undefined;
246
+ if (part && !deferred && assembled === undefined)
247
+ throw new Error("split body requires reassembly");
248
+ const unprojected = part !== undefined && !deferred && assembled === null;
249
+ if (unprojected && part.totalBytes <= MAX_PROJECTED_SPLIT_BYTES)
250
+ throw new Error("split body within projection bound");
251
+ // Like an over-limit SSE frame: content is retained, its semantics are not decoded.
252
+ if (unprojected && o.kind !== "request") state.invalid = state.frameLimit = true;
253
+ const body = deferred || unprojected ? new Uint8Array() : (assembled ?? decodeBody(o));
208
254
  const decoder = new TextDecoder("utf-8", { fatal: true });
209
255
  // A new after-auth event replaces the selection, including unknown fields.
210
256
  // Stream initialization may omit auth; an explicit selection replaces the pair.
@@ -227,14 +273,21 @@ export function applyObservation(
227
273
  call.correlationConflicts = o.correlationConflicts ?? [];
228
274
  call.traceId = o.correlation?.traceId;
229
275
  call.sourceTraceId = o.sourceTraceId;
230
- try {
231
- const input = parseObject(decoder.decode(body));
232
- state.streamed = input.stream === true;
233
- call.streamed = state.streamed;
234
- } catch {
235
- state.invalid = true;
276
+ if (!deferred && !unprojected) {
277
+ try {
278
+ const input = parseObject(decoder.decode(body));
279
+ state.streamed = input.stream === true;
280
+ call.streamed = state.streamed;
281
+ } catch {
282
+ state.invalid = true;
283
+ }
236
284
  }
237
285
  }
286
+ // An unparsed request (e.g. a split body over the projection bound) takes
287
+ // streaming from the protocol evidence that follows it.
288
+ if (call.streamed === undefined && (o.kind === "stream_init" || o.kind === "stream_chunk"))
289
+ call.streamed = true;
290
+ else if (call.streamed === undefined && o.kind === "response") call.streamed = false;
238
291
  // Derived read fields: reads fill them only for summaries projected before 0.3.0.
239
292
  Object.assign(call, providerIdentity(call), { costProvenance: call.cost.kind });
240
293
  if (o.kind === "response" || o.kind === "stream_chunk" || o.kind === "completion") {
@@ -251,10 +304,7 @@ export function applyObservation(
251
304
  }
252
305
  if ((o.kind === "stream_chunk" || o.kind === "completion") && body.length) {
253
306
  const reader = new SSEReader(state.sse);
254
- const pieces =
255
- o.bodyFraming === "stock_hook_block"
256
- ? expandStockHookBlock(body, o.stockHookChunkLengths ?? [])
257
- : [body];
307
+ const pieces = o.bodyFraming === "stock_hook_block" ? stockHookPieces(o, body) : [body];
258
308
  const frames = pieces.flatMap((piece) =>
259
309
  o.bodyFraming === "stock_hook_chunk" || o.bodyFraming === "stock_hook_block"
260
310
  ? reader.feedStock(piece, call.clientProtocol, o.observedAt)
@@ -10,7 +10,7 @@ export type { SSEFrame, SSECheckpoint } from "./framing.js";
10
10
  export type { CliproxyProjection, ProjectionState } from "./types.js";
11
11
  export { assembleResponsesStream, assembleChatStream, assembleAnthropicStream };
12
12
  export { nanoTime } from "./values.js";
13
- export const PARSER_VERSION = "client-protocol-v2";
13
+ export const PARSER_VERSION = "client-protocol-v3";
14
14
  export function protocolForRoute(route: string): CliproxyProjection["protocol"] {
15
15
  if (route === "POST /v1/messages" || route === "POST /v1/messages/count_tokens")
16
16
  return "anthropic_messages";
@@ -99,6 +99,12 @@ export function projectCapturedPayloads(args: {
99
99
  ? assembleChatStream(events, done, args.complete === true)
100
100
  : assembleAnthropicStream(events);
101
101
  response = assembled.response;
102
+ if (
103
+ "metadataEvents" in assembled &&
104
+ Array.isArray(assembled.metadataEvents) &&
105
+ assembled.metadataEvents.length
106
+ )
107
+ projection.metadata.responseMetadata = assembled.metadataEvents;
102
108
  invalid ||= assembled.invalid;
103
109
  projection.responseState = invalid
104
110
  ? "invalid"
@@ -144,3 +150,4 @@ export function projectCapturedPayloads(args: {
144
150
  }
145
151
 
146
152
  export { expandStockHookBlock } from "./stock-block.js";
153
+ export { BodyPartAssembler, stockHookPieces } from "./body-parts.js";