@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.
- package/CHANGELOG.md +17 -0
- package/README.md +2 -2
- package/dist/capture/content-health.d.ts +4 -1
- package/dist/capture/content-health.d.ts.map +1 -1
- package/dist/capture/content-health.js +6 -5
- package/dist/capture/content-health.js.map +1 -1
- package/dist/capture/index.d.ts +21 -0
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +53 -0
- package/dist/capture/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/ingest.d.ts +1 -0
- package/dist/component/ingest.d.ts.map +1 -1
- package/dist/projection.d.ts +6 -3
- package/dist/projection.d.ts.map +1 -1
- package/dist/projection.js +55 -7
- package/dist/projection.js.map +1 -1
- package/dist/protocols/anthropic-messages.d.ts +5 -0
- package/dist/protocols/anthropic-messages.d.ts.map +1 -1
- package/dist/protocols/anthropic-messages.js +38 -4
- package/dist/protocols/anthropic-messages.js.map +1 -1
- package/dist/protocols/body-parts.d.ts +19 -0
- package/dist/protocols/body-parts.d.ts.map +1 -0
- package/dist/protocols/body-parts.js +56 -0
- package/dist/protocols/body-parts.js.map +1 -0
- package/dist/protocols/chat-completions.d.ts.map +1 -1
- package/dist/protocols/chat-completions.js +16 -4
- package/dist/protocols/chat-completions.js.map +1 -1
- package/dist/protocols/checkpoint.d.ts +17 -1
- package/dist/protocols/checkpoint.d.ts.map +1 -1
- package/dist/protocols/checkpoint.js +59 -15
- package/dist/protocols/checkpoint.js.map +1 -1
- package/dist/protocols/index.d.ts +2 -1
- package/dist/protocols/index.d.ts.map +1 -1
- package/dist/protocols/index.js +6 -1
- package/dist/protocols/index.js.map +1 -1
- package/dist/protocols/responses.d.ts +8 -0
- package/dist/protocols/responses.d.ts.map +1 -1
- package/dist/protocols/responses.js +43 -1
- package/dist/protocols/responses.js.map +1 -1
- package/package.json +1 -1
- package/src/capture/content-health.ts +8 -5
- package/src/capture/index.ts +74 -0
- package/src/component/_generated/api.ts +5 -6
- package/src/component/_generated/component.ts +6 -6
- package/src/projection.ts +71 -16
- package/src/protocols/anthropic-messages.ts +39 -8
- package/src/protocols/body-parts.ts +58 -0
- package/src/protocols/chat-completions.ts +13 -4
- package/src/protocols/checkpoint.ts +64 -14
- package/src/protocols/index.ts +8 -1
- 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).
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
}
|
package/src/capture/index.ts
CHANGED
|
@@ -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<
|
|
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
|
-
[
|
|
14
|
-
? Key
|
|
15
|
-
|
|
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
|
-
[
|
|
25
|
-
? Key
|
|
26
|
-
|
|
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 {
|
|
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
|
|
55
|
-
.
|
|
56
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
package/src/protocols/index.ts
CHANGED
|
@@ -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-
|
|
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";
|