@juspay/neurolink 12.12.5 → 12.12.7
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 +2 -2
- package/dist/browser/neurolink.min.js +399 -401
- package/dist/cli/commands/proxy.js +62 -24
- package/dist/cli/commands/proxyAnalyze.js +4 -1
- package/dist/core/baseProvider.d.ts +10 -0
- package/dist/core/baseProvider.js +12 -2
- package/dist/middleware/builtin/guardrails.js +23 -13
- package/dist/middleware/utils/guardrailsUtils.d.ts +3 -15
- package/dist/middleware/utils/guardrailsUtils.js +14 -7
- package/dist/middleware/wrapLanguageModel.d.ts +3 -4
- package/dist/middleware/wrapLanguageModel.js +3 -4
- package/dist/providers/openaiChatCompletionsBase.js +214 -24
- package/dist/proxy/codexUsage.d.ts +2 -1
- package/dist/proxy/codexUsage.js +82 -33
- package/dist/proxy/proxyActivity.d.ts +4 -1
- package/dist/proxy/proxyActivity.js +40 -14
- package/dist/proxy/proxyAnalysis.js +184 -59
- package/dist/proxy/proxyLifecycle.d.ts +1 -1
- package/dist/proxy/proxyLifecycle.js +54 -8
- package/dist/proxy/requestLogger.d.ts +2 -1
- package/dist/proxy/requestLogger.js +87 -29
- package/dist/proxy/sseInterceptor.js +36 -18
- package/dist/proxy/streamOutcome.d.ts +1 -1
- package/dist/proxy/streamOutcome.js +7 -1
- package/dist/server/routes/claudeProxyRoutes.js +70 -16
- package/dist/server/routes/codexProxyRoutes.js +73 -8
- package/dist/types/proxy.d.ts +69 -3
- package/package.json +3 -1
|
@@ -66,6 +66,73 @@ const yieldsSchemaValidObject = (text, schema) => {
|
|
|
66
66
|
const coerced = coerceJsonToSchema(text, schema);
|
|
67
67
|
return coerced !== null && schemaAccepts(schema, coerced.structuredData);
|
|
68
68
|
};
|
|
69
|
+
// Pull one native chunk at a time and forward cancellation to its iterator.
|
|
70
|
+
const chunksToV3Stream = (source, completion, cancel) => {
|
|
71
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
72
|
+
return new ReadableStream({
|
|
73
|
+
async pull(controller) {
|
|
74
|
+
try {
|
|
75
|
+
const next = await iterator.next();
|
|
76
|
+
if (next.done) {
|
|
77
|
+
controller.enqueue(await completion);
|
|
78
|
+
controller.close();
|
|
79
|
+
}
|
|
80
|
+
else if (next.value.reasoning) {
|
|
81
|
+
controller.enqueue({
|
|
82
|
+
type: "reasoning-delta",
|
|
83
|
+
delta: next.value.reasoning,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
controller.enqueue({ type: "text-delta", delta: next.value.content });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
controller.error(error);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
async cancel() {
|
|
95
|
+
cancel();
|
|
96
|
+
await iterator.return?.();
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
async function* v3StreamToChunks(stream, onFinish) {
|
|
101
|
+
const reader = stream.getReader();
|
|
102
|
+
let done = false;
|
|
103
|
+
try {
|
|
104
|
+
while (true) {
|
|
105
|
+
const next = await reader.read();
|
|
106
|
+
if (next.done) {
|
|
107
|
+
done = true;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const part = next.value;
|
|
111
|
+
if (part.type === "text-delta") {
|
|
112
|
+
yield { content: part.delta };
|
|
113
|
+
}
|
|
114
|
+
else if (part.type === "reasoning-delta") {
|
|
115
|
+
yield { content: "", reasoning: part.delta };
|
|
116
|
+
}
|
|
117
|
+
else if (part.type === "finish") {
|
|
118
|
+
onFinish(part);
|
|
119
|
+
}
|
|
120
|
+
else if (part.type === "error") {
|
|
121
|
+
throw part.error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
try {
|
|
127
|
+
if (!done) {
|
|
128
|
+
await reader.cancel();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
reader.releaseLock();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
69
136
|
export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
70
137
|
config;
|
|
71
138
|
resolvedModel;
|
|
@@ -946,7 +1013,10 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
946
1013
|
let wireNameMaps;
|
|
947
1014
|
let openAITools;
|
|
948
1015
|
let openAIToolChoice;
|
|
949
|
-
|
|
1016
|
+
// The prompt is kept in its pre-wire shape. Model middleware transforms
|
|
1017
|
+
// `params.prompt`, and the conversion to the chat-completions wire format
|
|
1018
|
+
// has to happen AFTER that or the transform would be discarded.
|
|
1019
|
+
let promptMessages;
|
|
950
1020
|
try {
|
|
951
1021
|
modelId = await this.resolveModelName();
|
|
952
1022
|
const shouldUseTools = !options.disableTools && this.supportsTools();
|
|
@@ -962,8 +1032,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
962
1032
|
? buildToolsForOpenAI(toolsRecord, wireNameMaps?.toWire)
|
|
963
1033
|
: undefined;
|
|
964
1034
|
openAIToolChoice = mapNeuroLinkToolChoice(resolveToolChoice(options, toolsRecord, shouldUseTools), wireNameMaps?.toWire);
|
|
965
|
-
|
|
966
|
-
conversation = messageBuilderToOpenAI(initialMessages, wireNameMaps?.toWire);
|
|
1035
|
+
promptMessages = (await this.buildMessagesForStream(options));
|
|
967
1036
|
}
|
|
968
1037
|
catch (setupErr) {
|
|
969
1038
|
timeoutController?.cleanup();
|
|
@@ -979,26 +1048,136 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
979
1048
|
const channel = createStreamChannel();
|
|
980
1049
|
// Per-provider lifecycle hook (e.g. OTel span wrap for LiteLLM).
|
|
981
1050
|
const lifecycle = this.onStreamStart(modelId);
|
|
982
|
-
|
|
983
|
-
|
|
1051
|
+
// Model middleware on the streaming path.
|
|
1052
|
+
//
|
|
1053
|
+
// The base model below is not `buildDelegatingModel()`'s — that one's
|
|
1054
|
+
// `doGenerate` is a single wire call and its `doStream` is a stub. This
|
|
1055
|
+
// one's `doStream` starts the real multi-step stream loop, which is what
|
|
1056
|
+
// "produce the stream for this request" means here. Wrapping it gives the
|
|
1057
|
+
// streaming path the contract the generate path has always had:
|
|
1058
|
+
// `transformParams` can rewrite the prompt before a byte is sent, and
|
|
1059
|
+
// `wrapStream` can observe, filter, or replace the stream outright.
|
|
1060
|
+
//
|
|
1061
|
+
// Honoured on the way back in: `prompt`, `maxOutputTokens`, `temperature`
|
|
1062
|
+
// and `topP`. `tools` is offered read-only — a middleware that rewrites it
|
|
1063
|
+
// gets a WARN rather than a silent drop, because re-deriving the wire tool
|
|
1064
|
+
// list here would diverge from `buildToolsForOpenAI`.
|
|
1065
|
+
const v3Tools = openAITools?.map((t) => ({
|
|
1066
|
+
type: "function",
|
|
1067
|
+
name: t.function.name,
|
|
1068
|
+
description: t.function.description,
|
|
1069
|
+
inputSchema: t.function.parameters,
|
|
1070
|
+
}));
|
|
1071
|
+
const v3Params = {
|
|
1072
|
+
prompt: promptMessages,
|
|
1073
|
+
...(v3Tools ? { tools: v3Tools } : {}),
|
|
1074
|
+
...(options.maxTokens !== undefined
|
|
1075
|
+
? { maxOutputTokens: options.maxTokens }
|
|
1076
|
+
: {}),
|
|
1077
|
+
...(options.temperature !== undefined
|
|
1078
|
+
? { temperature: options.temperature }
|
|
1079
|
+
: {}),
|
|
1080
|
+
...(options.topP !== undefined ? { topP: options.topP } : {}),
|
|
1081
|
+
};
|
|
1082
|
+
let loopPromise;
|
|
1083
|
+
const providerNameForLoop = this.providerName;
|
|
1084
|
+
const streamBaseModel = {
|
|
1085
|
+
specificationVersion: "v3",
|
|
1086
|
+
provider: providerNameForLoop,
|
|
984
1087
|
modelId,
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1088
|
+
supportedUrls: {},
|
|
1089
|
+
doGenerate: async (params) => {
|
|
1090
|
+
const model = await this.getAISDKModel();
|
|
1091
|
+
if (typeof model === "string") {
|
|
1092
|
+
throw new Error("Native model handle required");
|
|
1093
|
+
}
|
|
1094
|
+
return model.doGenerate(params);
|
|
1095
|
+
},
|
|
1096
|
+
doStream: async (params) => {
|
|
1097
|
+
if (params?.tools !== undefined && params.tools !== v3Tools) {
|
|
1098
|
+
logger.warn(`${providerNameForLoop}: middleware rewrote 'tools' on the streaming path; tool rewrites are not applied to the wire request yet — the original tool list was sent.`);
|
|
1099
|
+
}
|
|
1100
|
+
const transformedPrompt = Array.isArray(params?.prompt)
|
|
1101
|
+
? params.prompt
|
|
1102
|
+
: promptMessages;
|
|
1103
|
+
const conversation = messageBuilderToOpenAI(transformedPrompt, wireNameMaps?.toWire);
|
|
1104
|
+
const sampled = {
|
|
1105
|
+
...options,
|
|
1106
|
+
...(typeof params?.maxOutputTokens === "number"
|
|
1107
|
+
? { maxTokens: params.maxOutputTokens }
|
|
1108
|
+
: {}),
|
|
1109
|
+
...(typeof params?.temperature === "number"
|
|
1110
|
+
? { temperature: params.temperature }
|
|
1111
|
+
: {}),
|
|
1112
|
+
...(typeof params?.topP === "number" ? { topP: params.topP } : {}),
|
|
1113
|
+
};
|
|
1114
|
+
loopPromise = this.runStreamLoop({
|
|
1115
|
+
maxSteps,
|
|
1116
|
+
modelId,
|
|
1117
|
+
url,
|
|
1118
|
+
fetchImpl,
|
|
1119
|
+
abortSignal,
|
|
1120
|
+
options: sampled,
|
|
1121
|
+
conversation,
|
|
1122
|
+
openAITools,
|
|
1123
|
+
openAIToolChoice,
|
|
1124
|
+
toolsRecord,
|
|
1125
|
+
toolNameFromWire: wireNameMaps?.fromWire,
|
|
1126
|
+
emitter,
|
|
1127
|
+
toolsUsed,
|
|
1128
|
+
toolExecutionSummaries,
|
|
1129
|
+
pushChunk: channel.push,
|
|
1130
|
+
closeChannel: channel.close,
|
|
1131
|
+
resolveUsage,
|
|
1132
|
+
resolveFinish,
|
|
1133
|
+
});
|
|
1134
|
+
const completion = loopPromise.then(() => Promise.all([usagePromise, finishPromise]).then(([usage, reason]) => ({
|
|
1135
|
+
type: "finish",
|
|
1136
|
+
finishReason: { unified: reason },
|
|
1137
|
+
usage: {
|
|
1138
|
+
inputTokens: {
|
|
1139
|
+
total: usage.promptTokens,
|
|
1140
|
+
cacheRead: usage.cacheReadTokens,
|
|
1141
|
+
},
|
|
1142
|
+
outputTokens: { total: usage.completionTokens },
|
|
1143
|
+
},
|
|
1144
|
+
})));
|
|
1145
|
+
// The producer can reject before the consumer pulls its terminal event.
|
|
1146
|
+
void completion.catch(() => undefined);
|
|
1147
|
+
return {
|
|
1148
|
+
stream: chunksToV3Stream(channel.iterable, completion, () => consumerAbortController.abort()),
|
|
1149
|
+
};
|
|
1150
|
+
},
|
|
1151
|
+
};
|
|
1152
|
+
// A middleware chain that blocks (guardrails' precall path) returns its own
|
|
1153
|
+
// stream without calling `doStream`, so the loop may never start. Every
|
|
1154
|
+
// later reader of `loopPromise` has to tolerate that.
|
|
1155
|
+
let chunkSource;
|
|
1156
|
+
try {
|
|
1157
|
+
const wrappedStreamModel = await this.applyMiddlewareToModel(streamBaseModel, options);
|
|
1158
|
+
if (typeof wrappedStreamModel === "string") {
|
|
1159
|
+
throw new Error("Native stream model handle required");
|
|
1160
|
+
}
|
|
1161
|
+
const { stream } = await wrappedStreamModel.doStream(v3Params);
|
|
1162
|
+
chunkSource = v3StreamToChunks(stream, (part) => {
|
|
1163
|
+
if (!loopPromise) {
|
|
1164
|
+
const input = part.usage.inputTokens.total ?? 0;
|
|
1165
|
+
const output = part.usage.outputTokens.total ?? 0;
|
|
1166
|
+
resolveUsage({
|
|
1167
|
+
promptTokens: input,
|
|
1168
|
+
completionTokens: output,
|
|
1169
|
+
totalTokens: input + output,
|
|
1170
|
+
});
|
|
1171
|
+
resolveFinish(part.finishReason.unified);
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
catch (error) {
|
|
1176
|
+
consumerAbortController.abort();
|
|
1177
|
+
channel.close();
|
|
1178
|
+
timeoutController?.cleanup();
|
|
1179
|
+
throw error;
|
|
1180
|
+
}
|
|
1002
1181
|
// Closure-scoped capture: the runStreamLoop's catch block stashes the
|
|
1003
1182
|
// underlying provider error here so we can pass it through to
|
|
1004
1183
|
// buildNoOutputSentinel for richer telemetry (matches the pattern in
|
|
@@ -1025,7 +1204,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1025
1204
|
const transformedStream = async function* () {
|
|
1026
1205
|
let contentYielded = 0;
|
|
1027
1206
|
try {
|
|
1028
|
-
for await (const chunk of
|
|
1207
|
+
for await (const chunk of chunkSource) {
|
|
1029
1208
|
if ("content" in chunk &&
|
|
1030
1209
|
typeof chunk.content === "string" &&
|
|
1031
1210
|
chunk.content.length > 0) {
|
|
@@ -1034,6 +1213,8 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1034
1213
|
yield chunk;
|
|
1035
1214
|
}
|
|
1036
1215
|
// Surface any error that the loop threw after we drained the channel.
|
|
1216
|
+
// `loopPromise` is undefined when a middleware blocked the request
|
|
1217
|
+
// before `doStream` ran, in which case there is no loop to surface.
|
|
1037
1218
|
await loopPromise;
|
|
1038
1219
|
// No-output path: stream completed normally but yielded zero text.
|
|
1039
1220
|
// Build an enriched sentinel + stamp the active OTel span so
|
|
@@ -1062,6 +1243,15 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1062
1243
|
throw streamError;
|
|
1063
1244
|
}
|
|
1064
1245
|
finally {
|
|
1246
|
+
if (!loopPromise) {
|
|
1247
|
+
resolveUsage({
|
|
1248
|
+
promptTokens: 0,
|
|
1249
|
+
completionTokens: 0,
|
|
1250
|
+
totalTokens: 0,
|
|
1251
|
+
});
|
|
1252
|
+
resolveFinish("stop");
|
|
1253
|
+
}
|
|
1254
|
+
timeoutController?.cleanup();
|
|
1065
1255
|
if (!consumerAbortController.signal.aborted) {
|
|
1066
1256
|
consumerAbortController.abort();
|
|
1067
1257
|
}
|
|
@@ -1101,7 +1291,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
|
|
|
1101
1291
|
}))),
|
|
1102
1292
|
});
|
|
1103
1293
|
loopPromise
|
|
1104
|
-
|
|
1294
|
+
?.finally(() => timeoutController?.cleanup())
|
|
1105
1295
|
.catch((error) => {
|
|
1106
1296
|
captureProviderError(error);
|
|
1107
1297
|
});
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* `prompt_tokens`/`completion_tokens` spellings. A `null` result means "not
|
|
40
40
|
* observed", never "zero tokens".
|
|
41
41
|
*/
|
|
42
|
-
import type { CodexStreamUsage } from "../types/index.js";
|
|
42
|
+
import type { CodexStreamUsage, CodexStreamEvidence } from "../types/index.js";
|
|
43
43
|
/**
|
|
44
44
|
* Pull usage out of one parsed SSE `data:` payload.
|
|
45
45
|
*
|
|
@@ -65,4 +65,5 @@ export declare function scanCodexSSEForUsage(text: string): CodexStreamUsage | n
|
|
|
65
65
|
export declare function createCodexUsageTap(): {
|
|
66
66
|
stream: TransformStream<Uint8Array, Uint8Array>;
|
|
67
67
|
usage: Promise<CodexStreamUsage | null>;
|
|
68
|
+
evidence: () => CodexStreamEvidence;
|
|
68
69
|
};
|
package/dist/proxy/codexUsage.js
CHANGED
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
* observed", never "zero tokens".
|
|
41
41
|
*/
|
|
42
42
|
import { appendFileSync } from "node:fs";
|
|
43
|
+
import { extractSSEEvents } from "./sseInterceptor.js";
|
|
44
|
+
import { sanitizeForLog } from "../utils/logSanitize.js";
|
|
43
45
|
const nonNegativeInt = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
|
|
44
46
|
? Math.floor(value)
|
|
45
47
|
: 0;
|
|
@@ -161,6 +163,68 @@ function createCaptureSink() {
|
|
|
161
163
|
* none was. It never rejects.
|
|
162
164
|
*/
|
|
163
165
|
export function createCodexUsageTap() {
|
|
166
|
+
const evidence = { completed: false, terminalBytes: 0 };
|
|
167
|
+
let totalBytes = 0;
|
|
168
|
+
const inspectEvidence = (events) => {
|
|
169
|
+
for (const frame of events) {
|
|
170
|
+
try {
|
|
171
|
+
const event = JSON.parse(frame.data);
|
|
172
|
+
if (!event || typeof event !== "object") {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const seen = extractCodexUsage(event);
|
|
176
|
+
if (seen) {
|
|
177
|
+
latest = seen;
|
|
178
|
+
}
|
|
179
|
+
const type = event.type ?? frame.event;
|
|
180
|
+
if ((type === "response.output_text.delta" ||
|
|
181
|
+
type === "response.function_call_arguments.delta") &&
|
|
182
|
+
typeof event.delta === "string" &&
|
|
183
|
+
event.delta.length > 0) {
|
|
184
|
+
evidence.firstUsefulOutputAt ??= Date.now();
|
|
185
|
+
}
|
|
186
|
+
if (type === "response.completed") {
|
|
187
|
+
evidence.completed = true;
|
|
188
|
+
evidence.terminalBytes = totalBytes;
|
|
189
|
+
}
|
|
190
|
+
else if (type === "error" ||
|
|
191
|
+
type === "response.failed" ||
|
|
192
|
+
type === "response.incomplete") {
|
|
193
|
+
evidence.errorType = "stream_error";
|
|
194
|
+
const response = event.response;
|
|
195
|
+
const details = response && typeof response === "object"
|
|
196
|
+
? response
|
|
197
|
+
: event;
|
|
198
|
+
const rawError = details.error;
|
|
199
|
+
const error = rawError && typeof rawError === "object"
|
|
200
|
+
? rawError
|
|
201
|
+
: details;
|
|
202
|
+
const incomplete = details.incomplete_details;
|
|
203
|
+
const reason = incomplete &&
|
|
204
|
+
typeof incomplete === "object" &&
|
|
205
|
+
"reason" in incomplete
|
|
206
|
+
? incomplete.reason
|
|
207
|
+
: undefined;
|
|
208
|
+
evidence.errorCode =
|
|
209
|
+
typeof error.code === "string"
|
|
210
|
+
? sanitizeForLog(error.code).slice(0, 200)
|
|
211
|
+
: typeof reason === "string"
|
|
212
|
+
? sanitizeForLog(reason).slice(0, 200)
|
|
213
|
+
: String(type);
|
|
214
|
+
evidence.errorMessage =
|
|
215
|
+
typeof error.message === "string"
|
|
216
|
+
? sanitizeForLog(error.message).slice(0, 200)
|
|
217
|
+
: type === "response.incomplete"
|
|
218
|
+
? "Codex reported an incomplete response"
|
|
219
|
+
: "Codex reported a stream failure";
|
|
220
|
+
evidence.terminalBytes = totalBytes;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Unknown frames cannot establish successful completion.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
164
228
|
let settleUsage = () => { };
|
|
165
229
|
const usage = new Promise((resolve) => {
|
|
166
230
|
settleUsage = resolve;
|
|
@@ -179,40 +243,33 @@ export function createCodexUsageTap() {
|
|
|
179
243
|
const capture = createCaptureSink();
|
|
180
244
|
let carry = "";
|
|
181
245
|
let latest = null;
|
|
182
|
-
|
|
183
|
-
* Ceiling on the unterminated tail we are willing to hold.
|
|
184
|
-
*
|
|
185
|
-
* `carry` normally holds a fraction of one SSE line, because every newline
|
|
186
|
-
* flushes it. A stream that never sends one — a hung upstream, a
|
|
187
|
-
* non-SSE body relayed by mistake — would otherwise grow it without bound
|
|
188
|
-
* for the life of the request. One `response.completed` event is a few
|
|
189
|
-
* hundred bytes, so a megabyte is far past any real event, and dropping the
|
|
190
|
-
* tail costs at most the usage reading this tap is allowed to miss anyway.
|
|
191
|
-
*/
|
|
246
|
+
// Bound malformed unterminated events without ever withholding relay bytes.
|
|
192
247
|
const CARRY_LIMIT_CHARS = 1024 * 1024;
|
|
248
|
+
let discardingEvent = false;
|
|
193
249
|
const transformer = {
|
|
194
250
|
transform(chunk, controller) {
|
|
195
251
|
// Bytes go out first and unconditionally: nothing below can delay or
|
|
196
252
|
// alter what the client receives.
|
|
197
253
|
controller.enqueue(chunk);
|
|
254
|
+
totalBytes += chunk.byteLength;
|
|
198
255
|
try {
|
|
199
256
|
capture?.(chunk);
|
|
200
257
|
carry += decoder.decode(chunk, { stream: true });
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// read. Give up on the tail rather than grow forever.
|
|
207
|
-
carry = "";
|
|
258
|
+
if (discardingEvent) {
|
|
259
|
+
const boundary = /\r\n\r\n|\n\n|\r\r/.exec(carry);
|
|
260
|
+
if (!boundary) {
|
|
261
|
+
carry = carry.slice(-3);
|
|
262
|
+
return;
|
|
208
263
|
}
|
|
209
|
-
|
|
264
|
+
carry = carry.slice(boundary.index + boundary[0].length);
|
|
265
|
+
discardingEvent = false;
|
|
210
266
|
}
|
|
211
|
-
const
|
|
212
|
-
carry =
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
267
|
+
const { events, remainder } = extractSSEEvents(carry);
|
|
268
|
+
carry = remainder;
|
|
269
|
+
inspectEvidence(events);
|
|
270
|
+
if (carry.length > CARRY_LIMIT_CHARS) {
|
|
271
|
+
carry = carry.slice(-2);
|
|
272
|
+
discardingEvent = true;
|
|
216
273
|
}
|
|
217
274
|
}
|
|
218
275
|
catch {
|
|
@@ -220,15 +277,7 @@ export function createCodexUsageTap() {
|
|
|
220
277
|
}
|
|
221
278
|
},
|
|
222
279
|
flush() {
|
|
223
|
-
|
|
224
|
-
const seen = scanCodexSSEForUsage(carry);
|
|
225
|
-
if (seen) {
|
|
226
|
-
latest = seen;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
// ignored — see above
|
|
231
|
-
}
|
|
280
|
+
// An event without its dispatch delimiter is incomplete on the wire.
|
|
232
281
|
settle(latest);
|
|
233
282
|
},
|
|
234
283
|
/**
|
|
@@ -242,5 +291,5 @@ export function createCodexUsageTap() {
|
|
|
242
291
|
},
|
|
243
292
|
};
|
|
244
293
|
const stream = new TransformStream(transformer);
|
|
245
|
-
return { stream, usage };
|
|
294
|
+
return { stream, usage, evidence: () => ({ ...evidence }) };
|
|
246
295
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import type { ProxyActivitySnapshot, ProxyResponseTrackingObserver } from "../types/index.js";
|
|
1
|
+
import type { ProxyActivitySnapshot, ProxyResponseTrackingObserver, RequestLogEntry } from "../types/index.js";
|
|
2
|
+
/** Join route accounting to the HTTP lifecycle without relying on write order. */
|
|
3
|
+
export declare function observeProxyFinalLog(requestId: string, observer: (entry: RequestLogEntry) => void): () => void;
|
|
4
|
+
export declare function notifyProxyFinalLog(entry: RequestLogEntry): void;
|
|
2
5
|
export declare function registerProxyResponseObserver(metadata: object, observer: ProxyResponseTrackingObserver): void;
|
|
3
6
|
export declare function takeProxyResponseObservers(metadata: object): ProxyResponseTrackingObserver[];
|
|
4
7
|
/** Track one client-facing proxy request until its response body settles. */
|
|
@@ -8,6 +8,19 @@ let lastActivityAtMs = null;
|
|
|
8
8
|
// response through one tracker, which fans these observers out at the point
|
|
9
9
|
// where bytes actually leave the proxy.
|
|
10
10
|
const responseObserversByMetadata = new WeakMap();
|
|
11
|
+
const finalLogObservers = new Map();
|
|
12
|
+
/** Join route accounting to the HTTP lifecycle without relying on write order. */
|
|
13
|
+
export function observeProxyFinalLog(requestId, observer) {
|
|
14
|
+
finalLogObservers.set(requestId, observer);
|
|
15
|
+
return () => {
|
|
16
|
+
if (finalLogObservers.get(requestId) === observer) {
|
|
17
|
+
finalLogObservers.delete(requestId);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function notifyProxyFinalLog(entry) {
|
|
22
|
+
finalLogObservers.get(entry.requestId)?.(entry);
|
|
23
|
+
}
|
|
11
24
|
export function registerProxyResponseObserver(metadata, observer) {
|
|
12
25
|
const existing = responseObserversByMetadata.get(metadata);
|
|
13
26
|
if (existing) {
|
|
@@ -69,12 +82,17 @@ export function isProxyActivityQuiet(snapshot, quietThresholdMs, nowMs = Date.no
|
|
|
69
82
|
/** Keep activity open until the response body completes, errors, or is cancelled. */
|
|
70
83
|
export function trackProxyResponse(response, finishRequest, observer) {
|
|
71
84
|
if (!response.body) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
85
|
+
try {
|
|
86
|
+
const notified = observer?.onTerminal?.({
|
|
87
|
+
outcome: "bodyless",
|
|
88
|
+
observedBodyBytes: 0,
|
|
89
|
+
responseChunks: 0,
|
|
90
|
+
});
|
|
91
|
+
void Promise.resolve(notified).then(finishRequest, finishRequest);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
finishRequest();
|
|
95
|
+
}
|
|
78
96
|
return response;
|
|
79
97
|
}
|
|
80
98
|
const reader = response.body.getReader();
|
|
@@ -88,17 +106,25 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
88
106
|
void reader.closed.then(() => {
|
|
89
107
|
sourceClosed = true;
|
|
90
108
|
}, () => undefined);
|
|
91
|
-
const settle = (outcome) => {
|
|
109
|
+
const settle = (outcome, error) => {
|
|
92
110
|
if (settled) {
|
|
93
111
|
return;
|
|
94
112
|
}
|
|
95
113
|
settled = true;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
// Keep drain accounting open through bounded terminal bookkeeping, but
|
|
115
|
+
// never hold back the client's response body while telemetry is written.
|
|
116
|
+
try {
|
|
117
|
+
const notified = observer?.onTerminal?.({
|
|
118
|
+
outcome,
|
|
119
|
+
error,
|
|
120
|
+
observedBodyBytes,
|
|
121
|
+
responseChunks,
|
|
122
|
+
});
|
|
123
|
+
void Promise.resolve(notified).then(finishRequest, finishRequest);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
finishRequest();
|
|
127
|
+
}
|
|
102
128
|
};
|
|
103
129
|
const trackedBody = new ReadableStream({
|
|
104
130
|
async pull(controller) {
|
|
@@ -120,7 +146,7 @@ export function trackProxyResponse(response, finishRequest, observer) {
|
|
|
120
146
|
}
|
|
121
147
|
}
|
|
122
148
|
catch (error) {
|
|
123
|
-
settle("stream_error");
|
|
149
|
+
settle("stream_error", error);
|
|
124
150
|
controller.error(error);
|
|
125
151
|
}
|
|
126
152
|
},
|