@shpitdev/convex-cliproxy-observability 0.1.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.
Files changed (129) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +201 -0
  3. package/README.md +40 -0
  4. package/dist/capture/index.d.ts +79 -0
  5. package/dist/capture/index.d.ts.map +1 -0
  6. package/dist/capture/index.js +182 -0
  7. package/dist/capture/index.js.map +1 -0
  8. package/dist/client.d.ts +129 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +232 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +12 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +7 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +17 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +3 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +8 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +3 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +21 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +11 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/convex.config.d.ts +4 -0
  29. package/dist/component/convex.config.d.ts.map +1 -0
  30. package/dist/component/convex.config.js +4 -0
  31. package/dist/component/convex.config.js.map +1 -0
  32. package/dist/component/ingest.d.ts +64 -0
  33. package/dist/component/ingest.d.ts.map +1 -0
  34. package/dist/component/ingest.js +309 -0
  35. package/dist/component/ingest.js.map +1 -0
  36. package/dist/component/queries.d.ts +136 -0
  37. package/dist/component/queries.d.ts.map +1 -0
  38. package/dist/component/queries.js +219 -0
  39. package/dist/component/queries.js.map +1 -0
  40. package/dist/component/schema.d.ts +159 -0
  41. package/dist/component/schema.d.ts.map +1 -0
  42. package/dist/component/schema.js +74 -0
  43. package/dist/component/schema.js.map +1 -0
  44. package/dist/content/index.d.ts +8 -0
  45. package/dist/content/index.d.ts.map +1 -0
  46. package/dist/content/index.js +41 -0
  47. package/dist/content/index.js.map +1 -0
  48. package/dist/model-call/index.d.ts +120 -0
  49. package/dist/model-call/index.d.ts.map +1 -0
  50. package/dist/model-call/index.js +96 -0
  51. package/dist/model-call/index.js.map +1 -0
  52. package/dist/projection.d.ts +19 -0
  53. package/dist/projection.d.ts.map +1 -0
  54. package/dist/projection.js +83 -0
  55. package/dist/projection.js.map +1 -0
  56. package/dist/protocols/anthropic-messages.d.ts +11 -0
  57. package/dist/protocols/anthropic-messages.d.ts.map +1 -0
  58. package/dist/protocols/anthropic-messages.js +114 -0
  59. package/dist/protocols/anthropic-messages.js.map +1 -0
  60. package/dist/protocols/chat-completions.d.ts +10 -0
  61. package/dist/protocols/chat-completions.d.ts.map +1 -0
  62. package/dist/protocols/chat-completions.js +89 -0
  63. package/dist/protocols/chat-completions.js.map +1 -0
  64. package/dist/protocols/checkpoint.d.ts +20 -0
  65. package/dist/protocols/checkpoint.d.ts.map +1 -0
  66. package/dist/protocols/checkpoint.js +318 -0
  67. package/dist/protocols/checkpoint.js.map +1 -0
  68. package/dist/protocols/content.d.ts +34 -0
  69. package/dist/protocols/content.d.ts.map +1 -0
  70. package/dist/protocols/content.js +40 -0
  71. package/dist/protocols/content.js.map +1 -0
  72. package/dist/protocols/framing.d.ts +20 -0
  73. package/dist/protocols/framing.d.ts.map +1 -0
  74. package/dist/protocols/framing.js +67 -0
  75. package/dist/protocols/framing.js.map +1 -0
  76. package/dist/protocols/index.d.ts +20 -0
  77. package/dist/protocols/index.d.ts.map +1 -0
  78. package/dist/protocols/index.js +139 -0
  79. package/dist/protocols/index.js.map +1 -0
  80. package/dist/protocols/output.d.ts +4 -0
  81. package/dist/protocols/output.d.ts.map +1 -0
  82. package/dist/protocols/output.js +81 -0
  83. package/dist/protocols/output.js.map +1 -0
  84. package/dist/protocols/responses.d.ts +12 -0
  85. package/dist/protocols/responses.d.ts.map +1 -0
  86. package/dist/protocols/responses.js +189 -0
  87. package/dist/protocols/responses.js.map +1 -0
  88. package/dist/protocols/types.d.ts +27 -0
  89. package/dist/protocols/types.d.ts.map +1 -0
  90. package/dist/protocols/types.js +2 -0
  91. package/dist/protocols/types.js.map +1 -0
  92. package/dist/protocols/usage.d.ts +3 -0
  93. package/dist/protocols/usage.d.ts.map +1 -0
  94. package/dist/protocols/usage.js +27 -0
  95. package/dist/protocols/usage.js.map +1 -0
  96. package/dist/protocols/values.d.ts +10 -0
  97. package/dist/protocols/values.d.ts.map +1 -0
  98. package/dist/protocols/values.js +32 -0
  99. package/dist/protocols/values.js.map +1 -0
  100. package/dist/test.d.ts +155 -0
  101. package/dist/test.d.ts.map +1 -0
  102. package/dist/test.js +8 -0
  103. package/dist/test.js.map +1 -0
  104. package/package.json +90 -0
  105. package/src/capture/index.ts +277 -0
  106. package/src/client.ts +286 -0
  107. package/src/component/_generated/api.ts +19 -0
  108. package/src/component/_generated/component.ts +32 -0
  109. package/src/component/_generated/dataModel.ts +15 -0
  110. package/src/component/_generated/server.ts +43 -0
  111. package/src/component/convex.config.ts +3 -0
  112. package/src/component/ingest.ts +328 -0
  113. package/src/component/queries.ts +248 -0
  114. package/src/component/schema.ts +73 -0
  115. package/src/content/index.ts +53 -0
  116. package/src/model-call/index.ts +224 -0
  117. package/src/projection.ts +99 -0
  118. package/src/protocols/anthropic-messages.ts +115 -0
  119. package/src/protocols/chat-completions.ts +94 -0
  120. package/src/protocols/checkpoint.ts +342 -0
  121. package/src/protocols/content.ts +41 -0
  122. package/src/protocols/framing.ts +62 -0
  123. package/src/protocols/index.ts +140 -0
  124. package/src/protocols/output.ts +78 -0
  125. package/src/protocols/responses.ts +188 -0
  126. package/src/protocols/types.ts +26 -0
  127. package/src/protocols/usage.ts +36 -0
  128. package/src/protocols/values.ts +35 -0
  129. package/src/test.ts +16 -0
@@ -0,0 +1,342 @@
1
+ import type {
2
+ ModelCallV1,
3
+ UsageMeasurement,
4
+ PrivateContentReference,
5
+ } from "../model-call/index.js";
6
+ import type { CaptureObservationV1 } from "../capture/index.js";
7
+ import { decodeBody } from "../capture/index.js";
8
+ import { assembleResponsesStream } from "./responses.js";
9
+ import { SSEReader, type SSECheckpoint } from "./framing.js";
10
+ import { count, list, nanoTime, object, parseObject } from "./values.js";
11
+ import { extractUsage } from "./usage.js";
12
+ import type { CliproxyProjection, RecordValue } from "./types.js";
13
+ /** Bounded semantic state. Emitted text/tools stay in immutable content segments. */
14
+ export type ProjectionCheckpoint = {
15
+ sse?: SSECheckpoint;
16
+ sseBlob?: PrivateContentReference;
17
+ usage?: RecordValue;
18
+ terminal?: string;
19
+ usageFinal?: boolean;
20
+ invalid?: boolean;
21
+ unsupported?: boolean;
22
+ rawGap?: boolean;
23
+ streamed?: boolean;
24
+ inputSeen?: boolean;
25
+ chatFinished?: boolean;
26
+ };
27
+ const usageFields = [
28
+ "input_tokens",
29
+ "output_tokens",
30
+ "total_tokens",
31
+ "prompt_tokens",
32
+ "completion_tokens",
33
+ "cache_read_input_tokens",
34
+ "cache_creation_input_tokens",
35
+ ] as const;
36
+ function boundedUsage(value: unknown): RecordValue {
37
+ const raw = object(value);
38
+ const out: RecordValue = {};
39
+ for (const field of usageFields) {
40
+ if (count(raw[field]) !== undefined) out[field] = raw[field];
41
+ }
42
+ for (const field of [
43
+ "input_tokens_details",
44
+ "output_tokens_details",
45
+ "prompt_tokens_details",
46
+ "completion_tokens_details",
47
+ "cache_creation",
48
+ ]) {
49
+ const detail = object(raw[field]);
50
+ const safe: RecordValue = {};
51
+ for (const name of [
52
+ "cached_tokens",
53
+ "reasoning_tokens",
54
+ "thinking_tokens",
55
+ "ephemeral_5m_input_tokens",
56
+ "ephemeral_1h_input_tokens",
57
+ ]) {
58
+ if (count(detail[name]) !== undefined) safe[name] = detail[name];
59
+ }
60
+ if (Object.keys(safe).length) out[field] = safe;
61
+ }
62
+ return out;
63
+ }
64
+ function semantic(event: RecordValue): boolean {
65
+ const delta = object(event.delta);
66
+ return (
67
+ [delta.text, delta.thinking, delta.partial_json].some(
68
+ (x) => typeof x === "string" && x.length > 0,
69
+ ) ||
70
+ (typeof event.delta === "string" && event.delta.length > 0) ||
71
+ list(event.choices).some((c) => {
72
+ const d = object(object(c).delta);
73
+ return (
74
+ [d.content, d.reasoning_content, d.reasoning].some(
75
+ (x) => typeof x === "string" && x.length > 0,
76
+ ) || list(d.tool_calls).length > 0
77
+ );
78
+ })
79
+ );
80
+ }
81
+ function observeProtocol(
82
+ call: ModelCallV1,
83
+ state: ProjectionCheckpoint,
84
+ event: RecordValue,
85
+ stream: boolean,
86
+ ) {
87
+ const protocol = call.clientProtocol;
88
+ const type = event.type;
89
+ if (stream && protocol === "responses") {
90
+ // Shared assembler determines whether the event vocabulary is supported; sequence
91
+ // validation belongs to selected-call content replay, not this scalar checkpoint.
92
+ if (assembleResponsesStream([event]).unsupported) state.unsupported = true;
93
+ }
94
+ if (typeof event.model === "string") call.responseModel = event.model.slice(0, 256);
95
+ if (protocol === "responses") {
96
+ const response = stream ? object(event.response) : event;
97
+ if (
98
+ !stream ||
99
+ ["response.completed", "response.failed", "response.incomplete"].includes(String(type))
100
+ ) {
101
+ const status = String(response.status);
102
+ if (
103
+ response.object !== "response" ||
104
+ typeof response.id !== "string" ||
105
+ !Array.isArray(response.output) ||
106
+ !["completed", "failed", "incomplete"].includes(status) ||
107
+ (stream && type !== `response.${status}`)
108
+ ) {
109
+ state.invalid = true;
110
+ return;
111
+ }
112
+ if (typeof response.model === "string") call.responseModel = response.model.slice(0, 256);
113
+ state.terminal = status;
114
+ state.usage = response.usage === undefined ? undefined : boundedUsage(response.usage);
115
+ state.usageFinal = response.usage !== undefined;
116
+ call.finishReason = status;
117
+ } else if (
118
+ ["response.created", "response.queued", "response.in_progress"].includes(String(type))
119
+ ) {
120
+ if (response.object !== "response" || typeof response.id !== "string") state.invalid = true;
121
+ else if (typeof response.model === "string")
122
+ call.responseModel = response.model.slice(0, 256);
123
+ } else if (type === "error") {
124
+ state.terminal = "failed";
125
+ state.usageFinal = false;
126
+ }
127
+ } else if (protocol === "anthropic_messages") {
128
+ if (!stream || type === "message_start") {
129
+ const message = stream ? object(event.message) : event;
130
+ if (
131
+ message.type !== "message" ||
132
+ typeof message.id !== "string" ||
133
+ !Array.isArray(message.content)
134
+ ) {
135
+ state.invalid = true;
136
+ return;
137
+ }
138
+ if (typeof message.model === "string") call.responseModel = message.model.slice(0, 256);
139
+ state.usage = boundedUsage(message.usage);
140
+ if (!stream) {
141
+ state.terminal = "completed";
142
+ state.usageFinal = message.usage !== undefined;
143
+ call.finishReason =
144
+ typeof message.stop_reason === "string" ? message.stop_reason : undefined;
145
+ }
146
+ }
147
+ if (type === "message_delta") {
148
+ state.usage = { ...state.usage, ...boundedUsage(event.usage) };
149
+ const reason = object(event.delta).stop_reason;
150
+ state.usageFinal =
151
+ typeof reason === "string" && count(object(event.usage).output_tokens) !== undefined;
152
+ if (typeof reason === "string") call.finishReason = reason;
153
+ }
154
+ if (type === "message_stop") state.terminal = "completed";
155
+ if (type === "error") {
156
+ state.terminal = "failed";
157
+ state.usageFinal = false;
158
+ }
159
+ } else if (protocol === "chat_completions") {
160
+ const choices = list(event.choices);
161
+ const finished = choices.every((x) => typeof object(x).finish_reason === "string");
162
+ if (choices.length) {
163
+ state.chatFinished = finished;
164
+ const reason = object(choices[0]).finish_reason;
165
+ if (typeof reason === "string") call.finishReason = reason;
166
+ }
167
+ if (event.usage !== undefined) {
168
+ state.usage = boundedUsage(event.usage);
169
+ state.usageFinal = Array.isArray(event.choices) && finished;
170
+ }
171
+ if (!stream) {
172
+ state.terminal = event.error ? "failed" : finished ? "completed" : undefined;
173
+ state.usageFinal = state.terminal === "completed" && event.usage !== undefined;
174
+ }
175
+ if (event.error) {
176
+ state.terminal = "failed";
177
+ state.usageFinal = false;
178
+ }
179
+ }
180
+ }
181
+ export function applyObservation(
182
+ call: ModelCallV1,
183
+ state: ProjectionCheckpoint,
184
+ o: CaptureObservationV1,
185
+ ): void {
186
+ if (o.sequence !== call.capture.projectedThroughSequence + 1)
187
+ throw new Error("projection sequence gap");
188
+ if (o.gap) {
189
+ state.rawGap = true;
190
+ if (!call.capture.gaps.includes(o.gap) && call.capture.gaps.length < 8)
191
+ call.capture.gaps.push(o.gap.slice(0, 128));
192
+ }
193
+ const body = decodeBody(o);
194
+ const decoder = new TextDecoder("utf-8", { fatal: true });
195
+ if (o.kind === "request") {
196
+ state.inputSeen = true;
197
+ if (o.requestedModel) call.requestModel = o.requestedModel.slice(0, 256);
198
+ call.correlation = o.correlation ?? {};
199
+ call.correlationConflicts = o.correlationConflicts ?? [];
200
+ call.traceId = o.correlation?.traceId;
201
+ call.sourceTraceId = o.sourceTraceId;
202
+ try {
203
+ const input = parseObject(decoder.decode(body));
204
+ state.streamed = input.stream === true;
205
+ call.streamed = state.streamed;
206
+ } catch {
207
+ state.invalid = true;
208
+ }
209
+ }
210
+ if (o.kind === "response" || o.kind === "stream_chunk" || o.kind === "completion") {
211
+ if (call.timeToFirstByteMs === undefined && (o.observedBodyBytes ?? body.length) > 0)
212
+ call.timeToFirstByteMs = o.offsetNs / 1e6;
213
+ try {
214
+ if (o.kind === "response" && body.length) {
215
+ const event = parseObject(decoder.decode(body));
216
+ if (call.operation === "generation") observeProtocol(call, state, event, false);
217
+ else {
218
+ state.terminal = "completed";
219
+ if (call.operation === "token_count") call.tokenCount = count(event.input_tokens);
220
+ }
221
+ }
222
+ if ((o.kind === "stream_chunk" || o.kind === "completion") && body.length) {
223
+ const reader = new SSEReader(state.sse);
224
+ for (const frame of reader.feed(body, o.observedAt)) {
225
+ if (frame.data === "[DONE]") {
226
+ if (call.clientProtocol === "chat_completions" && state.chatFinished)
227
+ state.terminal = "completed";
228
+ continue;
229
+ }
230
+ const event = parseObject(frame.data);
231
+ if (call.timeToFirstContentMs === undefined && semantic(event))
232
+ call.timeToFirstContentMs = o.offsetNs / 1e6;
233
+ observeProtocol(call, state, event, true);
234
+ }
235
+ state.sse = reader.checkpoint();
236
+ }
237
+ } catch {
238
+ state.invalid = true;
239
+ }
240
+ }
241
+ if (o.kind === "completion") {
242
+ try {
243
+ if (new SSEReader(state.sse).finish().truncated) state.invalid = true;
244
+ } catch {
245
+ state.invalid = true;
246
+ }
247
+ delete state.sse;
248
+ delete state.sseBlob;
249
+ if (
250
+ call.clientProtocol === "chat_completions" &&
251
+ state.chatFinished &&
252
+ o.completionOutcome === "succeeded"
253
+ )
254
+ state.terminal = "completed";
255
+ call.completionOutcome = o.completionOutcome;
256
+ call.executionStatusCode = o.executionStatusCode;
257
+ call.endTimeUnixNano = nanoTime(o.executionCompletedAt);
258
+ const start = nanoTime(o.executionStartedAt);
259
+ if (start) call.startTimeUnixNano = start;
260
+ if (start && call.endTimeUnixNano)
261
+ call.latencyMs = Number(BigInt(call.endTimeUnixNano) - BigInt(start)) / 1e6;
262
+ call.capture.terminalSequence = o.sequence;
263
+ call.state =
264
+ o.completionOutcome === "canceled"
265
+ ? "aborted"
266
+ : o.completionOutcome === "failed"
267
+ ? "failed"
268
+ : o.completionOutcome === "rejected"
269
+ ? "rejected"
270
+ : state.terminal === "completed"
271
+ ? "succeeded"
272
+ : state.terminal === "failed" || state.terminal === "incomplete"
273
+ ? "failed"
274
+ : "unknown";
275
+ }
276
+ call.protocolTerminal = state.terminal;
277
+ const final =
278
+ !!state.terminal && state.usageFinal && !state.invalid && !state.unsupported && !state.rawGap;
279
+ const projection: CliproxyProjection = {
280
+ version: 1,
281
+ protocol: call.clientProtocol ?? "unknown",
282
+ requestState: "absent",
283
+ responseState: final ? "decoded" : "truncated",
284
+ scalars: {},
285
+ metadata: {},
286
+ };
287
+ if (state.usage && call.operation === "generation") {
288
+ extractUsage(projection, state.usage);
289
+ const measurements: UsageMeasurement[] = [];
290
+ for (const field of usageFields) {
291
+ const value = count(state.usage[field]);
292
+ if (value !== undefined)
293
+ measurements.push({
294
+ value,
295
+ nativeField: field,
296
+ source: "client_protocol",
297
+ scope: "call",
298
+ unit: "tokens",
299
+ finality: final ? "final" : "partial",
300
+ semanticsVersion: String(projection.metadata.tokenSemantics),
301
+ });
302
+ }
303
+ for (const [key, detail] of Object.entries(state.usage)) {
304
+ if (!detail || typeof detail !== "object") continue;
305
+ for (const [field, raw] of Object.entries(detail)) {
306
+ const value = count(raw);
307
+ if (value !== undefined)
308
+ measurements.push({
309
+ value,
310
+ nativeField: `${key}.${field}`,
311
+ source: "client_protocol",
312
+ scope: "call",
313
+ unit: "tokens",
314
+ finality: final ? "final" : "partial",
315
+ semanticsVersion: String(projection.metadata.tokenSemantics),
316
+ });
317
+ }
318
+ }
319
+ call.usage = measurements;
320
+ for (const field of [
321
+ "inputTokens",
322
+ "outputTokens",
323
+ "totalTokens",
324
+ "reasoningTokens",
325
+ "cachedInputTokens",
326
+ ] as const)
327
+ call[field] = final ? projection.scalars[field] : undefined;
328
+ }
329
+ call.capture.lastObservedAt = o.observedAt;
330
+ call.capture.projectedThroughSequence = o.sequence;
331
+ call.capture.persistedThroughSequence = o.sequence;
332
+ call.capture.raw =
333
+ call.capture.terminalSequence === o.sequence && !state.rawGap ? "complete" : "partial";
334
+ call.capture.projection = state.invalid
335
+ ? "invalid"
336
+ : state.unsupported
337
+ ? "unsupported"
338
+ : state.terminal
339
+ ? "complete"
340
+ : "partial";
341
+ call.capture.usage = call.usage.length ? (final ? "complete" : "partial") : "unavailable";
342
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from "zod";
2
+ const toolCall = z.object({
3
+ id: z.string(),
4
+ type: z.literal("function"),
5
+ function: z.object({ name: z.string(), arguments: z.string() }),
6
+ });
7
+ const message = z
8
+ .object({
9
+ role: z.string().optional(),
10
+ content: z.unknown().optional(),
11
+ tool_call_id: z.string().optional(),
12
+ tool_calls: z.array(toolCall).optional(),
13
+ })
14
+ .loose();
15
+ const output = z.object({
16
+ format: z.literal("cliproxy_response_v1"),
17
+ protocol: z.enum(["chat_completions", "anthropic_messages", "responses"]),
18
+ completion: z.string(),
19
+ reasoning: z.string(),
20
+ messages: z.array(message),
21
+ native: z.record(z.string(), z.unknown()),
22
+ });
23
+ export type CliproxyOutput = z.infer<typeof output>;
24
+ export function decodeCliproxyOutput(
25
+ raw?: string,
26
+ ):
27
+ | { state: "absent" | "invalid" | "unsupported" | "truncated" }
28
+ | { state: "decoded"; content: CliproxyOutput } {
29
+ if (raw === undefined) return { state: "absent" };
30
+ let value: unknown;
31
+ try {
32
+ value = JSON.parse(raw);
33
+ } catch {
34
+ return { state: "invalid" };
35
+ }
36
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { state: "unsupported" };
37
+ if (!("format" in value) || value.format !== "cliproxy_response_v1")
38
+ return { state: "unsupported" };
39
+ const parsed = output.safeParse(value);
40
+ return parsed.success ? { state: "decoded", content: parsed.data } : { state: "invalid" };
41
+ }
@@ -0,0 +1,62 @@
1
+ /** Incremental SSE framing. Times belong to the observation containing the final byte. */
2
+ export type SSEFrame = { data: string; event?: string; observedAt?: string };
3
+ export type SSECheckpoint = { pendingBase64: string };
4
+ export class SSEReader {
5
+ private pending: Uint8Array;
6
+ constructor(
7
+ checkpoint: SSECheckpoint = { pendingBase64: "" },
8
+ private maxBytes = 1024 * 1024,
9
+ ) {
10
+ this.pending = Uint8Array.from(atob(checkpoint.pendingBase64), (c) => c.charCodeAt(0));
11
+ if (this.pending.length > maxBytes) throw new Error("SSE frame limit");
12
+ }
13
+ feed(bytes: Uint8Array, observedAt?: string): SSEFrame[] {
14
+ const combined = new Uint8Array(this.pending.length + bytes.length);
15
+ combined.set(this.pending);
16
+ combined.set(bytes, this.pending.length);
17
+ const frames: SSEFrame[] = [];
18
+ let start = 0;
19
+ for (let i = 0; i < combined.length; i++) {
20
+ const delimiter =
21
+ combined[i] === 13 &&
22
+ combined[i + 1] === 10 &&
23
+ combined[i + 2] === 13 &&
24
+ combined[i + 3] === 10
25
+ ? 4
26
+ : combined[i] === 10 && combined[i + 1] === 10
27
+ ? 2
28
+ : combined[i] === 13 && combined[i + 1] === 13
29
+ ? 2
30
+ : 0;
31
+ if (i - start > this.maxBytes) throw new Error("SSE frame limit");
32
+ if (!delimiter) continue;
33
+ const block = new TextDecoder("utf-8", { fatal: true }).decode(combined.subarray(start, i));
34
+ const data: string[] = [];
35
+ let event: string | undefined;
36
+ for (const line of block.split(/\r\n|\r|\n/)) {
37
+ if (line.startsWith(":")) continue;
38
+ const colon = line.indexOf(":");
39
+ const field = colon < 0 ? line : line.slice(0, colon);
40
+ const value = (colon < 0 ? "" : line.slice(colon + 1)).replace(/^ /, "");
41
+ if (field === "data") data.push(value);
42
+ else if (field === "event") event = value;
43
+ }
44
+ if (data.length) frames.push({ data: data.join("\n"), event, observedAt });
45
+ i += delimiter - 1;
46
+ start = i + 1;
47
+ }
48
+ this.pending = combined.slice(start);
49
+ if (this.pending.length > this.maxBytes) throw new Error("SSE frame limit");
50
+ return frames;
51
+ }
52
+ finish(): { truncated: boolean } {
53
+ return {
54
+ truncated: new TextDecoder("utf-8", { fatal: true }).decode(this.pending).trim().length > 0,
55
+ };
56
+ }
57
+ checkpoint(): SSECheckpoint {
58
+ let raw = "";
59
+ for (const byte of this.pending) raw += String.fromCharCode(byte);
60
+ return { pendingBase64: btoa(raw) };
61
+ }
62
+ }
@@ -0,0 +1,140 @@
1
+ import { assembleResponsesStream } from "./responses.js";
2
+ import { assembleChatStream } from "./chat-completions.js";
3
+ import { assembleAnthropicStream } from "./anthropic-messages.js";
4
+ import { SSEReader } from "./framing.js";
5
+ import { object, parseObject } from "./values.js";
6
+ import { requestInput, responseOutput } from "./output.js";
7
+ import type { CliproxyProjection, RecordValue } from "./types.js";
8
+ export { SSEReader } from "./framing.js";
9
+ export type { SSEFrame, SSECheckpoint } from "./framing.js";
10
+ export type { CliproxyProjection, ProjectionState } from "./types.js";
11
+ export { assembleResponsesStream, assembleChatStream, assembleAnthropicStream };
12
+ export { nanoTime } from "./values.js";
13
+ export const PARSER_VERSION = "client-protocol-v1";
14
+ export function protocolForRoute(route: string): CliproxyProjection["protocol"] {
15
+ if (route === "POST /v1/messages" || route === "POST /v1/messages/count_tokens")
16
+ return "anthropic_messages";
17
+ if (route === "POST /v1/responses") return "responses";
18
+ if (route === "POST /v1/chat/completions") return "chat_completions";
19
+ return "unknown";
20
+ }
21
+ /** Bounded replay helper for fixtures and selected-call export; never fetches data itself. */
22
+ export function projectCapturedPayloads(args: {
23
+ route: string;
24
+ request?: Uint8Array;
25
+ response?: Uint8Array;
26
+ chunks?: readonly Uint8Array[];
27
+ complete?: boolean;
28
+ }): CliproxyProjection {
29
+ const projection: CliproxyProjection = {
30
+ version: 1,
31
+ protocol: protocolForRoute(args.route),
32
+ requestState: "absent",
33
+ responseState: "absent",
34
+ scalars: {},
35
+ metadata: { parserVersion: PARSER_VERSION, providerIdentity: "unavailable" },
36
+ };
37
+ const decoder = new TextDecoder("utf-8", { fatal: true });
38
+ if (args.request) {
39
+ try {
40
+ const req = parseObject(decoder.decode(args.request));
41
+ projection.input = requestInput(projection.protocol, req);
42
+ projection.requestState = "decoded";
43
+ if (typeof req.model === "string") projection.scalars.requestModel = req.model;
44
+ if (typeof req.stream === "boolean") projection.scalars.streamed = req.stream;
45
+ } catch {
46
+ projection.requestState = "invalid";
47
+ }
48
+ }
49
+ let response: RecordValue = {};
50
+ if (args.response) {
51
+ try {
52
+ response = parseObject(decoder.decode(args.response));
53
+ projection.responseState = "decoded";
54
+ projection.metadata.terminalObserved = true;
55
+ projection.metadata.usageState = response.usage ? "terminal_snapshot" : "absent";
56
+ } catch {
57
+ projection.responseState = "invalid";
58
+ }
59
+ }
60
+ if (args.chunks) {
61
+ const reader = new SSEReader();
62
+ const events: RecordValue[] = [];
63
+ let done = false;
64
+ let invalid = false;
65
+ let bytes = 0;
66
+ for (const chunk of args.chunks) {
67
+ bytes += chunk.byteLength;
68
+ if (bytes > 16 * 1024 * 1024)
69
+ throw new Error("bounded replay limit; use event pages for larger calls");
70
+ try {
71
+ for (const frame of reader.feed(chunk)) {
72
+ if (frame.data === "[DONE]") {
73
+ done = true;
74
+ continue;
75
+ }
76
+ try {
77
+ events.push(parseObject(frame.data));
78
+ } catch {
79
+ invalid = true;
80
+ }
81
+ }
82
+ } catch {
83
+ invalid = true;
84
+ }
85
+ }
86
+ try {
87
+ invalid ||= reader.finish().truncated;
88
+ } catch {
89
+ invalid = true;
90
+ }
91
+ const assembled =
92
+ projection.protocol === "responses"
93
+ ? assembleResponsesStream(events)
94
+ : projection.protocol === "chat_completions"
95
+ ? assembleChatStream(events, done, args.complete === true)
96
+ : assembleAnthropicStream(events);
97
+ response = assembled.response;
98
+ invalid ||= assembled.invalid;
99
+ projection.responseState = invalid
100
+ ? "invalid"
101
+ : !assembled.recognized || assembled.unsupported
102
+ ? "unsupported"
103
+ : assembled.terminal
104
+ ? "decoded"
105
+ : "truncated";
106
+ projection.metadata.terminalObserved = assembled.terminal;
107
+ const final =
108
+ assembled.terminalUsage &&
109
+ assembled.terminal &&
110
+ !invalid &&
111
+ assembled.recognized &&
112
+ !assembled.unsupported;
113
+ projection.metadata.usageState =
114
+ final && response.usage
115
+ ? "terminal_snapshot"
116
+ : response.usage
117
+ ? "partial_snapshot"
118
+ : "absent";
119
+ if (!final) {
120
+ projection.metadata.partialUsage = response.usage;
121
+ delete response.usage;
122
+ }
123
+ }
124
+ if (projection.protocol !== "unknown" && (args.response || args.chunks))
125
+ responseOutput(projection, response);
126
+ if (projection.protocol === "responses" && typeof response.status === "string")
127
+ projection.scalars.statusJson = JSON.stringify({ responseStatus: response.status });
128
+ if (args.route === "POST /v1/messages/count_tokens") {
129
+ projection.metadata.operation = "token_count";
130
+ projection.metadata.tokenCount = object(response).input_tokens;
131
+ projection.scalars.inputTokens = undefined;
132
+ projection.scalars.outputTokens = undefined;
133
+ projection.scalars.totalTokens = undefined;
134
+ }
135
+ if (args.route === "GET /v1/models") {
136
+ projection.metadata.operation = "discovery";
137
+ projection.output = { native: response };
138
+ }
139
+ return projection;
140
+ }
@@ -0,0 +1,78 @@
1
+ import type { CliproxyProjection, RecordValue } from "./types.js";
2
+ import { identity, list, object } from "./values.js";
3
+ import { extractUsage } from "./usage.js";
4
+ import { responsesMessages } from "./responses.js";
5
+ import { anthropicMessages } from "./anthropic-messages.js";
6
+ export function requestInput(
7
+ protocol: CliproxyProjection["protocol"],
8
+ request: RecordValue,
9
+ ): RecordValue {
10
+ if (protocol === "anthropic_messages") {
11
+ const messages = anthropicMessages(request.messages);
12
+ if (request.system !== undefined) messages.unshift({ role: "system", content: request.system });
13
+ return { ...request, messages, nativeMessages: request.messages };
14
+ }
15
+ if (protocol === "responses") {
16
+ const { input, ...rest } = request;
17
+ return {
18
+ ...rest,
19
+ messages: [
20
+ ...(typeof request.instructions === "string"
21
+ ? [{ role: "system", content: request.instructions }]
22
+ : []),
23
+ ...(typeof input === "string" ? [{ role: "user", content: input }] : list(input)),
24
+ ],
25
+ nativeInput: input,
26
+ };
27
+ }
28
+ return request;
29
+ }
30
+ export function responseOutput(projection: CliproxyProjection, response: RecordValue) {
31
+ projection.scalars.responseModel = identity(response.model);
32
+ projection.scalars.generationId = identity(response.id);
33
+ if (projection.responseState === "decoded" && response.usage !== undefined)
34
+ extractUsage(projection, object(response.usage));
35
+ let messages: RecordValue[];
36
+ if (projection.protocol === "chat_completions") {
37
+ const choices = list(response.choices);
38
+ messages = choices.map((raw) => ({
39
+ ...object(object(raw).message),
40
+ choiceIndex: object(raw).index,
41
+ finishReason: object(raw).finish_reason,
42
+ }));
43
+ projection.scalars.finishReason = identity(object(choices[0]).finish_reason);
44
+ } else if (projection.protocol === "responses") {
45
+ messages = responsesMessages(response.output);
46
+ projection.scalars.finishReason = identity(response.status);
47
+ } else {
48
+ messages = anthropicMessages([{ role: "assistant", content: response.content }]);
49
+ projection.scalars.finishReason = identity(response.stop_reason);
50
+ }
51
+ const first = messages[0] ?? {};
52
+ const text: string[] = [];
53
+ const reasoning: string[] = [];
54
+ const selected = projection.protocol === "chat_completions" ? [first] : messages;
55
+ for (const message of selected) {
56
+ if (typeof message.content === "string") text.push(message.content);
57
+ if (typeof message.reasoning_content === "string") reasoning.push(message.reasoning_content);
58
+ if (typeof message.reasoning === "string") reasoning.push(message.reasoning);
59
+ for (const raw of [...list(message.content), ...list(message.summary)]) {
60
+ const block = object(raw);
61
+ if (["text", "output_text"].includes(String(block.type)) && typeof block.text === "string")
62
+ text.push(block.text);
63
+ if (block.type === "thinking" && typeof block.thinking === "string")
64
+ reasoning.push(block.thinking);
65
+ if (block.type === "summary_text" && typeof block.text === "string")
66
+ reasoning.push(block.text);
67
+ }
68
+ }
69
+ projection.output = {
70
+ format: "cliproxy_response_v1",
71
+ protocol: projection.protocol,
72
+ completion: text.join(""),
73
+ reasoning: reasoning.join(""),
74
+ messages,
75
+ // Native blocks preserve signatures, tool results, refusal, unknown content and errors.
76
+ native: response,
77
+ };
78
+ }