@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,224 @@
1
+ /** Read contract only: storage and ingestion remain independently owned. */
2
+ export type ModelCallSource = "cliproxy" | "openrouter" | "cliproxy_legacy";
3
+ export type ModelCallState =
4
+ "in_progress" | "succeeded" | "failed" | "aborted" | "rejected" | "interrupted" | "unknown";
5
+ export type EvidenceState = "complete" | "partial" | "unavailable" | "invalid" | "unsupported";
6
+ export type ExactCorrelation = Partial<
7
+ Record<
8
+ | "requestId"
9
+ | "runId"
10
+ | "jobId"
11
+ | "traceId"
12
+ | "rootExecutionId"
13
+ | "opencodeSessionId"
14
+ | "operationId"
15
+ | "stepId"
16
+ | "partId"
17
+ | "attemptId",
18
+ string
19
+ >
20
+ >;
21
+ export type UsageMeasurement = {
22
+ value: number;
23
+ nativeField: string;
24
+ source: "client_protocol" | "openrouter_span";
25
+ scope: "call";
26
+ unit: "tokens";
27
+ finality: "final" | "partial" | "unknown";
28
+ semanticsVersion: string;
29
+ };
30
+ export type PrivateContentReference = {
31
+ key: string;
32
+ sha256: string;
33
+ byteLength: number;
34
+ contentType: string;
35
+ };
36
+ export type ModelCallV1 = {
37
+ schemaVersion: 1;
38
+ source: ModelCallSource;
39
+ callId: string;
40
+ sourceDocumentId: string;
41
+ gateway: "cliproxy" | "openrouter";
42
+ deploymentId?: string;
43
+ environment?: "dev" | "prod";
44
+ route?: string;
45
+ configRevision?: string;
46
+ authType: "unknown";
47
+ destinationId?: string;
48
+ instanceId?: string;
49
+ pluginBootId?: string;
50
+ executionId?: string;
51
+ sourceTraceId?: string;
52
+ traceId?: string;
53
+ spanId?: string;
54
+ parentSpanId?: string;
55
+ requestModel?: string;
56
+ responseModel?: string;
57
+ providerName?: string;
58
+ streamed?: boolean;
59
+ operation: "generation" | "token_count" | "discovery" | "unknown";
60
+ tokenCount?: number;
61
+ clientProtocol?: "anthropic_messages" | "responses" | "chat_completions" | "unknown";
62
+ state: ModelCallState;
63
+ completionOutcome?: string;
64
+ executionStatusCode?: number;
65
+ protocolTerminal?: string;
66
+ finishReason?: string;
67
+ startTimeUnixNano?: string;
68
+ endTimeUnixNano?: string;
69
+ latencyMs?: number;
70
+ timeToFirstByteMs?: number;
71
+ timeToFirstContentMs?: number;
72
+ timingSource: "plugin_observed" | "openrouter_span" | "unavailable";
73
+ correlation: ExactCorrelation;
74
+ correlationConflicts: string[];
75
+ inputTokens?: number;
76
+ outputTokens?: number;
77
+ totalTokens?: number;
78
+ reasoningTokens?: number;
79
+ cachedInputTokens?: number;
80
+ usage: UsageMeasurement[];
81
+ cost:
82
+ | { kind: "unknown" }
83
+ | { kind: "provider_billed" | "proxy_reported"; currency: string; total: string };
84
+ attemptDetail: "unavailable";
85
+ capture: {
86
+ raw: EvidenceState;
87
+ projection: EvidenceState;
88
+ usage: EvidenceState;
89
+ preHook: "unavailable";
90
+ projectedThroughSequence: number;
91
+ persistedThroughSequence: number;
92
+ terminalSequence?: number;
93
+ gaps: string[];
94
+ parserVersion: string;
95
+ pluginVersion?: string;
96
+ redactionVersion?: string;
97
+ lastObservedAt?: string;
98
+ };
99
+ receivedAt: number;
100
+ };
101
+ export type ModelCallSummaryV1 = ModelCallV1;
102
+
103
+ /** Structural input: there is no runtime dependency on the OpenRouter component. */
104
+ export type OpenRouterSpanInput = {
105
+ _id: string;
106
+ receivedAt: number;
107
+ totalCost?: number;
108
+ openrouterUsageCost?: number;
109
+ traceId?: string;
110
+ spanId?: string;
111
+ parentSpanId?: string;
112
+ requestModel?: string;
113
+ responseModel?: string;
114
+ providerName?: string;
115
+ streamed?: boolean;
116
+ startTimeUnixNano?: string;
117
+ endTimeUnixNano?: string;
118
+ finishReason?: string;
119
+ requestId?: string;
120
+ runId?: string;
121
+ jobId?: string;
122
+ rootExecutionId?: string;
123
+ opencodeSessionId?: string;
124
+ inputTokens?: number;
125
+ outputTokens?: number;
126
+ totalTokens?: number;
127
+ reasoningTokens?: number;
128
+ cachedInputTokens?: number;
129
+ };
130
+ export function fromOpenRouterSpan(span: OpenRouterSpanInput): ModelCallV1 {
131
+ const correlation: ExactCorrelation = {};
132
+ for (const field of [
133
+ "requestId",
134
+ "runId",
135
+ "jobId",
136
+ "rootExecutionId",
137
+ "opencodeSessionId",
138
+ ] as const) {
139
+ if (span[field] !== undefined) correlation[field] = span[field];
140
+ }
141
+ const usage: UsageMeasurement[] = [];
142
+ for (const field of [
143
+ "inputTokens",
144
+ "outputTokens",
145
+ "totalTokens",
146
+ "reasoningTokens",
147
+ "cachedInputTokens",
148
+ ] as const) {
149
+ const value = span[field];
150
+ if (value !== undefined && Number.isSafeInteger(value) && value >= 0)
151
+ usage.push({
152
+ value,
153
+ nativeField: field,
154
+ source: "openrouter_span",
155
+ scope: "call",
156
+ unit: "tokens",
157
+ finality: "unknown",
158
+ semanticsVersion: "stored_openrouter_span_v1",
159
+ });
160
+ }
161
+ return {
162
+ schemaVersion: 1,
163
+ source: "openrouter",
164
+ callId: `openrouter:${span._id}`,
165
+ sourceDocumentId: span._id,
166
+ gateway: "openrouter",
167
+ authType: "unknown",
168
+ traceId: span.traceId,
169
+ spanId: span.spanId,
170
+ parentSpanId: span.parentSpanId,
171
+ requestModel: span.requestModel,
172
+ responseModel: span.responseModel,
173
+ providerName: span.providerName,
174
+ streamed: span.streamed,
175
+ operation: "generation",
176
+ state: "unknown",
177
+ finishReason: span.finishReason,
178
+ startTimeUnixNano: span.startTimeUnixNano,
179
+ endTimeUnixNano: span.endTimeUnixNano,
180
+ timingSource: "openrouter_span",
181
+ correlation,
182
+ correlationConflicts: [],
183
+ inputTokens: span.inputTokens,
184
+ outputTokens: span.outputTokens,
185
+ totalTokens: span.totalTokens,
186
+ reasoningTokens: span.reasoningTokens,
187
+ cachedInputTokens: span.cachedInputTokens,
188
+ usage,
189
+ cost:
190
+ typeof (span.totalCost ?? span.openrouterUsageCost) === "number" &&
191
+ Number.isFinite(span.totalCost ?? span.openrouterUsageCost) &&
192
+ (span.totalCost ?? span.openrouterUsageCost)! >= 0
193
+ ? {
194
+ kind: "proxy_reported",
195
+ currency: "USD",
196
+ total: String(span.totalCost ?? span.openrouterUsageCost),
197
+ }
198
+ : { kind: "unknown" },
199
+ attemptDetail: "unavailable",
200
+ capture: {
201
+ raw: "unavailable",
202
+ projection: "partial",
203
+ usage: usage.length ? "partial" : "unavailable",
204
+ preHook: "unavailable",
205
+ projectedThroughSequence: 0,
206
+ persistedThroughSequence: 0,
207
+ gaps: ["stream_events_and_attempt_detail_unavailable"],
208
+ parserVersion: "openrouter-structural-v1",
209
+ },
210
+ receivedAt: span.receivedAt,
211
+ };
212
+ }
213
+ export function fromLegacyCliproxySpan(span: OpenRouterSpanInput): ModelCallV1 {
214
+ const call = fromOpenRouterSpan(span);
215
+ return {
216
+ ...call,
217
+ source: "cliproxy_legacy",
218
+ callId: `cliproxy_legacy:${span._id}`,
219
+ gateway: "cliproxy",
220
+ providerName: undefined,
221
+ timingSource: "unavailable",
222
+ cost: { kind: "unknown" },
223
+ };
224
+ }
@@ -0,0 +1,99 @@
1
+ import type { GenericActionCtx, GenericDataModel } from "convex/server";
2
+ import type { CliproxyObservability } from "./client.js";
3
+ import { resolveCallBlob, type PrivateCaptureStorage } from "./content/index.js";
4
+ import { sha256, type CaptureObservationV1 } from "./capture/index.js";
5
+ import { applyObservation, type ProjectionCheckpoint } from "./protocols/checkpoint.js";
6
+ import type { ModelCallV1 } from "./model-call/index.js";
7
+ /** 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
+ ) {
17
+ const { client, destinationId, callId } = options;
18
+ const state = await ctx.runQuery(client.component.queries.getProcessingState, {
19
+ destinationId,
20
+ callId,
21
+ });
22
+ if (!state) return { progressed: false, more: false };
23
+ try {
24
+ const page = await ctx.runQuery(client.component.queries.pageEventSegments, {
25
+ destinationId,
26
+ callId,
27
+ afterSequence: state.projectedThroughSequence,
28
+ limit: 2,
29
+ });
30
+ const call = JSON.parse(state.summaryJson) as ModelCallV1;
31
+ call.capture.projectedThroughSequence = state.projectedThroughSequence;
32
+ const checkpoint = JSON.parse(state.checkpointJson) as ProjectionCheckpoint;
33
+ if (checkpoint.sseBlob) {
34
+ const stored = await resolveCallBlob(
35
+ options.storage,
36
+ [checkpoint.sseBlob],
37
+ checkpoint.sseBlob,
38
+ );
39
+ checkpoint.sse = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(stored));
40
+ delete checkpoint.sseBlob;
41
+ }
42
+ let through = state.projectedThroughSequence;
43
+ let waitingForGap = false;
44
+ for (const segment of page.segments) {
45
+ if (segment.sequence !== through + 1) {
46
+ waitingForGap = true;
47
+ break;
48
+ }
49
+ const content = await resolveCallBlob(
50
+ options.storage,
51
+ [segment.reference],
52
+ segment.reference,
53
+ );
54
+ for (const line of new TextDecoder("utf-8", { fatal: true })
55
+ .decode(content)
56
+ .split("\n")
57
+ .filter(Boolean))
58
+ applyObservation(call, checkpoint, JSON.parse(line) as CaptureObservationV1);
59
+ through = segment.throughSequence;
60
+ }
61
+ if (through === state.projectedThroughSequence)
62
+ return { progressed: false, more: false, waitingForGap };
63
+ // A frame can exceed Convex's checkpoint budget. Persist pending framing bytes
64
+ // privately; the bounded component checkpoint owns the exact reference.
65
+ if (checkpoint.sse && checkpoint.sse.pendingBase64.length > 16 * 1024) {
66
+ const bytes = new TextEncoder().encode(JSON.stringify(checkpoint.sse));
67
+ const digest = await sha256(bytes);
68
+ const ref = {
69
+ key: `observability/cliproxy/v2/${destinationId}/${state.instanceId}/${callId}/parser/${digest}.json`,
70
+ sha256: digest,
71
+ byteLength: bytes.length,
72
+ contentType: "application/json",
73
+ };
74
+ await options.storage.put(ref, bytes);
75
+ checkpoint.sseBlob = ref;
76
+ delete checkpoint.sse;
77
+ }
78
+ const committed = await ctx.runMutation(client.component.ingest.commitProjection, {
79
+ destinationId,
80
+ callId,
81
+ expectedRevision: state.revision,
82
+ throughSequence: through,
83
+ summaryJson: JSON.stringify(call),
84
+ checkpointJson: JSON.stringify(checkpoint),
85
+ });
86
+ return {
87
+ progressed: committed.committed,
88
+ more: !committed.committed || (!page.done && !waitingForGap),
89
+ waitingForGap,
90
+ };
91
+ } catch (error) {
92
+ await ctx.runMutation(client.component.ingest.recordProjectionFailure, {
93
+ destinationId,
94
+ callId,
95
+ expectedRevision: state.revision,
96
+ });
97
+ throw error;
98
+ }
99
+ }
@@ -0,0 +1,115 @@
1
+ import type { RecordValue } from "./types.js";
2
+ import { append, count, list, object } from "./values.js";
3
+ export function anthropicMessages(messages: unknown): RecordValue[] {
4
+ const result: RecordValue[] = [];
5
+ for (const value of list(messages)) {
6
+ const message = object(value);
7
+ if (!Array.isArray(message.content)) {
8
+ result.push(message);
9
+ continue;
10
+ }
11
+ let content: unknown[] = [];
12
+ let tools: unknown[] = [];
13
+ const flush = () => {
14
+ if (content.length || tools.length)
15
+ result.push({
16
+ role: message.role,
17
+ content,
18
+ ...(tools.length ? { tool_calls: tools } : {}),
19
+ });
20
+ content = [];
21
+ tools = [];
22
+ };
23
+ for (const raw of message.content) {
24
+ const block = object(raw);
25
+ if (block.type === "tool_use") {
26
+ tools.push({
27
+ id: block.id,
28
+ type: "function",
29
+ function: {
30
+ name: block.name,
31
+ arguments:
32
+ typeof block.partial_json === "string"
33
+ ? block.partial_json
34
+ : JSON.stringify(block.input),
35
+ },
36
+ });
37
+ } else if (block.type === "tool_result") {
38
+ flush();
39
+ result.push({
40
+ role: "tool",
41
+ tool_call_id: block.tool_use_id,
42
+ content: block.content,
43
+ is_error: block.is_error,
44
+ });
45
+ } else if (block.type === "text") content.push({ type: "text", text: block.text });
46
+ else content.push(block);
47
+ }
48
+ flush();
49
+ }
50
+ return result;
51
+ }
52
+
53
+ export function assembleAnthropicStream(events: RecordValue[]) {
54
+ let response: RecordValue = {};
55
+ let invalid = false,
56
+ recognized = false,
57
+ unsupported = false,
58
+ terminal = false,
59
+ terminalUsage = false;
60
+ const blocks = new Map<number, RecordValue>();
61
+ const argumentsByIndex = new Map<number, string>();
62
+ for (const event of events) {
63
+ const index = count(event.index);
64
+ if (event.type === "message_start") {
65
+ response = { ...object(event.message) };
66
+ recognized = true;
67
+ terminalUsage = false;
68
+ } else if (event.type === "content_block_start" && index !== undefined)
69
+ blocks.set(index, { ...object(event.content_block) });
70
+ else if (event.type === "content_block_delta" && index !== undefined) {
71
+ const block = blocks.get(index);
72
+ const delta = object(event.delta);
73
+ if (!block) {
74
+ invalid = true;
75
+ continue;
76
+ }
77
+ if (
78
+ !["text_delta", "thinking_delta", "signature_delta", "input_json_delta"].includes(
79
+ String(delta.type),
80
+ )
81
+ )
82
+ unsupported = true;
83
+ for (const key of ["text", "thinking", "signature"]) append(block, key, delta[key]);
84
+ if (typeof delta.partial_json === "string")
85
+ argumentsByIndex.set(index, (argumentsByIndex.get(index) ?? "") + delta.partial_json);
86
+ } else if (event.type === "message_delta") {
87
+ Object.assign(response, object(event.delta));
88
+ // Message start and final delta are snapshots of different fields, not increments.
89
+ response.usage = { ...object(response.usage), ...object(event.usage) };
90
+ terminalUsage =
91
+ typeof object(event.delta).stop_reason === "string" &&
92
+ count(object(event.usage).output_tokens) !== undefined;
93
+ } else if (event.type === "message_stop") terminal = true;
94
+ else if (event.type === "error") {
95
+ response.error = event.error;
96
+ recognized = true;
97
+ terminal = true;
98
+ terminalUsage = false;
99
+ }
100
+ }
101
+ for (const [index, argumentsText] of argumentsByIndex) {
102
+ const block = blocks.get(index);
103
+ if (block) {
104
+ try {
105
+ block.input = JSON.parse(argumentsText);
106
+ } catch {
107
+ block.partial_json = argumentsText;
108
+ invalid ||= terminal;
109
+ }
110
+ }
111
+ }
112
+ response.content = [...blocks.entries()].toSorted(([a], [b]) => a - b).map(([, value]) => value);
113
+
114
+ return { response, invalid, recognized, unsupported, terminal, terminalUsage };
115
+ }
@@ -0,0 +1,94 @@
1
+ import type { RecordValue } from "./types.js";
2
+ import { append, count, list, object } from "./values.js";
3
+ export function assembleChatStream(events: RecordValue[], done: boolean, stockCompleted = false) {
4
+ const response: RecordValue = {};
5
+ let invalid = false,
6
+ recognized = false,
7
+ unsupported = false,
8
+ terminal = false,
9
+ terminalUsage = false;
10
+ const choices = new Map<number, RecordValue>();
11
+ const tools = new Map<number, Map<number, RecordValue>>();
12
+ for (const event of events) {
13
+ if (Array.isArray(event.choices) || event.error) recognized = true;
14
+ for (const key of ["id", "model", "object"])
15
+ if (event[key] !== undefined) response[key] = event[key];
16
+ if (event.usage !== undefined && event.usage !== null) response.usage = event.usage;
17
+ // Final usage is emitted on an empty choices chunk or with finished choices.
18
+ // A later [DONE] marker cannot turn an earlier running count into final usage.
19
+ if (Array.isArray(event.choices))
20
+ terminalUsage =
21
+ event.usage !== undefined &&
22
+ event.usage !== null &&
23
+ event.choices.every((choice) => typeof object(choice).finish_reason === "string");
24
+ if (event.error) {
25
+ response.error = event.error;
26
+ terminalUsage = false;
27
+ }
28
+ for (const raw of list(event.choices)) {
29
+ const choice = object(raw);
30
+ const index = count(choice.index);
31
+ if (index === undefined) {
32
+ invalid = true;
33
+ continue;
34
+ }
35
+ const saved = choices.get(index) ?? { index, message: { role: "assistant" } };
36
+ const message = object(saved.message);
37
+ const delta = object(choice.delta);
38
+ if (
39
+ Object.keys(delta).some(
40
+ (key) =>
41
+ ![
42
+ "role",
43
+ "content",
44
+ "reasoning_content",
45
+ "reasoning",
46
+ "refusal",
47
+ "tool_calls",
48
+ "reasoning_details",
49
+ ].includes(key),
50
+ )
51
+ )
52
+ unsupported = true;
53
+ for (const key of ["content", "reasoning_content", "reasoning", "refusal"])
54
+ append(message, key, delta[key]);
55
+ if (delta.reasoning_details !== undefined)
56
+ message.reasoning_details = [
57
+ ...list(message.reasoning_details),
58
+ ...list(delta.reasoning_details),
59
+ ];
60
+ const byIndex = tools.get(index) ?? new Map<number, RecordValue>();
61
+ for (const rawTool of list(delta.tool_calls)) {
62
+ const tool = object(rawTool);
63
+ const toolIndex = count(tool.index);
64
+ if (toolIndex === undefined) {
65
+ invalid = true;
66
+ continue;
67
+ }
68
+ const savedTool = byIndex.get(toolIndex) ?? { type: "function", function: {} };
69
+ if (tool.id !== undefined) savedTool.id = tool.id;
70
+ if (tool.type !== undefined) savedTool.type = tool.type;
71
+ for (const key of ["name", "arguments"])
72
+ append(object(savedTool.function), key, object(tool.function)[key]);
73
+ byIndex.set(toolIndex, savedTool);
74
+ }
75
+ tools.set(index, byIndex);
76
+ if (byIndex.size)
77
+ message.tool_calls = [...byIndex.entries()]
78
+ .toSorted(([a], [b]) => a - b)
79
+ .map(([, value]) => value);
80
+ if (choice.finish_reason !== undefined && choice.finish_reason !== null)
81
+ saved.finish_reason = choice.finish_reason;
82
+ choices.set(index, saved);
83
+ }
84
+ }
85
+ response.choices = [...choices.entries()].toSorted(([a], [b]) => a - b).map(([, value]) => value);
86
+ terminal =
87
+ done ||
88
+ response.error !== undefined ||
89
+ (stockCompleted &&
90
+ choices.size > 0 &&
91
+ [...choices.values()].every((choice) => typeof choice.finish_reason === "string"));
92
+
93
+ return { response, invalid, recognized, unsupported, terminal, terminalUsage };
94
+ }