@juspay/neurolink 12.14.7 → 12.14.9
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/adapters/providerImageAdapter.js +1 -0
- package/dist/browser/neurolink.min.js +379 -379
- package/dist/cli/commands/proxy.js +7 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +11 -0
- package/dist/mcp/servers/aiProviders/aiAnalysisTools.js +43 -30
- package/dist/neurolink.d.ts +4 -1
- package/dist/neurolink.js +41 -6
- package/dist/providers/azureOpenai.js +1 -1
- package/dist/providers/catalog/loader.d.ts +1 -1
- package/dist/providers/catalog/loader.js +30 -1
- package/dist/providers/cohere.js +1 -1
- package/dist/providers/deepseek.js +1 -1
- package/dist/providers/googleAiStudio/client.js +6 -5
- package/dist/providers/googleVertex/client.d.ts +11 -30
- package/dist/providers/googleVertex/client.js +49 -131
- package/dist/providers/huggingFace/client.js +1 -1
- package/dist/providers/llamaCpp.js +1 -1
- package/dist/providers/lmStudio.js +1 -1
- package/dist/providers/nvidiaNim/client.js +1 -1
- package/dist/providers/ollama/client.js +1 -1
- package/dist/providers/openRouter/client.js +1 -1
- package/dist/providers/openaiCompatible/client.js +1 -1
- package/dist/proxy/bodyCaptureProcessing.js +47 -3
- package/dist/proxy/bodyCaptureWorker.js +14 -10
- package/dist/proxy/claudeOutputObservation.d.ts +9 -0
- package/dist/proxy/claudeOutputObservation.js +85 -0
- package/dist/proxy/codexUsage.js +12 -10
- package/dist/proxy/otelLogSink.js +1 -1
- package/dist/proxy/processTelemetry.d.ts +3 -0
- package/dist/proxy/processTelemetry.js +25 -0
- package/dist/proxy/rawStreamCapture.js +17 -26
- package/dist/proxy/requestLogger.d.ts +2 -0
- package/dist/proxy/requestLogger.js +5 -1
- package/dist/proxy/restartControl.d.ts +3 -1
- package/dist/proxy/restartControl.js +118 -1
- package/dist/proxy/sseInterceptor.js +71 -3
- package/dist/server/routes/claudeProxyRoutes.d.ts +0 -11
- package/dist/server/routes/claudeProxyRoutes.js +27 -12
- package/dist/server/routes/codexProxyRoutes.js +83 -2
- package/dist/types/proxy.d.ts +14 -0
- package/dist/types/proxyRestart.d.ts +25 -0
- package/dist/utils/logSanitize.js +1 -1
- package/dist/utils/timeout.d.ts +13 -2
- package/dist/utils/timeout.js +14 -3
- package/docs-site/static/search-index.json +1 -1
- package/package.json +1 -1
- package/scripts/observability/proxy-telemetry-check.mjs +39 -5
|
@@ -977,9 +977,13 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
977
977
|
async createVertexGenAIClient(regionOverride) {
|
|
978
978
|
warnGoogleSdkIgnoresProxy("GoogleVertex");
|
|
979
979
|
const expressApiKey = this.resolveExpressApiKey();
|
|
980
|
-
// Resolved only on the ADC path
|
|
981
|
-
//
|
|
982
|
-
|
|
980
|
+
// Resolved only on the ADC path, and from the per-instance projectId the
|
|
981
|
+
// constructor already settled (credentials.projectId if supplied, else
|
|
982
|
+
// the same ambient getVertexProjectId() fallback) — not re-read from
|
|
983
|
+
// ambient env here, or a per-request projectId override would be built
|
|
984
|
+
// into the client with the wrong project while call sites elsewhere
|
|
985
|
+
// (generateContent's `project:` param) correctly used this.projectId.
|
|
986
|
+
const project = expressApiKey ? "" : this.projectId;
|
|
983
987
|
const location = regionOverride || this.location || getVertexLocation();
|
|
984
988
|
const mod = await import("@google/genai");
|
|
985
989
|
const ctor = mod.GoogleGenAI;
|
|
@@ -1060,94 +1064,6 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1060
1064
|
].some((name) => (process.env[name] ?? "").trim().length > 0);
|
|
1061
1065
|
return projectConfigured ? undefined : fromEnv;
|
|
1062
1066
|
}
|
|
1063
|
-
/**
|
|
1064
|
-
* Convert one AI-SDK tool into a Vertex Gemini function declaration.
|
|
1065
|
-
* Single source for the pre-loop snapshot AND the mid-turn discovery
|
|
1066
|
-
* refresh, so tools hydrated by search_tools get the exact same schema
|
|
1067
|
-
* treatment (inline, typed, additionalProperties stripped).
|
|
1068
|
-
*/
|
|
1069
|
-
buildGeminiFunctionDeclaration(name, tool) {
|
|
1070
|
-
const decl = {
|
|
1071
|
-
name,
|
|
1072
|
-
description: tool.description || `Tool: ${name}`,
|
|
1073
|
-
};
|
|
1074
|
-
// Access legacy `parameters` (AI SDK v3/v4) or current `inputSchema` (v6)
|
|
1075
|
-
const legacyTool = tool;
|
|
1076
|
-
const toolParams = legacyTool.parameters || tool.inputSchema;
|
|
1077
|
-
if (toolParams) {
|
|
1078
|
-
// Convert and inline schema to resolve $ref/definitions
|
|
1079
|
-
const rawSchema = convertZodToJsonSchema(toolParams, "openApi3");
|
|
1080
|
-
const inlinedSchema = inlineJsonSchema(rawSchema);
|
|
1081
|
-
// Remove $schema if present - @google/genai doesn't need it
|
|
1082
|
-
if (inlinedSchema.$schema) {
|
|
1083
|
-
delete inlinedSchema.$schema;
|
|
1084
|
-
}
|
|
1085
|
-
// CRITICAL: Google Vertex AI requires ALL nested schemas to have a type field
|
|
1086
|
-
// ensureNestedSchemaTypes recursively adds missing type fields to tool schemas
|
|
1087
|
-
// Note: convertZodToJsonSchema now uses openApi3 target which produces nullable: true
|
|
1088
|
-
const typedSchema = ensureNestedSchemaTypes(inlinedSchema);
|
|
1089
|
-
// Strip `additionalProperties` recursively — Vertex Gemini's
|
|
1090
|
-
// function-call validator rejects it on object schemas (returns
|
|
1091
|
-
// 400 INVALID_ARGUMENT) even though it's valid OpenAPI 3. The
|
|
1092
|
-
// field has no semantic meaning to the model, so dropping it
|
|
1093
|
-
// before send is safe for every caller.
|
|
1094
|
-
stripAdditionalPropertiesDeep(typedSchema);
|
|
1095
|
-
decl.parametersJsonSchema = typedSchema;
|
|
1096
|
-
}
|
|
1097
|
-
return decl;
|
|
1098
|
-
}
|
|
1099
|
-
/**
|
|
1100
|
-
* Mid-turn tool sync for the native Gemini loops. `search_tools` hydrates
|
|
1101
|
-
* discovered tools into the live `options.tools` record between steps, but
|
|
1102
|
-
* the loop's declarations + executeMap are a pre-loop snapshot — without
|
|
1103
|
-
* this refresh, a tool discovered this turn stays invisible to the rest of
|
|
1104
|
-
* the turn and every call to it dies as TOOL_NOT_FOUND. Mutates the
|
|
1105
|
-
* declaration array in place (the request config holds it by reference)
|
|
1106
|
-
* and clears breaker strikes accrued while the tool was still deferred.
|
|
1107
|
-
*/
|
|
1108
|
-
refreshGeminiToolDeclarations(liveTools, declarations, executeMap, failedTools) {
|
|
1109
|
-
if (!liveTools || !declarations) {
|
|
1110
|
-
return;
|
|
1111
|
-
}
|
|
1112
|
-
const declared = new Set(declarations.map((d) => d.name));
|
|
1113
|
-
for (const [name, tool] of Object.entries(liveTools)) {
|
|
1114
|
-
if (declared.has(name)) {
|
|
1115
|
-
continue;
|
|
1116
|
-
}
|
|
1117
|
-
declarations.push(this.buildGeminiFunctionDeclaration(name, tool));
|
|
1118
|
-
if (tool.execute) {
|
|
1119
|
-
executeMap.set(name, tool.execute);
|
|
1120
|
-
}
|
|
1121
|
-
failedTools.delete(name);
|
|
1122
|
-
logger.info(`[GoogleVertex] Tool "${name}" hydrated mid-turn via discovery — added to Gemini declarations.`);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Dispatch-miss recovery for the native Gemini loops: the model called a
|
|
1127
|
-
* name missing from the executeMap snapshot. Re-read the live record (a
|
|
1128
|
-
* tool hydrated by search_tools in this very step batch) or auto-hydrate a
|
|
1129
|
-
* deferred catalog tool the model called directly by its advertised name.
|
|
1130
|
-
* Returns the dedup-wrapped executor, or undefined when the name is
|
|
1131
|
-
* genuinely unknown — callers keep TOOL_NOT_FOUND for that case.
|
|
1132
|
-
*/
|
|
1133
|
-
resolveGeminiToolOnMiss(name, liveTools, declarations, executeMap, failedTools) {
|
|
1134
|
-
if (!declarations) {
|
|
1135
|
-
return undefined;
|
|
1136
|
-
}
|
|
1137
|
-
const tool = resolveLiveTool(liveTools, name);
|
|
1138
|
-
if (!tool?.execute) {
|
|
1139
|
-
return undefined;
|
|
1140
|
-
}
|
|
1141
|
-
if (!declarations.some((d) => d.name === name)) {
|
|
1142
|
-
declarations.push(this.buildGeminiFunctionDeclaration(name, tool));
|
|
1143
|
-
}
|
|
1144
|
-
executeMap.set(name, tool.execute);
|
|
1145
|
-
// NOT_FOUND strikes accrued while the tool was deferred are snapshot
|
|
1146
|
-
// artifacts, not real failures — reset so the breaker starts clean.
|
|
1147
|
-
failedTools.delete(name);
|
|
1148
|
-
logger.info(`[GoogleVertex] Tool "${name}" resolved mid-turn via discovery — executing.`);
|
|
1149
|
-
return executeMap.get(name);
|
|
1150
|
-
}
|
|
1151
1067
|
/**
|
|
1152
1068
|
* Convert one AI-SDK tool into an Anthropic (Claude-on-Vertex) tool
|
|
1153
1069
|
* declaration. Single source for the pre-loop snapshot AND the mid-turn
|
|
@@ -1179,8 +1095,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1179
1095
|
return anthropicTool;
|
|
1180
1096
|
}
|
|
1181
1097
|
/**
|
|
1182
|
-
* Mid-turn tool sync for the Claude-on-Vertex loops
|
|
1183
|
-
* counterpart of refreshGeminiToolDeclarations. Claude only calls tools
|
|
1098
|
+
* Mid-turn tool sync for the Claude-on-Vertex loops. Claude only calls tools
|
|
1184
1099
|
* present in the request's `tools` array, so without this per-step refresh
|
|
1185
1100
|
* a tool discovered via search_tools is unreachable for the rest of the
|
|
1186
1101
|
* turn. Mutates the array in place (requestParams holds it by reference).
|
|
@@ -1203,8 +1118,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1203
1118
|
}
|
|
1204
1119
|
}
|
|
1205
1120
|
/**
|
|
1206
|
-
* Dispatch-miss recovery for the Claude-on-Vertex loops
|
|
1207
|
-
* counterpart of resolveGeminiToolOnMiss.
|
|
1121
|
+
* Dispatch-miss recovery for the Claude-on-Vertex loops.
|
|
1208
1122
|
*/
|
|
1209
1123
|
resolveAnthropicToolOnMiss(name, liveTools, declarations, executeMap, failedTools) {
|
|
1210
1124
|
if (!declarations) {
|
|
@@ -1222,6 +1136,31 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1222
1136
|
logger.info(`[GoogleVertex] Tool "${name}" resolved mid-turn via discovery — executing.`);
|
|
1223
1137
|
return executeMap.get(name);
|
|
1224
1138
|
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Build the synthetic `final_result` tool used to force structured output
|
|
1141
|
+
* on Claude-on-Vertex. Anthropic has no native responseSchema, so a schema
|
|
1142
|
+
* is instead enforced by requiring the model to call this tool with
|
|
1143
|
+
* matching arguments. Single source for the non-streaming and streaming
|
|
1144
|
+
* paths — both previously built this object independently from their own
|
|
1145
|
+
* schema variable, byte-identical apart from that variable's name.
|
|
1146
|
+
*/
|
|
1147
|
+
buildFinalResultTool(schema) {
|
|
1148
|
+
const schemaAsJson = convertZodToJsonSchema(schema);
|
|
1149
|
+
const inlinedSchema = inlineJsonSchema(schemaAsJson);
|
|
1150
|
+
if (inlinedSchema.$schema) {
|
|
1151
|
+
delete inlinedSchema.$schema;
|
|
1152
|
+
}
|
|
1153
|
+
const typedSchema = ensureNestedSchemaTypes(inlinedSchema);
|
|
1154
|
+
return {
|
|
1155
|
+
name: "final_result",
|
|
1156
|
+
description: "Return the final structured result. You MUST call this tool when you have gathered all information and are ready to provide the final answer. The arguments should contain the structured data matching the expected schema.",
|
|
1157
|
+
input_schema: {
|
|
1158
|
+
type: "object",
|
|
1159
|
+
properties: typedSchema.properties || typedSchema,
|
|
1160
|
+
required: typedSchema.required || [],
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1225
1164
|
/**
|
|
1226
1165
|
* Execute stream using native @google/genai SDK for Gemini 3 models on Vertex AI
|
|
1227
1166
|
* This bypasses @ai-sdk/google-vertex to properly handle thought_signature
|
|
@@ -3132,23 +3071,8 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3132
3071
|
let schemaSystemPromptSuffix = "";
|
|
3133
3072
|
if (streamOptions.schema) {
|
|
3134
3073
|
useFinalResultTool = true;
|
|
3135
|
-
// Convert schema to JSON schema format
|
|
3136
|
-
const schemaAsJson = convertZodToJsonSchema(streamOptions.schema);
|
|
3137
|
-
const inlinedSchema = inlineJsonSchema(schemaAsJson);
|
|
3138
|
-
if (inlinedSchema.$schema) {
|
|
3139
|
-
delete inlinedSchema.$schema;
|
|
3140
|
-
}
|
|
3141
|
-
const typedSchema = ensureNestedSchemaTypes(inlinedSchema);
|
|
3142
3074
|
// Create final_result tool
|
|
3143
|
-
const finalResultTool =
|
|
3144
|
-
name: "final_result",
|
|
3145
|
-
description: "Return the final structured result. You MUST call this tool when you have gathered all information and are ready to provide the final answer. The arguments should contain the structured data matching the expected schema.",
|
|
3146
|
-
input_schema: {
|
|
3147
|
-
type: "object",
|
|
3148
|
-
properties: typedSchema.properties || typedSchema,
|
|
3149
|
-
required: typedSchema.required || [],
|
|
3150
|
-
},
|
|
3151
|
-
};
|
|
3075
|
+
const finalResultTool = this.buildFinalResultTool(streamOptions.schema);
|
|
3152
3076
|
// Add to tools array or create new array
|
|
3153
3077
|
if (!tools) {
|
|
3154
3078
|
tools = [];
|
|
@@ -3158,7 +3082,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3158
3082
|
schemaSystemPromptSuffix =
|
|
3159
3083
|
"\n\nIMPORTANT: You MUST call the 'final_result' tool to return your response in the required structured format. Do not respond with plain text - always use the final_result tool.";
|
|
3160
3084
|
logger.debug("[GoogleVertex] Added final_result tool for Anthropic structured output (stream)", {
|
|
3161
|
-
schemaKeys: Object.keys(
|
|
3085
|
+
schemaKeys: Object.keys(finalResultTool.input_schema.properties ?? {}),
|
|
3162
3086
|
totalTools: tools.length,
|
|
3163
3087
|
});
|
|
3164
3088
|
}
|
|
@@ -3719,10 +3643,19 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3719
3643
|
// with text in step 2 would look unfinished and fall into terminal
|
|
3720
3644
|
// handling. The finish reason is the per-turn signal — the engine
|
|
3721
3645
|
// reports "tool-calls" only when the cap was hit with tools still
|
|
3722
|
-
// pending.
|
|
3646
|
+
// pending. That signal is not enough on its own, though: Anthropic
|
|
3647
|
+
// reports stop_reason "tool_use" — which maps to finishReason
|
|
3648
|
+
// "tool-calls" — for ANY tool_use block, including a genuine
|
|
3649
|
+
// terminal `final_result` call. Without the third clause, a turn
|
|
3650
|
+
// that called a real tool earlier (toolCalls.length > 0) and then
|
|
3651
|
+
// legitimately finished via final_result reads as unfinished and
|
|
3652
|
+
// falls into the terminal-handling block below, which forces a
|
|
3653
|
+
// redundant second final_result call and pushes its JSON onto the
|
|
3654
|
+
// stream a second time.
|
|
3723
3655
|
modelFinished =
|
|
3724
3656
|
engineResult.toolCalls.length === 0 ||
|
|
3725
|
-
engineResult.finishReason !== "tool-calls"
|
|
3657
|
+
engineResult.finishReason !== "tool-calls" ||
|
|
3658
|
+
(useFinalResultTool && structuredOutputRef.value !== undefined);
|
|
3726
3659
|
// Replace in place: the terminal block and the finalization call both
|
|
3727
3660
|
// read `currentMessages`.
|
|
3728
3661
|
currentMessages.length = 0;
|
|
@@ -4361,23 +4294,8 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
4361
4294
|
let schemaSystemPromptSuffix = "";
|
|
4362
4295
|
if (options.schema) {
|
|
4363
4296
|
useFinalResultTool = true;
|
|
4364
|
-
// Convert schema to JSON schema format
|
|
4365
|
-
const schemaAsJson = convertZodToJsonSchema(options.schema);
|
|
4366
|
-
const inlinedSchema = inlineJsonSchema(schemaAsJson);
|
|
4367
|
-
if (inlinedSchema.$schema) {
|
|
4368
|
-
delete inlinedSchema.$schema;
|
|
4369
|
-
}
|
|
4370
|
-
const typedSchema = ensureNestedSchemaTypes(inlinedSchema);
|
|
4371
4297
|
// Create final_result tool
|
|
4372
|
-
const finalResultTool =
|
|
4373
|
-
name: "final_result",
|
|
4374
|
-
description: "Return the final structured result. You MUST call this tool when you have gathered all information and are ready to provide the final answer. The arguments should contain the structured data matching the expected schema.",
|
|
4375
|
-
input_schema: {
|
|
4376
|
-
type: "object",
|
|
4377
|
-
properties: typedSchema.properties || typedSchema,
|
|
4378
|
-
required: typedSchema.required || [],
|
|
4379
|
-
},
|
|
4380
|
-
};
|
|
4298
|
+
const finalResultTool = this.buildFinalResultTool(options.schema);
|
|
4381
4299
|
// Add to tools array or create new array
|
|
4382
4300
|
if (!tools) {
|
|
4383
4301
|
tools = [];
|
|
@@ -4387,7 +4305,7 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
4387
4305
|
schemaSystemPromptSuffix =
|
|
4388
4306
|
"\n\nIMPORTANT: You MUST call the 'final_result' tool to return your response in the required structured format. Do not respond with plain text - always use the final_result tool.";
|
|
4389
4307
|
logger.debug("[GoogleVertex] Added final_result tool for Anthropic structured output (generate)", {
|
|
4390
|
-
schemaKeys: Object.keys(
|
|
4308
|
+
schemaKeys: Object.keys(finalResultTool.input_schema.properties ?? {}),
|
|
4391
4309
|
totalTools: tools.length,
|
|
4392
4310
|
});
|
|
4393
4311
|
}
|
|
@@ -97,6 +97,6 @@ export class HuggingFaceProvider extends OpenAIChatCompletionsProvider {
|
|
|
97
97
|
},
|
|
98
98
|
...DEFAULT_ERROR_RULES,
|
|
99
99
|
];
|
|
100
|
-
return classifyProviderError(error, rules,
|
|
100
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
@@ -74,6 +74,6 @@ export class LlamaCppProvider extends OpenAIChatCompletionsProvider {
|
|
|
74
74
|
},
|
|
75
75
|
...DEFAULT_ERROR_RULES,
|
|
76
76
|
];
|
|
77
|
-
return classifyProviderError(error, rules,
|
|
77
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
@@ -69,7 +69,7 @@ export class LMStudioProvider extends OpenAIChatCompletionsProvider {
|
|
|
69
69
|
},
|
|
70
70
|
...DEFAULT_ERROR_RULES,
|
|
71
71
|
];
|
|
72
|
-
return classifyProviderError(error, rules,
|
|
72
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
73
73
|
}
|
|
74
74
|
async validateConfiguration() {
|
|
75
75
|
return this.probeModelsEndpoint(this.config.apiKey && this.config.apiKey !== LM_STUDIO_PLACEHOLDER_KEY
|
|
@@ -281,7 +281,7 @@ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
|
|
|
281
281
|
},
|
|
282
282
|
...DEFAULT_ERROR_RULES,
|
|
283
283
|
];
|
|
284
|
-
return classifyProviderError(error, rules,
|
|
284
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
285
285
|
}
|
|
286
286
|
async validateConfiguration() {
|
|
287
287
|
return (typeof this.config.apiKey === "string" &&
|
|
@@ -136,7 +136,7 @@ export class OllamaProvider extends OpenAIChatCompletionsProvider {
|
|
|
136
136
|
},
|
|
137
137
|
...DEFAULT_ERROR_RULES,
|
|
138
138
|
];
|
|
139
|
-
return classifyProviderError(error, rules,
|
|
139
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
140
140
|
}
|
|
141
141
|
// ===========================================================================
|
|
142
142
|
// Optional hooks — Ollama-specific behaviour
|
|
@@ -149,7 +149,7 @@ export class OpenRouterProvider extends OpenAIChatCompletionsProvider {
|
|
|
149
149
|
},
|
|
150
150
|
...DEFAULT_ERROR_RULES,
|
|
151
151
|
];
|
|
152
|
-
return classifyProviderError(error, rules,
|
|
152
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
153
153
|
}
|
|
154
154
|
// ===========================================================================
|
|
155
155
|
// Optional hooks — provider-specific quirks
|
|
@@ -85,6 +85,6 @@ export class OpenAICompatibleProvider extends OpenAIChatCompletionsProvider {
|
|
|
85
85
|
},
|
|
86
86
|
...DEFAULT_ERROR_RULES,
|
|
87
87
|
];
|
|
88
|
-
return classifyProviderError(error, rules,
|
|
88
|
+
return classifyProviderError(error, rules, this.providerName, this.modelName);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { mkdir, chmod, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
|
+
import { sanitizeForLog } from "../utils/logSanitize.js";
|
|
5
6
|
import { gzip as gzipCallback } from "node:zlib";
|
|
6
7
|
const REQUEST_LOG_IO_TIMEOUT_MS = 5_000;
|
|
7
8
|
/** Maximum redacted body bytes persisted per capture entry. */
|
|
@@ -43,6 +44,15 @@ function redactHeaders(headers) {
|
|
|
43
44
|
return redacted;
|
|
44
45
|
}
|
|
45
46
|
const SENSITIVE_BODY_KEY = /^(?:password|access_token|refresh_token|api_key|apiKey|secret|authorization|token|credential|x-api-key)$/i;
|
|
47
|
+
/** Error messages may contain nested JSON encoded as a string. */
|
|
48
|
+
function redactNestedValue(key, value) {
|
|
49
|
+
if (SENSITIVE_BODY_KEY.test(key)) {
|
|
50
|
+
return "[REDACTED]";
|
|
51
|
+
}
|
|
52
|
+
return typeof value === "string"
|
|
53
|
+
? sanitizeForLog(value.replace(SENSITIVE_BODY_KEYS, '$1"[REDACTED]"'), value.length)
|
|
54
|
+
: value;
|
|
55
|
+
}
|
|
46
56
|
/** Redact every value under a sensitive key, including objects and arrays. */
|
|
47
57
|
function redactBody(body) {
|
|
48
58
|
if (body === undefined || body === null) {
|
|
@@ -54,11 +64,44 @@ function redactBody(body) {
|
|
|
54
64
|
value = JSON.parse(body);
|
|
55
65
|
}
|
|
56
66
|
catch {
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
if (/^(?:event|data):/m.test(body)) {
|
|
68
|
+
// Redact whole SSE data values, including nested credentials. Never
|
|
69
|
+
// retain a truncated/invalid JSON tail whose secret cannot be parsed.
|
|
70
|
+
return body
|
|
71
|
+
.split(/\r\n\r\n|\n\n|\r\r/)
|
|
72
|
+
.map((frame) => {
|
|
73
|
+
const lines = frame.split(/\r\n|\n|\r/);
|
|
74
|
+
const data = lines
|
|
75
|
+
.filter((line) => line.startsWith("data:"))
|
|
76
|
+
.map((line) => line.slice(5).trimStart())
|
|
77
|
+
.join("\n");
|
|
78
|
+
if (!data || data === "[DONE]") {
|
|
79
|
+
return frame;
|
|
80
|
+
}
|
|
81
|
+
let redacted;
|
|
82
|
+
try {
|
|
83
|
+
redacted = JSON.stringify(JSON.parse(data), redactNestedValue);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
redacted = "[UNPARSEABLE DATA REDACTED]";
|
|
87
|
+
}
|
|
88
|
+
return [
|
|
89
|
+
...lines.filter((line) => !line.startsWith("data:")),
|
|
90
|
+
`data: ${redacted}`,
|
|
91
|
+
].join("\n");
|
|
92
|
+
})
|
|
93
|
+
.join("\n\n");
|
|
94
|
+
}
|
|
95
|
+
// Invalid or truncated structured JSON must not expose an unterminated
|
|
96
|
+
// credential value that a quoted-string regex cannot safely redact.
|
|
97
|
+
if (/^\s*[[{]/.test(body)) {
|
|
98
|
+
return "[UNPARSEABLE DATA REDACTED]";
|
|
99
|
+
}
|
|
100
|
+
// Plain-text bodies retain the existing credential-field redaction.
|
|
101
|
+
return sanitizeForLog(body.replace(SENSITIVE_BODY_KEYS, '$1"[REDACTED]"'), body.length);
|
|
59
102
|
}
|
|
60
103
|
}
|
|
61
|
-
return JSON.stringify(value,
|
|
104
|
+
return JSON.stringify(value, redactNestedValue);
|
|
62
105
|
}
|
|
63
106
|
/**
|
|
64
107
|
* Restrict request and phase identifiers to characters safe for artifact
|
|
@@ -208,6 +251,7 @@ export async function processProxyBodyCapture(entry, logDir) {
|
|
|
208
251
|
const headers = redactHeaders(entry.headers);
|
|
209
252
|
const limit = logDir === null ? MAX_OTEL_CAPTURED_BODY_BYTES : MAX_CAPTURED_BODY_BYTES;
|
|
210
253
|
const prepared = prepareRedactedBody(entry.body, limit);
|
|
254
|
+
prepared.truncated ||= entry.sourceTruncated === true;
|
|
211
255
|
if (logDir === null) {
|
|
212
256
|
return {
|
|
213
257
|
headers,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
|
-
const MAX_PENDING =
|
|
2
|
+
const MAX_PENDING = 64;
|
|
3
3
|
const MAX_PENDING_BYTES = 32 * 1024 * 1024;
|
|
4
4
|
// Bound retained UTF-16 strings rather than a 3x UTF-8 guess. This accommodates
|
|
5
5
|
// the observed 7.3 MB JSON requests while retaining a 32 MiB aggregate pool.
|
|
@@ -29,7 +29,7 @@ const pending = new Map();
|
|
|
29
29
|
* Conservatively bound clone size and traversal work without invoking
|
|
30
30
|
* getters or serializers.
|
|
31
31
|
*/
|
|
32
|
-
function estimateCloneBytes(value) {
|
|
32
|
+
function estimateCloneBytes(value, maxEntryBytes) {
|
|
33
33
|
const stack = [value];
|
|
34
34
|
const seen = new Set();
|
|
35
35
|
let bytes = 0, nodes = 0;
|
|
@@ -37,8 +37,8 @@ function estimateCloneBytes(value) {
|
|
|
37
37
|
if (++nodes > 100_000) {
|
|
38
38
|
throw new Error("body_capture_traversal_limit");
|
|
39
39
|
}
|
|
40
|
-
if (bytes >
|
|
41
|
-
|
|
40
|
+
if (bytes > maxEntryBytes) {
|
|
41
|
+
return bytes;
|
|
42
42
|
}
|
|
43
43
|
const item = stack.pop();
|
|
44
44
|
if (typeof item === "string") {
|
|
@@ -68,8 +68,8 @@ function estimateCloneBytes(value) {
|
|
|
68
68
|
if (stack.length > 100_000) {
|
|
69
69
|
throw new Error("body_capture_traversal_limit");
|
|
70
70
|
}
|
|
71
|
-
if (bytes >
|
|
72
|
-
|
|
71
|
+
if (bytes > maxEntryBytes) {
|
|
72
|
+
return bytes;
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
}
|
|
@@ -135,10 +135,13 @@ function getWorker() {
|
|
|
135
135
|
/** Bounded bulk capture. Failures are indexed; never fall back to blocking work. */
|
|
136
136
|
export async function captureProxyBody(entry, logDir, consume) {
|
|
137
137
|
snapshot.attempted += 1;
|
|
138
|
+
// An OTel capture can use the existing byte pool when it is otherwise idle.
|
|
139
|
+
// Raising the slot count does not raise the aggregate retained-memory bound.
|
|
140
|
+
const maxEntryBytes = logDir === null ? MAX_PENDING_BYTES : MAX_ENTRY_BYTES;
|
|
138
141
|
let bytes;
|
|
139
142
|
let admissionError;
|
|
140
143
|
try {
|
|
141
|
-
bytes = estimateCloneBytes(entry);
|
|
144
|
+
bytes = estimateCloneBytes(entry, maxEntryBytes);
|
|
142
145
|
}
|
|
143
146
|
catch (error) {
|
|
144
147
|
bytes = Infinity;
|
|
@@ -152,12 +155,12 @@ export async function captureProxyBody(entry, logDir, consume) {
|
|
|
152
155
|
? error.message
|
|
153
156
|
: "body_capture_unsupported_value";
|
|
154
157
|
}
|
|
155
|
-
if (bytes >
|
|
158
|
+
if (bytes > maxEntryBytes ||
|
|
156
159
|
snapshot.pending >= MAX_PENDING ||
|
|
157
160
|
snapshot.pendingBytes + bytes > MAX_PENDING_BYTES ||
|
|
158
161
|
Date.now() < retryAfter) {
|
|
159
162
|
snapshot.rejected += 1;
|
|
160
|
-
const error = bytes >
|
|
163
|
+
const error = bytes > maxEntryBytes
|
|
161
164
|
? (admissionError ?? "body_capture_entry_too_large")
|
|
162
165
|
: Date.now() < retryAfter
|
|
163
166
|
? "body_worker_backoff"
|
|
@@ -170,7 +173,7 @@ export async function captureProxyBody(entry, logDir, consume) {
|
|
|
170
173
|
error,
|
|
171
174
|
stored: { bodyWriteFailed: true },
|
|
172
175
|
admission: {
|
|
173
|
-
limitingResource: bytes >
|
|
176
|
+
limitingResource: bytes > maxEntryBytes
|
|
174
177
|
? "entry"
|
|
175
178
|
: Date.now() < retryAfter
|
|
176
179
|
? "worker"
|
|
@@ -178,6 +181,7 @@ export async function captureProxyBody(entry, logDir, consume) {
|
|
|
178
181
|
? "captures"
|
|
179
182
|
: "bytes",
|
|
180
183
|
estimatedBytes: Number.isFinite(bytes) ? bytes : undefined,
|
|
184
|
+
maxEntryBytes,
|
|
181
185
|
pending: snapshot.pending,
|
|
182
186
|
pendingBytes: snapshot.pendingBytes,
|
|
183
187
|
maxPending: MAX_PENDING,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RequestLogEntry, SSETelemetry } from "../types/index.js";
|
|
2
|
+
/** Content visible to the caller; thinking and empty text are not useful output. */
|
|
3
|
+
export declare function hasUsefulClaudeContent(value: unknown): boolean;
|
|
4
|
+
/** A complete JSON response becomes observable when its body has been read. */
|
|
5
|
+
export declare function observeClaudeJsonOutput(metadata: Record<string, unknown>, value: unknown, at?: number): void;
|
|
6
|
+
/** Keep unavailable observations distinct from a complete stream with no output. */
|
|
7
|
+
export declare function observeClaudeStreamOutput(metadata: Record<string, unknown>, data: SSETelemetry): void;
|
|
8
|
+
/** Final records always explain whether useful-output latency was observed. */
|
|
9
|
+
export declare function claudeOutputLogFields(metadata: Record<string, unknown>, requestStartTime: number): Pick<RequestLogEntry, "firstUsefulOutputMs" | "firstUsefulOutputStatus" | "firstUsefulOutputEvent" | "firstUsefulOutputUnavailableReason">;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Content visible to the caller; thinking and empty text are not useful output. */
|
|
2
|
+
export function hasUsefulClaudeContent(value) {
|
|
3
|
+
if (!value || typeof value !== "object") {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const block = value;
|
|
7
|
+
if (Array.isArray(block.content)) {
|
|
8
|
+
return block.content.some(hasUsefulClaudeContent);
|
|
9
|
+
}
|
|
10
|
+
if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (typeof block.text === "string" && block.text.trim().length > 0) {
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
if (typeof block.refusal === "string" && block.refusal.trim().length > 0) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
return ((block.type === "tool_use" || block.type === "server_tool_use") &&
|
|
20
|
+
typeof block.name === "string" &&
|
|
21
|
+
block.name.length > 0 &&
|
|
22
|
+
block.input !== undefined);
|
|
23
|
+
}
|
|
24
|
+
/** A complete JSON response becomes observable when its body has been read. */
|
|
25
|
+
export function observeClaudeJsonOutput(metadata, value, at = Date.now()) {
|
|
26
|
+
if (!value ||
|
|
27
|
+
typeof value !== "object" ||
|
|
28
|
+
!Array.isArray(value.content)) {
|
|
29
|
+
metadata.firstUsefulOutputStatus = "not_observed";
|
|
30
|
+
metadata.firstUsefulOutputUnavailableReason = "unrecognized_response";
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (hasUsefulClaudeContent(value)) {
|
|
34
|
+
metadata.firstUsefulOutputAt = at;
|
|
35
|
+
metadata.firstUsefulOutputStatus = "observed";
|
|
36
|
+
metadata.firstUsefulOutputEvent = "json_response.content";
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
metadata.firstUsefulOutputStatus = "no_useful_output";
|
|
40
|
+
delete metadata.firstUsefulOutputAt;
|
|
41
|
+
delete metadata.firstUsefulOutputEvent;
|
|
42
|
+
}
|
|
43
|
+
delete metadata.firstUsefulOutputUnavailableReason;
|
|
44
|
+
}
|
|
45
|
+
/** Keep unavailable observations distinct from a complete stream with no output. */
|
|
46
|
+
export function observeClaudeStreamOutput(metadata, data) {
|
|
47
|
+
delete metadata.firstUsefulOutputAt;
|
|
48
|
+
delete metadata.firstUsefulOutputEvent;
|
|
49
|
+
delete metadata.firstUsefulOutputUnavailableReason;
|
|
50
|
+
if (!data.observationIncomplete && data.firstUsefulOutputAt !== undefined) {
|
|
51
|
+
metadata.firstUsefulOutputAt = data.firstUsefulOutputAt;
|
|
52
|
+
metadata.firstUsefulOutputEvent = data.firstUsefulOutputEvent;
|
|
53
|
+
metadata.firstUsefulOutputStatus = "observed";
|
|
54
|
+
}
|
|
55
|
+
else if (!data.observationIncomplete && data.messageStopReceived) {
|
|
56
|
+
metadata.firstUsefulOutputStatus = "no_useful_output";
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
metadata.firstUsefulOutputStatus = "not_observed";
|
|
60
|
+
metadata.firstUsefulOutputUnavailableReason = data.observationIncomplete
|
|
61
|
+
? "incomplete_observation"
|
|
62
|
+
: "stream_incomplete";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Final records always explain whether useful-output latency was observed. */
|
|
66
|
+
export function claudeOutputLogFields(metadata, requestStartTime) {
|
|
67
|
+
const at = metadata.firstUsefulOutputAt;
|
|
68
|
+
if (typeof at === "number" && Number.isFinite(at)) {
|
|
69
|
+
return {
|
|
70
|
+
firstUsefulOutputMs: Math.max(0, at - requestStartTime),
|
|
71
|
+
firstUsefulOutputStatus: "observed",
|
|
72
|
+
firstUsefulOutputEvent: typeof metadata.firstUsefulOutputEvent === "string"
|
|
73
|
+
? metadata.firstUsefulOutputEvent
|
|
74
|
+
: "translated_output",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return metadata.firstUsefulOutputStatus === "no_useful_output"
|
|
78
|
+
? { firstUsefulOutputStatus: "no_useful_output" }
|
|
79
|
+
: {
|
|
80
|
+
firstUsefulOutputStatus: "not_observed",
|
|
81
|
+
firstUsefulOutputUnavailableReason: typeof metadata.firstUsefulOutputUnavailableReason === "string"
|
|
82
|
+
? metadata.firstUsefulOutputUnavailableReason
|
|
83
|
+
: "response_not_observed",
|
|
84
|
+
};
|
|
85
|
+
}
|
package/dist/proxy/codexUsage.js
CHANGED
|
@@ -53,20 +53,22 @@ function usefulOutputItem(item) {
|
|
|
53
53
|
if (item.type === "function_call") {
|
|
54
54
|
return ("arguments" in item &&
|
|
55
55
|
typeof item.arguments === "string" &&
|
|
56
|
-
item.arguments.length > 0);
|
|
56
|
+
item.arguments.trim().length > 0);
|
|
57
57
|
}
|
|
58
58
|
if (item.type === "custom_tool_call") {
|
|
59
59
|
return ("input" in item &&
|
|
60
60
|
typeof item.input === "string" &&
|
|
61
|
-
item.input.length > 0);
|
|
61
|
+
item.input.trim().length > 0);
|
|
62
62
|
}
|
|
63
63
|
if (item.type === "output_text") {
|
|
64
|
-
return ("text" in item &&
|
|
64
|
+
return ("text" in item &&
|
|
65
|
+
typeof item.text === "string" &&
|
|
66
|
+
item.text.trim().length > 0);
|
|
65
67
|
}
|
|
66
68
|
if (item.type === "refusal") {
|
|
67
69
|
return ("refusal" in item &&
|
|
68
70
|
typeof item.refusal === "string" &&
|
|
69
|
-
item.refusal.length > 0);
|
|
71
|
+
item.refusal.trim().length > 0);
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
return ("content" in item &&
|
|
@@ -213,7 +215,7 @@ export function createCodexUsageTap() {
|
|
|
213
215
|
type === "response.refusal.delta" ||
|
|
214
216
|
type === "response.function_call_arguments.delta") &&
|
|
215
217
|
typeof event.delta === "string" &&
|
|
216
|
-
event.delta.length > 0) {
|
|
218
|
+
event.delta.trim().length > 0) {
|
|
217
219
|
if (evidence.firstUsefulOutputAt === undefined) {
|
|
218
220
|
evidence.firstUsefulOutputAt = Date.now();
|
|
219
221
|
evidence.firstUsefulOutputEvent = String(type);
|
|
@@ -232,22 +234,22 @@ export function createCodexUsageTap() {
|
|
|
232
234
|
completedResponse.output.some(usefulOutputItem);
|
|
233
235
|
const usefulDone = (type === "response.output_text.done" &&
|
|
234
236
|
typeof event.text === "string" &&
|
|
235
|
-
event.text.length > 0) ||
|
|
237
|
+
event.text.trim().length > 0) ||
|
|
236
238
|
(type === "response.refusal.done" &&
|
|
237
239
|
typeof event.refusal === "string" &&
|
|
238
|
-
event.refusal.length > 0) ||
|
|
240
|
+
event.refusal.trim().length > 0) ||
|
|
239
241
|
(type === "response.function_call_arguments.done" &&
|
|
240
242
|
typeof event.arguments === "string" &&
|
|
241
|
-
event.arguments.length > 0) ||
|
|
243
|
+
event.arguments.trim().length > 0) ||
|
|
242
244
|
(type === "response.custom_tool_call_input.done" &&
|
|
243
245
|
typeof event.input === "string" &&
|
|
244
|
-
event.input.length > 0) ||
|
|
246
|
+
event.input.trim().length > 0) ||
|
|
245
247
|
((type === "response.content_part.added" ||
|
|
246
248
|
type === "response.content_part.done") &&
|
|
247
249
|
usefulOutputItem(event.part));
|
|
248
250
|
const usefulToolDelta = type === "response.custom_tool_call_input.delta" &&
|
|
249
251
|
typeof event.delta === "string" &&
|
|
250
|
-
event.delta.length > 0;
|
|
252
|
+
event.delta.trim().length > 0;
|
|
251
253
|
if (evidence.firstUsefulOutputAt === undefined &&
|
|
252
254
|
(usefulDone ||
|
|
253
255
|
usefulCompletion ||
|