@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +201 -0
- package/README.md +40 -0
- package/dist/capture/index.d.ts +79 -0
- package/dist/capture/index.d.ts.map +1 -0
- package/dist/capture/index.js +182 -0
- package/dist/capture/index.js.map +1 -0
- package/dist/client.d.ts +129 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +232 -0
- package/dist/client.js.map +1 -0
- package/dist/component/_generated/api.d.ts +12 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +7 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +17 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +3 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +8 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +3 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +21 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +11 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +4 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +4 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/ingest.d.ts +64 -0
- package/dist/component/ingest.d.ts.map +1 -0
- package/dist/component/ingest.js +309 -0
- package/dist/component/ingest.js.map +1 -0
- package/dist/component/queries.d.ts +136 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +219 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +159 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +74 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/content/index.d.ts +8 -0
- package/dist/content/index.d.ts.map +1 -0
- package/dist/content/index.js +41 -0
- package/dist/content/index.js.map +1 -0
- package/dist/model-call/index.d.ts +120 -0
- package/dist/model-call/index.d.ts.map +1 -0
- package/dist/model-call/index.js +96 -0
- package/dist/model-call/index.js.map +1 -0
- package/dist/projection.d.ts +19 -0
- package/dist/projection.d.ts.map +1 -0
- package/dist/projection.js +83 -0
- package/dist/projection.js.map +1 -0
- package/dist/protocols/anthropic-messages.d.ts +11 -0
- package/dist/protocols/anthropic-messages.d.ts.map +1 -0
- package/dist/protocols/anthropic-messages.js +114 -0
- package/dist/protocols/anthropic-messages.js.map +1 -0
- package/dist/protocols/chat-completions.d.ts +10 -0
- package/dist/protocols/chat-completions.d.ts.map +1 -0
- package/dist/protocols/chat-completions.js +89 -0
- package/dist/protocols/chat-completions.js.map +1 -0
- package/dist/protocols/checkpoint.d.ts +20 -0
- package/dist/protocols/checkpoint.d.ts.map +1 -0
- package/dist/protocols/checkpoint.js +318 -0
- package/dist/protocols/checkpoint.js.map +1 -0
- package/dist/protocols/content.d.ts +34 -0
- package/dist/protocols/content.d.ts.map +1 -0
- package/dist/protocols/content.js +40 -0
- package/dist/protocols/content.js.map +1 -0
- package/dist/protocols/framing.d.ts +20 -0
- package/dist/protocols/framing.d.ts.map +1 -0
- package/dist/protocols/framing.js +67 -0
- package/dist/protocols/framing.js.map +1 -0
- package/dist/protocols/index.d.ts +20 -0
- package/dist/protocols/index.d.ts.map +1 -0
- package/dist/protocols/index.js +139 -0
- package/dist/protocols/index.js.map +1 -0
- package/dist/protocols/output.d.ts +4 -0
- package/dist/protocols/output.d.ts.map +1 -0
- package/dist/protocols/output.js +81 -0
- package/dist/protocols/output.js.map +1 -0
- package/dist/protocols/responses.d.ts +12 -0
- package/dist/protocols/responses.d.ts.map +1 -0
- package/dist/protocols/responses.js +189 -0
- package/dist/protocols/responses.js.map +1 -0
- package/dist/protocols/types.d.ts +27 -0
- package/dist/protocols/types.d.ts.map +1 -0
- package/dist/protocols/types.js +2 -0
- package/dist/protocols/types.js.map +1 -0
- package/dist/protocols/usage.d.ts +3 -0
- package/dist/protocols/usage.d.ts.map +1 -0
- package/dist/protocols/usage.js +27 -0
- package/dist/protocols/usage.js.map +1 -0
- package/dist/protocols/values.d.ts +10 -0
- package/dist/protocols/values.d.ts.map +1 -0
- package/dist/protocols/values.js +32 -0
- package/dist/protocols/values.js.map +1 -0
- package/dist/test.d.ts +155 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +8 -0
- package/dist/test.js.map +1 -0
- package/package.json +90 -0
- package/src/capture/index.ts +277 -0
- package/src/client.ts +286 -0
- package/src/component/_generated/api.ts +19 -0
- package/src/component/_generated/component.ts +32 -0
- package/src/component/_generated/dataModel.ts +15 -0
- package/src/component/_generated/server.ts +43 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/ingest.ts +328 -0
- package/src/component/queries.ts +248 -0
- package/src/component/schema.ts +73 -0
- package/src/content/index.ts +53 -0
- package/src/model-call/index.ts +224 -0
- package/src/projection.ts +99 -0
- package/src/protocols/anthropic-messages.ts +115 -0
- package/src/protocols/chat-completions.ts +94 -0
- package/src/protocols/checkpoint.ts +342 -0
- package/src/protocols/content.ts +41 -0
- package/src/protocols/framing.ts +62 -0
- package/src/protocols/index.ts +140 -0
- package/src/protocols/output.ts +78 -0
- package/src/protocols/responses.ts +188 -0
- package/src/protocols/types.ts +26 -0
- package/src/protocols/usage.ts +36 -0
- package/src/protocols/values.ts +35 -0
- 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
|
+
}
|