UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

500 lines (499 loc) • 21.1 kB
import { MIN_STRING_BUDGET_CHARS, REDACTED_VALUE, TRUNCATION_NOTE_ALLOWANCE_CHARS, } from "../constants/telemetry.js"; import { messageRole } from "./chatMessages.js"; import { binaryPlaceholder, declaredMimeType, describeDataUrls, isBinaryKey, redactSecretAssignments, redactSensitiveEntry, } from "./spanSanitizer.js"; import { capStrings, stringify } from "./spanSerialization.js"; export const ATTR = { // GenAI standard (OpenTelemetry semantic conventions) GEN_AI_SYSTEM: "gen_ai.system", GEN_AI_MODEL: "gen_ai.request.model", GEN_AI_OPERATION: "gen_ai.operation.name", GEN_AI_OBSERVATION: "gen_ai.observation.type", GEN_AI_INPUT_TOKENS: "gen_ai.usage.input_tokens", GEN_AI_OUTPUT_TOKENS: "gen_ai.usage.output_tokens", GEN_AI_FINISH_REASON: "gen_ai.response.finish_reason", GEN_AI_COST_USD: "gen_ai.cost_usd", GEN_AI_TOOL_NAME: "gen_ai.tool.name", GEN_AI_TEMPERATURE: "gen_ai.request.temperature", GEN_AI_MAX_TOKENS: "gen_ai.request.max_tokens", NL_PROVIDER: "sdk.provider", NL_MODEL: "sdk.model", NL_STREAM_MODE: "stream_mode", NL_TOOL_COUNT: "tool_count", NL_MESSAGE_COUNT: "message_count", NL_HAS_TOOLS: "has_tools", NL_INPUT_LENGTH: "input_length", NL_OUTPUT_LENGTH: "output_length", NL_REQUEST_ID: "request_id", NL_PATH: "path", NL_HAS_MEMORY: "has_conversation_memory", NL_COST: "cost", NL_STRUCTURED_OUTPUT: "structured_output", NL_HAS_FALLBACK: "has_fallback", MCP_SERVER_ID: "mcp.server_id", MCP_TOOL_NAME: "mcp.tool_name", MCP_TIMEOUT_MS: "mcp.timeout_ms", MCP_TRANSPORT: "mcp.transport", MCP_CIRCUIT_STATE: "mcp.circuit_state", // Session/Memory SESSION_ID: "session.id", USER_ID: "user.id", MEMORY_TYPE: "memory.type", MESSAGE_COUNT: "message.count", CONTENT_LENGTH: "content.length", // RAG RAG_FILE_COUNT: "rag.file_count", RAG_STRATEGY: "rag.strategy", RAG_CHUNK_SIZE: "rag.chunk_size", RAG_TOP_K: "rag.top_k", RAG_RESULT_COUNT: "rag.result_count", // Message building MSG_COUNT: "message.build.count", MSG_HAS_IMAGES: "message.has_images", MSG_HAS_FILES: "message.has_files", MSG_HAS_SYSTEM_PROMPT: "message.has_system_prompt", MSG_TOTAL_CONTENT_LENGTH: "message.total_content_length", MSG_IS_MULTIMODAL: "message.is_multimodal", // File processing FILE_NAME: "file.name", FILE_MIMETYPE: "file.mimetype", FILE_SIZE_BYTES: "file.size_bytes", FILE_CATEGORY: "file.category", FILE_OUTPUT_LENGTH: "file.output_length", FILE_SUCCESS: "file.success", FILE_PROCESSOR_USED: "file.processor_used", FILE_CONFIDENCE: "file.detection_confidence", FILE_HAS_IMAGES: "file.has_images", FILE_IMAGE_COUNT: "file.image_count", FILE_ERROR: "file.error", FILE_SKIPPED_REASON: "file.skipped_reason", FILE_TOTAL_COUNT: "file.total_count", FILE_INCLUDED_COUNT: "file.included_count", FILE_CONTENT_TYPE: "file.content_type", // Video processing VIDEO_DURATION_SEC: "video.duration_sec", VIDEO_WIDTH: "video.width", VIDEO_HEIGHT: "video.height", VIDEO_CODEC: "video.codec", VIDEO_AUDIO_CODEC: "video.audio_codec", VIDEO_FORMAT: "video.format", VIDEO_BITRATE: "video.bitrate", VIDEO_KEYFRAMES_EXTRACTED: "video.keyframes_extracted", VIDEO_TARGET_FRAMES: "video.target_frames", VIDEO_INTERVAL_SEC: "video.interval_sec", VIDEO_FRAME_QUALITY: "video.frame_quality", VIDEO_FRAME_RESIZE_PX: "video.frame_resize_px", VIDEO_FRAMES_RESIZED: "video.frames_resized", VIDEO_TOTAL_FRAME_BYTES: "video.total_frame_bytes", VIDEO_HAS_SUBTITLES: "video.has_subtitles", VIDEO_SUBTITLE_LENGTH: "video.subtitle_length", VIDEO_TEXT_CONTENT_LENGTH: "video.text_content_length", VIDEO_HAS_METADATA: "video.has_metadata", VIDEO_HAS_KEYFRAMES: "video.has_keyframes", VIDEO_TEMP_PATH: "video.temp_path", VIDEO_BYTES_WRITTEN: "video.bytes_written", VIDEO_WRITE_DURATION_MS: "video.write_duration_ms", VIDEO_STEP: "video.step", // Context CONTEXT_STAGE: "context.compaction_stage", CONTEXT_TOKENS_BEFORE: "context.tokens_before", CONTEXT_TOKENS_AFTER: "context.tokens_after", // Middleware MW_COUNT: "middleware.count", MW_NAMES: "middleware.names", // Autoresearch AR_TAG: "autoresearch.tag", AR_BRANCH: "autoresearch.branch", AR_PHASE: "autoresearch.phase", AR_PHASE_FROM: "autoresearch.phase_from", AR_PHASE_TO: "autoresearch.phase_to", AR_RUN_COUNT: "autoresearch.run_count", AR_KEEP_COUNT: "autoresearch.keep_count", AR_STATUS: "autoresearch.status", AR_METRIC: "autoresearch.metric", AR_BEST_METRIC: "autoresearch.best_metric", AR_DIRECTION: "autoresearch.metric_direction", AR_COMMIT: "autoresearch.commit", AR_DURATION_MS: "autoresearch.duration_ms", AR_DESCRIPTION: "autoresearch.description", AR_ERROR_CODE: "autoresearch.error_code", }; /** Default ceiling for serialized span attribute values. */ export const SPAN_ATTRIBUTE_MAX_CHARS = 40_000; /** Smallest ceiling accepted, so the system and question openings still fit. */ const SPAN_ATTRIBUTE_MIN_CHARS = 10_000; /** * The ceiling in force: `NEUROLINK_SPAN_ATTRIBUTE_MAX_CHARS` when it is a * positive whole number (raised to the minimum if lower), otherwise the * default. Read on every call, so a change applies to the next span. */ export function spanAttributeMaxChars() { const configured = Number(process.env.NEUROLINK_SPAN_ATTRIBUTE_MAX_CHARS?.trim()); if (!Number.isSafeInteger(configured) || configured <= 0) { return SPAN_ATTRIBUTE_MAX_CHARS; } return Math.max(SPAN_ATTRIBUTE_MIN_CHARS, configured); } /** Opening of the system prompt a model-request attribute always keeps. */ const SYSTEM_PROMPT_FLOOR_CHARS = 2_000; /** Room the current question always gets in a model-request attribute. */ const QUESTION_FLOOR_CHARS = 4_000; const REDACTION_FAILED_VALUE = "[content omitted: redaction failed]"; const SENSITIVE_KEY_PATTERN = /(authorization|password|passwd|secret|apikey|privatekey|tokens?$|credential|signature)/; const COOKIE_KEY_PATTERN = /^(set)?cookies?$/; // Cheap pre-check: text without any of these is returned untouched, so the // common case costs one regex scan and stays byte-identical. const SENSITIVE_HINT_PATTERN = /authorization|password|passwd|secret|api[_-]?key|private[_-]?key|token|credential|signature|cookie|bearer|sk-|gh[pousr]_|github_pat_|AKIA|xox[baprs]-/i; function isSensitiveKey(key) { const normalized = key.replace(/[_-]/g, "").toLowerCase(); return (SENSITIVE_KEY_PATTERN.test(normalized) || COOKIE_KEY_PATTERN.test(normalized)); } // JSON that a tool returned as text, carried inside JSON the model sees as a // string, has its quotes escaped once per level of nesting, and each level // doubles the backslashes before the quote: one, three, seven. const ESCAPED_QUOTE_BACKSLASHES = [1, 3, 7]; const ESCAPED_PAIR_PATTERNS = ESCAPED_QUOTE_BACKSLASHES.map((backslashes) => { const quote = `${"\\\\".repeat(backslashes)}"`; return new RegExp(`${quote}([^"\\\\]{1,80})${quote}(\\s*:\\s*)${quote}((?:(?!${quote})[\\s\\S]){0,8192})${quote}`, "g"); }); /** * Masks credentials in text that is about to be written to a span: bearer * tokens, `name=value` credentials in URLs and form bodies, and the values of * sensitive keys in JSON text (tool results arrive as JSON strings). */ function redactText(text) { if (!SENSITIVE_HINT_PATTERN.test(text)) { return text; } let redacted = redactSecretAssignments(text.replace(/\bBearer\s+[\w.\-+/=]{16,}/gi, `Bearer ${REDACTED_VALUE}`)).replace(/"([^"\\]{1,80})"(\s*:\s*)"(?:[^"\\]|\\.)*"/g, (match, key, separator) => isSensitiveKey(key) ? `"${key}"${separator}"${REDACTED_VALUE}"` : match); ESCAPED_QUOTE_BACKSLASHES.forEach((backslashes, index) => { const quote = `${"\\".repeat(backslashes)}"`; redacted = redacted.replace(ESCAPED_PAIR_PATTERNS[index], (match, key, separator) => isSensitiveKey(key) ? `${quote}${key}${quote}${separator}${quote}${REDACTED_VALUE}${quote}` : match); }); return redacted; } function redactNode(value, ancestors) { if (typeof value === "string") { return redactText(describeDataUrls(value)); } if (value === null || typeof value !== "object") { return value; } if (ancestors.has(value)) { return "[circular]"; } if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { return binaryPlaceholder(value, undefined); } ancestors.add(value); try { if ("toJSON" in value && typeof value.toJSON === "function") { return redactNode(value.toJSON(), ancestors); } if (Array.isArray(value)) { return value.map((entry) => redactNode(entry, ancestors)); } // No prototype, so a key named __proto__ is data rather than an assignment. const redacted = Object.create(null); const mimeType = declaredMimeType(value); for (const [key, entry] of Object.entries(value)) { if (isSensitiveKey(key) && (typeof entry === "string" || (typeof entry === "object" && entry !== null))) { redacted[key] = redactSensitiveEntry(key, entry); continue; } const placeholder = isBinaryKey(key) ? binaryPlaceholder(entry, mimeType) : undefined; redacted[key] = placeholder ?? redactNode(entry, ancestors); } return redacted; } finally { ancestors.delete(value); } } /** * @description Returns a copy of a value with credentials masked: values under keys such as `access_token`, `password`, `authorization` or `set-cookie`, bearer tokens, and `name=value` credentials inside text. Purely numeric values after a credential-looking name (e.g. `max_tokens: 4096`) are left alone. * @param value {JsonValue | object} - Content to redact; objects and arrays are walked recursively. * @returns {JsonValue | object} A redacted copy, serializing exactly as the input when nothing in it is sensitive. * @throws {Error} When the value cannot be walked; tracing callers use `spanContentAttribute`, which turns that into an omitted value. */ export function redactSpanContent(value) { return redactNode(value, new WeakSet()); } function redactSafely(value) { try { return redactSpanContent(value); } catch { return REDACTION_FAILED_VALUE; } } /** * `spanJsonAttribute` for content a person or model wrote or a tool returned * (arguments, results, answers), with credentials masked first. Use the plain * form for metadata such as usage counts and server ids. * * A structured value over the cap is shortened by cutting every string to a * per-string budget in one walk, tightening the budget only if the result * still does not fit, so the attribute stays valid JSON instead of ending * mid-token. */ export function spanContentAttribute(value, maxChars = spanAttributeMaxChars()) { const redacted = redactSafely(value); if (redacted === null || typeof redacted !== "object") { return spanJsonAttribute(redacted, maxChars); } let serialized = stringify(redacted); let budget = Math.floor(maxChars / 4); while (serialized.length > maxChars && budget >= MIN_STRING_BUDGET_CHARS) { serialized = stringify(capStrings(redacted, budget)); budget = Math.floor(budget / 4); } return spanJsonAttribute(serialized, maxChars); } /** * Serialize an arbitrary value for a span attribute, hard-capped at * `maxChars` so a pathological prompt or tool result can't put megabytes * on a single span. Strings pass through unserialized; everything else is * JSON-stringified with a String() fallback for circular structures. */ export function spanJsonAttribute(value, maxChars = spanAttributeMaxChars()) { const serialized = stringify(value); if (serialized.length > maxChars) { const truncationSuffix = `...[truncated ${serialized.length - maxChars} chars]`; const keepLength = Math.max(0, maxChars - truncationSuffix.length); return `${serialized.slice(0, keepLength)}${truncationSuffix}`; } return serialized; } /** The content blocks of an Anthropic-shaped message; none for plain text. */ function messageBlocks(message) { if (message === null || typeof message !== "object") { return []; } return "content" in message && Array.isArray(message.content) ? message.content : []; } /** One field of a content block, or undefined when the block lacks it. */ function blockField(block, field) { return block !== null && typeof block === "object" && field in block ? Reflect.get(block, field) : undefined; } /** Whether a message is the user's, as opposed to the assistant's. */ function isUserMessage(message) { return (message !== null && typeof message === "object" && "role" in message && message.role === "user"); } /** A user message that says something, not one that only returns tool results. */ function isUserQuestion(message) { if (!isUserMessage(message)) { return false; } return (("content" in message && typeof message.content === "string") || messageBlocks(message).some((block) => blockField(block, "type") === "text")); } /** * Removes every tool result whose call is not among `messages`, including one * that shares a message with text, and drops messages left with nothing. */ function withoutUnansweredResults(messages) { const calls = new Set(messages .flatMap(messageBlocks) .filter((block) => blockField(block, "type") === "tool_use") .map((block) => blockField(block, "id"))); return messages.flatMap((message) => { const blocks = messageBlocks(message); const answered = blocks.filter((block) => blockField(block, "type") !== "tool_result" || calls.has(blockField(block, "tool_use_id"))); if (answered.length === blocks.length) { return [message]; } if (answered.length === 0 || message === null || typeof message !== "object") { return []; } return [{ ...message, content: answered }]; }); } /** * Serialize a model request for a span attribute without losing the * conversation to the cap. * * A head cut keeps the system prompt and drops everything after it, so a long * system prompt hides every message, the user's question included. Here the * prompt, the current question and this turn's newest messages are budgeted * first, earlier messages are dropped whole and counted, and the system prompt * gets whatever room is left. A tool result is never kept without the call it * answers. The result is always valid JSON. */ export function spanModelRequestAttribute(rawRequest, maxChars = spanAttributeMaxChars()) { // Credentials are masked before anything is measured, so budgets apply to // what is actually written. Each message is masked on its own: one that // cannot be walked is omitted without losing the rest of the conversation. const request = { ...(rawRequest.system !== undefined ? { system: redactSafely(rawRequest.system) } : {}), ...(rawRequest.messages !== undefined ? { messages: rawRequest.messages.map((message) => redactSafely(message)), } : {}), ...(rawRequest.prompt !== undefined ? { prompt: redactText(describeDataUrls(rawRequest.prompt)) } : {}), }; const jsonLength = (value) => { try { return (JSON.stringify(value) ?? "null").length; } catch { return Number.POSITIVE_INFINITY; } }; // Never returns an empty string for non-empty text: when nothing fits, the // truncation note alone says the value existed and how long it was. const fitText = (text, budget) => { if (jsonLength(text) <= budget) { return text; } let keep = budget; for (let attempt = 0; attempt < 12 && keep > 0; attempt++) { const candidate = `${text.slice(0, keep)}...[truncated ${text.length - keep} chars]`; const size = jsonLength(candidate); if (size <= budget) { return candidate; } keep = Math.floor((keep * budget) / size) - 1; } return `[truncated ${text.length} chars]`; }; // Room for keys, brackets and the omitted-message count. let remaining = maxChars - 128; // The system prompt always keeps its opening, so a trace still shows which // prompt was in use. Reserved before the messages are budgeted and returned // to the system prompt afterwards; a request that fits is unaffected. const systemFloor = request.system !== undefined ? Math.min(jsonLength(request.system), SYSTEM_PROMPT_FLOOR_CHARS + TRUNCATION_NOTE_ALLOWANCE_CHARS) : 0; remaining -= systemFloor; let prompt; if (request.prompt !== undefined) { prompt = fitText(request.prompt, remaining); remaining -= jsonLength(prompt); } // Shortens a copy of the message's longest strings; the shape survives. const fitMessage = (message, budget) => { if (jsonLength(message) <= budget) { return message; } let copy; try { copy = JSON.parse(stringify(message)); } catch { copy = undefined; } if (copy !== null && typeof copy === "object") { for (let stringBudget = Math.floor(budget / 4); jsonLength(copy) > budget && stringBudget >= MIN_STRING_BUDGET_CHARS; stringBudget = Math.floor(stringBudget / 4)) { capStrings(copy, stringBudget); } if (jsonLength(copy) <= budget) { return copy; } } return { role: messageRole(message), content: fitText(stringify(message), Math.max(0, budget - 32)), }; }; const messages = request.messages ?? []; // The current question is the newest user message with something to say; // everything after it is this turn's tool calls and their results. let questionIndex = -1; for (let index = messages.length - 1; index >= 0; index--) { if (isUserQuestion(messages[index])) { questionIndex = index; break; } } const questionFloor = questionIndex >= 0 ? Math.min(jsonLength(messages[questionIndex]) + 1, QUESTION_FLOOR_CHARS + TRUNCATION_NOTE_ALLOWANCE_CHARS) : 0; remaining -= questionFloor; const turn = []; let turnComplete = true; for (let index = messages.length - 1; index > questionIndex; index--) { const size = jsonLength(messages[index]) + 1; if (size <= remaining) { turn.unshift(messages[index]); remaining -= size; continue; } turnComplete = false; if (turn.length === 0 && remaining > 64) { const excerpt = fitText(stringify(messages[index]), remaining - 32); turn.unshift({ role: messageRole(messages[index]), content: excerpt }); remaining -= jsonLength(excerpt) + 32; } break; } remaining += questionFloor; let kept = []; if (questionIndex >= 0) { const question = fitMessage(messages[questionIndex], remaining - 1); remaining -= jsonLength(question) + 1; const history = []; for (let index = questionIndex - 1; turnComplete && index >= 0; index--) { const size = jsonLength(messages[index]) + 1; if (size > remaining) { break; } history.unshift(messages[index]); remaining -= size; } kept.push(...history, question); } kept.push(...turn); if (kept.length < messages.length) { kept = withoutUnansweredResults(kept); } const omittedMessages = messages.length - kept.length; remaining += systemFloor; const leading = []; if (request.system !== undefined) { leading.push({ role: "system", content: jsonLength(request.system) <= remaining ? request.system : fitText(stringify(request.system), Math.max(0, remaining)), }); } if (omittedMessages > 0) { leading.push({ role: "system", content: `[${omittedMessages} earlier messages omitted]`, }); } const conversation = [ ...leading, ...kept, ...(prompt !== undefined ? [{ role: "user", content: prompt }] : []), ]; try { return JSON.stringify(conversation); } catch { return spanJsonAttribute(conversation, maxChars); } }