From d7713e979219c014f04688b363caaea2df099ab2 Mon Sep 17 00:00:00 2001 From: Sachin Sharma Date: Sat, 18 Apr 2026 15:29:54 +0530 Subject: [PATCH] refactor(types): close OTel gaps, consolidate 262 scattered types, harden proxy retry - Close 28/28 OTel/Langfuse telemetry gaps across all providers - Relocate 262 local type declarations into canonical src/lib/types/ - Add no-local-type-alias ESLint rule to prevent future violations - Add per-account coolingUntil + maybeResetPrimaryToHome for proxy 429 handling - Increase MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES to 10 - Fix ReDoS in memoryRetrievalTools, Array.isArray validation in proxy - Add failure generation:end emits for Bedrock, Google AI Studio, Ollama - Fix Pipeline B spanId uniqueness, step_count source, stream span lifecycle --- .gitignore | 1 + CLAUDE.md | 3 +- eslint-rules/index.cjs | 4 + eslint-rules/no-local-type-alias.cjs | 87 +++ eslint.config.js | 1 + package.json | 1 + pnpm-lock.yaml | 30 + src/cli/commands/auth.ts | 6 +- src/cli/commands/autoresearch.ts | 21 +- src/cli/commands/config.ts | 21 +- src/cli/commands/evaluate.ts | 98 +--- src/cli/commands/mcp.ts | 58 +- src/cli/commands/proxy.ts | 46 +- src/cli/commands/rag.ts | 43 +- src/cli/commands/serve.ts | 33 +- src/cli/commands/server.ts | 24 +- src/cli/commands/setup-anthropic.ts | 31 +- src/cli/commands/setup-azure.ts | 34 +- src/cli/commands/setup-bedrock.ts | 67 +-- src/cli/commands/setup-gcp.ts | 57 +- src/cli/commands/setup-google-ai.ts | 31 +- src/cli/commands/setup-huggingface.ts | 6 +- src/cli/commands/setup-mistral.ts | 6 +- src/cli/commands/setup-openai.ts | 33 +- src/cli/commands/setup.ts | 25 +- src/cli/commands/task.ts | 47 +- src/cli/commands/telemetry.ts | 3 +- src/cli/commands/voiceServer.ts | 5 +- src/cli/factories/ollamaCommandFactory.ts | 12 +- src/cli/loop/session.ts | 45 +- src/cli/utils/interactiveSetup.ts | 23 +- src/cli/utils/videoFileUtils.ts | 19 +- src/lib/action/actionInputs.ts | 13 +- src/lib/adapters/tts/cartesiaHandler.ts | 7 +- src/lib/adapters/video/directorPipeline.ts | 32 +- src/lib/adapters/video/vertexVideoHandler.ts | 13 +- src/lib/agent/directTools.ts | 30 +- src/lib/artifacts/artifactStore.ts | 4 +- src/lib/auth/AuthProviderFactory.ts | 16 +- src/lib/auth/anthropicOAuth.ts | 53 +- src/lib/auth/index.ts | 16 + src/lib/auth/middleware/AuthMiddleware.ts | 91 +-- src/lib/auth/middleware/rateLimitByUser.ts | 134 +---- src/lib/auth/providers/CognitoProvider.ts | 8 +- src/lib/auth/providers/KeycloakProvider.ts | 8 +- src/lib/auth/providers/auth0.ts | 26 +- src/lib/auth/sessionManager.ts | 101 +++- src/lib/auth/tokenStore.ts | 73 ++- src/lib/autoresearch/tools.ts | 17 +- src/lib/client/aiSdkAdapter.ts | 16 +- src/lib/client/httpClient.ts | 22 + src/lib/client/sseClient.ts | 59 +- src/lib/client/streamingClient.ts | 32 ++ src/lib/client/wsClient.ts | 138 +++-- src/lib/context/budgetChecker.ts | 4 + src/lib/context/contextCompactor.ts | 427 +++++++------- src/lib/context/fileSummarizationService.ts | 15 +- src/lib/context/summarizationEngine.ts | 66 ++- src/lib/core/baseProvider.ts | 347 +++++------- src/lib/core/infrastructure/baseRegistry.ts | 11 +- src/lib/core/modules/GenerationHandler.ts | 20 + src/lib/core/modules/StreamHandler.ts | 9 + src/lib/core/modules/ToolsManager.ts | 18 +- src/lib/evaluation/BatchEvaluator.ts | 106 +--- src/lib/evaluation/EvaluationAggregator.ts | 132 +---- src/lib/evaluation/EvaluatorFactory.ts | 14 +- src/lib/evaluation/EvaluatorRegistry.ts | 59 +- src/lib/evaluation/errors/EvaluationError.ts | 37 +- src/lib/evaluation/hooks/langfuseAdapter.ts | 45 +- .../evaluation/hooks/observabilityHooks.ts | 64 +-- .../pipeline/strategies/batchStrategy.ts | 110 ++-- src/lib/evaluation/ragasEvaluator.ts | 127 +++-- .../evaluation/reporting/metricsCollector.ts | 68 +-- .../evaluation/reporting/reportGenerator.ts | 19 +- .../scorers/llm/biasDetectionScorer.ts | 7 +- .../scorers/llm/contextRelevancyScorer.ts | 7 +- .../scorers/llm/faithfulnessScorer.ts | 2 +- .../scorers/llm/hallucinationScorer.ts | 6 +- .../scorers/llm/toneConsistencyScorer.ts | 7 +- .../evaluation/scorers/llm/toxicityScorer.ts | 2 +- .../scorers/rule/contentSimilarityScorer.ts | 49 +- .../evaluation/scorers/rule/formatScorer.ts | 81 +-- .../scorers/rule/keywordCoverageScorer.ts | 32 +- .../evaluation/scorers/rule/lengthScorer.ts | 54 +- src/lib/evaluation/scorers/scorerRegistry.ts | 7 +- src/lib/factories/providerFactory.ts | 32 +- src/lib/image-gen/ImageGenService.ts | 4 +- src/lib/mcp/batching/requestBatcher.ts | 233 ++++---- src/lib/mcp/caching/toolCache.ts | 38 +- src/lib/mcp/httpRateLimiter.ts | 4 + src/lib/mcp/httpRetryHandler.ts | 18 +- src/lib/mcp/mcpClientFactory.ts | 4 + src/lib/mcp/multiServerManager.ts | 15 +- .../servers/aiProviders/aiAnalysisTools.ts | 23 +- .../servers/aiProviders/aiWorkflowTools.ts | 46 +- src/lib/mcp/toolDiscoveryService.ts | 178 +++--- src/lib/mcp/toolRegistry.ts | 13 +- src/lib/memory/memoryRetrievalTools.ts | 411 ++++++++------ src/lib/neurolink.ts | 255 +++++++-- src/lib/observability/exporterRegistry.ts | 34 +- .../observability/exporters/sentryExporter.ts | 26 +- src/lib/observability/metricsAggregator.ts | 37 +- src/lib/observability/tokenTracker.ts | 36 +- src/lib/observability/utils/spanSerializer.ts | 29 +- src/lib/processors/base/BaseFileProcessor.ts | 174 +++--- src/lib/processors/document/ExcelProcessor.ts | 6 +- src/lib/processors/errors/errorHelpers.ts | 31 +- src/lib/processors/errors/errorSerializer.ts | 47 +- .../processors/registry/ProcessorRegistry.ts | 27 +- src/lib/providers/amazonBedrock.ts | 261 ++++++++- src/lib/providers/amazonSagemaker.ts | 41 +- src/lib/providers/anthropic.ts | 23 + src/lib/providers/azureOpenai.ts | 10 + src/lib/providers/googleAiStudio.ts | 103 +++- src/lib/providers/googleVertex.ts | 63 ++- src/lib/providers/huggingFace.ts | 10 + src/lib/providers/litellm.ts | 10 + src/lib/providers/mistral.ts | 10 + src/lib/providers/ollama.ts | 122 +++- src/lib/providers/openAI.ts | 35 +- src/lib/providers/openRouter.ts | 10 + src/lib/providers/openaiCompatible.ts | 10 + src/lib/providers/sagemaker/detection.ts | 67 +-- src/lib/providers/sagemaker/diagnostics.ts | 27 +- src/lib/providers/sagemaker/language-model.ts | 25 +- src/lib/proxy/proxyConfig.ts | 5 +- src/lib/proxy/proxyEnv.ts | 25 +- src/lib/proxy/proxyFetch.ts | 20 +- src/lib/proxy/proxyTracer.ts | 72 +-- src/lib/proxy/quietDetector.ts | 8 +- src/lib/proxy/rawStreamCapture.ts | 14 +- src/lib/proxy/requestLogger.ts | 38 +- src/lib/proxy/sseInterceptor.ts | 102 +--- src/lib/proxy/updateChecker.ts | 21 +- src/lib/proxy/updateState.ts | 18 +- src/lib/rag/chunkers/BaseChunker.ts | 69 ++- src/lib/rag/chunking/jsonChunker.ts | 19 +- src/lib/rag/document/MDocument.ts | 21 +- src/lib/rag/errors/RAGError.ts | 3 +- src/lib/rag/ragIntegration.ts | 109 ++-- src/lib/rag/reranker/reranker.ts | 340 +++++++----- src/lib/rag/resilience/CircuitBreaker.ts | 11 +- src/lib/rag/resilience/RetryHandler.ts | 5 +- src/lib/rag/retrieval/vectorQueryTool.ts | 216 ++++---- src/lib/sdk/toolRegistration.ts | 74 +-- src/lib/server/middleware/abortSignal.ts | 16 +- src/lib/server/middleware/auth.ts | 28 +- src/lib/server/middleware/common.ts | 103 ++-- src/lib/server/middleware/deprecation.ts | 23 +- src/lib/server/middleware/rateLimit.ts | 96 +--- src/lib/server/middleware/validation.ts | 114 +--- src/lib/server/openapi/generator.ts | 53 +- src/lib/server/routes/agentRoutes.ts | 215 +++++--- src/lib/server/routes/claudeProxyRoutes.ts | 271 ++++++--- src/lib/server/routes/healthRoutes.ts | 405 ++++++++------ src/lib/server/routes/index.ts | 32 +- src/lib/server/routes/mcpRoutes.ts | 85 ++- src/lib/server/routes/memoryRoutes.ts | 66 ++- src/lib/server/routes/openApiRoutes.ts | 134 +++-- src/lib/server/routes/toolRoutes.ts | 351 +++++++----- src/lib/server/streaming/dataStream.ts | 60 +- src/lib/server/voice/voiceWebSocketHandler.ts | 32 +- .../ai/observability/instrumentation.ts | 160 +++++- src/lib/session/globalSessionState.ts | 15 +- src/lib/telemetry/traceContext.ts | 22 + src/lib/telemetry/tracers.ts | 2 + src/lib/types/action.ts | 3 + src/lib/types/artifact.ts | 6 + src/lib/types/auth.ts | 195 ++++++- src/lib/types/autoresearch.ts | 17 + src/lib/types/cli.ts | 520 ++++++++++++++++++ src/lib/types/client.ts | 33 ++ src/lib/types/common.ts | 70 +-- src/lib/types/context.ts | 6 + src/lib/types/evaluation.ts | 410 ++++++++++++++ src/lib/types/file.ts | 5 + src/lib/types/mcp.ts | 144 ++++- src/lib/types/middleware.ts | 142 +++++ src/lib/types/multimodal.ts | 77 +++ src/lib/types/observability.ts | 105 ++++ src/lib/types/processor.ts | 63 +++ src/lib/types/providers.ts | 187 ++++++- src/lib/types/proxy.ts | 399 +++++++++++++- src/lib/types/rag.ts | 69 +++ src/lib/types/scorer.ts | 224 ++++++++ src/lib/types/server.ts | 127 +++++ src/lib/types/span.ts | 1 + src/lib/types/tools.ts | 56 ++ src/lib/types/tts.ts | 7 + src/lib/types/utilities.ts | 32 ++ src/lib/types/workflow.ts | 20 + src/lib/utils/async/retry.ts | 43 +- src/lib/utils/fileDetector.ts | 8 +- src/lib/utils/imageCompressor.ts | 35 +- src/lib/utils/messageBuilder.ts | 28 +- src/lib/utils/rateLimiter.ts | 17 +- src/lib/utils/redis.ts | 4 +- src/lib/utils/toolEndEmitter.ts | 79 +++ src/lib/workflow/config.ts | 40 +- src/lib/workflow/core/ensembleExecutor.ts | 59 ++ src/lib/workflow/core/judgeScorer.ts | 32 ++ src/lib/workflow/core/responseConditioner.ts | 31 ++ src/lib/workflow/core/workflowRunner.ts | 506 +++++++++-------- 203 files changed, 8186 insertions(+), 5417 deletions(-) create mode 100644 eslint-rules/no-local-type-alias.cjs create mode 100644 src/lib/telemetry/traceContext.ts create mode 100644 src/lib/utils/toolEndEmitter.ts diff --git a/.gitignore b/.gitignore index 1b224ec8a..f2f0bf5c6 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,4 @@ docs/mastra-features-implementation # Generated docs search index docs-site/static/search-index.json .claude/worktrees/ +.type-consolidation/ diff --git a/CLAUDE.md b/CLAUDE.md index e6752dd3a..8898d8329 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,11 @@ These are non-negotiable. Violating them breaks the build or introduces bugs. 13. **Barrel-only imports for internal types** — Code outside `src/lib/types/` must import internal types from the barrel (`../types/index.js` or `../types`), never from specific type files (`../types/rag.js`, `../types/mcp.js`). External library types (`zod`, `@ai-sdk/provider`, etc.) can be imported normally. Files inside `src/lib/types/` are exempt (they import from each other). -**Enforcement:** All rules (7-13) are enforced by custom ESLint rules in `eslint-rules/`. Run `pnpm run lint` (or the pre-commit hook) — no shell scripts, no regex heuristics, everything AST-based. +**Enforcement:** All rules (2, 7-13) are enforced by custom ESLint rules in `eslint-rules/`. Run `pnpm run lint` (or the pre-commit hook) — no shell scripts, no regex heuristics, everything AST-based. | Rule | ESLint rule | | -------- | ---------------------------------------- | +| 2 | `neurolink/no-local-type-alias` | | 7 | `neurolink/no-interface` | | 8 | `neurolink/no-types-suffix-filename` | | 9 | `neurolink/unique-type-names` | diff --git a/eslint-rules/index.cjs b/eslint-rules/index.cjs index 8efd9a62d..2b7e6446e 100644 --- a/eslint-rules/index.cjs +++ b/eslint-rules/index.cjs @@ -14,6 +14,9 @@ * neurolink/no-local-types-folder → Rules 11 & 11b: Types must live in src/lib/types/. * neurolink/no-type-export-outside-types → Rule 12: No `export type` outside src/lib/types/. * neurolink/barrel-type-imports → Rule 13: Internal type imports must use the barrel. + * neurolink/no-local-type-alias → Rule 2 (strict): No `type X = ...` alias outside + * src/lib/types/ (catches non-exported aliases + * that the Rule 12 rule misses). */ "use strict"; @@ -27,5 +30,6 @@ module.exports = { "no-local-types-folder": require("./no-local-types-folder.cjs"), "no-type-export-outside-types": require("./no-type-export-outside-types.cjs"), "barrel-type-imports": require("./barrel-type-imports.cjs"), + "no-local-type-alias": require("./no-local-type-alias.cjs"), }, }; diff --git a/eslint-rules/no-local-type-alias.cjs b/eslint-rules/no-local-type-alias.cjs new file mode 100644 index 000000000..96670f8c5 --- /dev/null +++ b/eslint-rules/no-local-type-alias.cjs @@ -0,0 +1,87 @@ +/** + * Rule 2 enforcement: No type-alias declarations outside src/lib/types/. + * + * Catches every form of local type-alias declaration that lives outside the + * canonical types folder, whether or not the alias is exported: + * + * type X = { ... }; // local alias (new enforcement) + * export type X = { ... }; // exported alias (also caught by + * // Rule 12 — this rule is a safety net) + * type X = Foo; // generic alias + * + * Exempt paths: + * - src/lib/types/** (canonical types folder) + * - src/test/**, **\/*.test.ts (test fixtures may declare throwaway aliases) + * - eslint-rules/** (AST-manipulation rules genuinely need local types) + * + * Special allowances: + * - None. Every violation should be fixed by moving the type into + * src/lib/types/ and importing it back through the barrel. If a genuine + * exception is needed (e.g. a type derived from a local runtime value + * that cannot be materialised), add an explicit eslint-disable-next-line + * with a comment justifying the exception. + */ + +"use strict"; + +/** Returns true if the file is inside src/lib/types/ (canonical types folder). */ +function isInsideTypesFolder(filename) { + const normalized = filename.replace(/\\/g, "/"); + return /\/src\/lib\/types\//.test(normalized); +} + +/** Returns true if the file is a test fixture or spec file. */ +function isTestFile(filename) { + const normalized = filename.replace(/\\/g, "/"); + return ( + /\/src\/test\//.test(normalized) || + /\.test\.ts$/.test(normalized) || + /\.spec\.ts$/.test(normalized) || + /\/test\//.test(normalized) + ); +} + +/** Returns true if the file is an ESLint rule itself (rule implementation). */ +function isEslintRule(filename) { + const normalized = filename.replace(/\\/g, "/"); + return /\/eslint-rules\//.test(normalized); +} + +/** @type {import("eslint").Rule.RuleModule} */ +module.exports = { + meta: { + type: "problem", + docs: { + description: + "Disallow any `type X = ...` alias declaration outside src/lib/types/ (Critical Rule 2).", + }, + schema: [], + messages: { + noLocalTypeAlias: + "Type alias `{{name}}` must live in src/lib/types/, not here. Move the declaration into the appropriate barrel file and import it back via the types barrel. See CLAUDE.md Critical Rule 2.", + }, + }, + + create(context) { + const filename = context.filename || context.getFilename(); + if ( + isInsideTypesFolder(filename) || + isTestFile(filename) || + isEslintRule(filename) + ) { + return {}; + } + + return { + TSTypeAliasDeclaration(node) { + context.report({ + node, + messageId: "noLocalTypeAlias", + data: { + name: node.id && node.id.name ? node.id.name : "", + }, + }); + }, + }; + }, +}; diff --git a/eslint.config.js b/eslint.config.js index 526f44a57..4769a937d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -98,6 +98,7 @@ export default [ "neurolink/no-local-types-folder": "error", // Rules 11 & 11b "neurolink/no-type-export-outside-types": "error", // Rule 12 "neurolink/barrel-type-imports": "error", // Rule 13 + "neurolink/no-local-type-alias": "error", // Rule 2 (strict) // Disable base rules that are covered by TypeScript "no-unused-vars": "off", diff --git a/package.json b/package.json index 1657d9bdc..05f94268c 100644 --- a/package.json +++ b/package.json @@ -351,6 +351,7 @@ "shell-quote": "^1.8.3", "svelte": "^5.53.6", "svelte-check": "^4.4.4", + "ts-morph": "^24.0.0", "tslib": "^2.8.1", "tsx": "^4.21.0", "typedoc": "^0.28.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98bdffde4..89ab22ca3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,6 +408,9 @@ importers: svelte-check: specifier: ^4.4.4 version: 4.4.5(picomatch@4.0.4)(svelte@5.55.0)(typescript@5.9.3) + ts-morph: + specifier: ^24.0.0 + version: 24.0.0 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -3413,6 +3416,9 @@ packages: '@tootallnate/quickjs-emscripten@0.23.0': resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@ts-morph/common@0.25.0': + resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -4120,6 +4126,9 @@ packages: resolution: {integrity: sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA==} engines: {node: '>=8.0.0'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -6121,6 +6130,9 @@ packages: partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -6980,6 +6992,9 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-morph@24.0.0: + resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -11726,6 +11741,12 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} + '@ts-morph/common@0.25.0': + dependencies: + minimatch: 10.2.4 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -12529,6 +12550,8 @@ snapshots: type-is: 1.6.18 optional: true + code-block-writer@13.0.3: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -14586,6 +14609,8 @@ snapshots: partial-json@0.1.7: {} + path-browserify@1.0.1: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -15627,6 +15652,11 @@ snapshots: dependencies: typescript: 5.9.3 + ts-morph@24.0.0: + dependencies: + '@ts-morph/common': 0.25.0 + code-block-writer: 13.0.3 + tslib@2.8.1: {} tsscmp@1.0.6: diff --git a/src/cli/commands/auth.ts b/src/cli/commands/auth.ts index da5ff3c86..c200da773 100644 --- a/src/cli/commands/auth.ts +++ b/src/cli/commands/auth.ts @@ -37,11 +37,12 @@ import { OAUTH_BETA_HEADERS, } from "../../lib/auth/anthropicOAuth.js"; import type { + AccountQuota, AuthCommandArgs, - StoredCredentials, AuthStatusResult, OAuthTokens as OAuthTokensType, - AccountQuota, + StoredCredentials, + SupportedProvider, } from "../../lib/types/index.js"; import { loadAccountQuotas } from "../../lib/proxy/accountQuota.js"; @@ -87,7 +88,6 @@ const ANTHROPIC_CONSOLE_OAUTH_CONFIG = { // Supported providers const SUPPORTED_PROVIDERS = ["anthropic"] as const; -type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; // ============================================================================= // SUBCOMMAND HANDLERS diff --git a/src/cli/commands/autoresearch.ts b/src/cli/commands/autoresearch.ts index 6f9a1d46a..60a30c2bb 100644 --- a/src/cli/commands/autoresearch.ts +++ b/src/cli/commands/autoresearch.ts @@ -24,6 +24,7 @@ import chalk from "chalk"; import ora from "ora"; import type { CommandModule } from "yargs"; import type { + AutoresearchInitArgs, MetricDirection, ResearchConfig, ResearchState, @@ -86,7 +87,9 @@ export class AutoresearchCommandFactory { .option("provider", { type: "string" }) .option("model", { type: "string" }), async (argv) => { - await AutoresearchCommandFactory.executeInit(argv as InitArgs); + await AutoresearchCommandFactory.executeInit( + argv as AutoresearchInitArgs, + ); }, ) .command( @@ -269,7 +272,7 @@ export class AutoresearchCommandFactory { return store; } - private static async executeInit(argv: InitArgs): Promise { + private static async executeInit(argv: AutoresearchInitArgs): Promise { const spinner = ora("Initializing autoresearch...").start(); try { const repoPath = resolve(argv.repoPath); @@ -683,17 +686,3 @@ export class AutoresearchCommandFactory { console.info(chalk.green(`Reset autoresearch state for ${resolved}`)); } } - -type InitArgs = { - repoPath: string; - tag: string; - target: string; - immutable: string; - runCommand: string; - metricName: string; - metricPattern: string; - metricDirection: string; - timeout: number; - provider?: string; - model?: string; -}; diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 12d818f4e..7ef03a95e 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -16,9 +16,14 @@ import { CLI_LIMITS } from "../../lib/core/constants.js"; import { logger } from "../../lib/utils/logger.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; -import { AIProviderName } from "../../lib/types/index.js"; -// Configuration schema for validation -const ConfigSchema = z.object({ +import { + AIProviderName, + type CliNeuroLinkConfig, +} from "../../lib/types/index.js"; +// Configuration schema for validation. Annotated with +// z.ZodType so drift between the canonical structural +// type in src/lib/types/cli.ts and this runtime schema fails at compile time. +const ConfigSchema: z.ZodType = z.object({ defaultProvider: z .enum([ "auto", @@ -312,12 +317,10 @@ const ConfigSchema = z.object({ }), }); -type NeuroLinkConfig = z.infer; - export class ConfigManager { private configDir: string; private configFile: string; - private config: NeuroLinkConfig; + private config: CliNeuroLinkConfig; constructor() { this.configDir = path.join(os.homedir(), ".neurolink"); @@ -328,7 +331,7 @@ export class ConfigManager { /** * Load configuration from file or create default */ - private loadConfig(): NeuroLinkConfig { + private loadConfig(): CliNeuroLinkConfig { try { if (fs.existsSync(this.configFile)) { const configData = JSON.parse(fs.readFileSync(this.configFile, "utf8")); @@ -926,14 +929,14 @@ export class ConfigManager { /** * Get current configuration */ - getConfig(): NeuroLinkConfig { + getConfig(): CliNeuroLinkConfig { return this.config; } /** * Update configuration */ - updateConfig(updates: Partial): void { + updateConfig(updates: Partial): void { this.config = { ...this.config, ...updates }; this.saveConfig(); } diff --git a/src/cli/commands/evaluate.ts b/src/cli/commands/evaluate.ts index a3c3f0640..0d9c4d664 100644 --- a/src/cli/commands/evaluate.ts +++ b/src/cli/commands/evaluate.ts @@ -20,100 +20,20 @@ import { import { ScorerRegistry } from "../../lib/evaluation/scorers/index.js"; import { ReportGenerator } from "../../lib/evaluation/reporting/reportGenerator.js"; import type { + DirectEvaluateArgs, + EvaluatePresetsArgs, + EvaluateReportArgs, + EvaluateRunArgs, + EvaluateScoreArgs, + EvaluateScorersArgs, PipelineConfig, - ScorerInput, - ReportFormat, ReportData, + ReportFormat, + RunPipelineArgs, + ScorerInput, } from "../../lib/types/index.js"; import { logger } from "../../lib/utils/logger.js"; -/** - * Base evaluate command arguments - */ -type BaseEvaluateArgs = { - json?: boolean; - verbose?: boolean; - format?: "text" | "json" | "table"; -}; - -/** - * Direct evaluate command arguments (main command) - */ -type DirectEvaluateArgs = BaseEvaluateArgs & { - input?: string; - query?: string; - scorers?: string[]; - context?: string; - threshold?: number; -}; - -/** - * Evaluate run command arguments - */ -type EvaluateRunArgs = BaseEvaluateArgs & { - input?: string; - output?: string; - context?: string[]; - groundTruth?: string; - pipeline?: string; - scorer?: string[]; -}; - -/** - * Evaluate score command arguments - */ -type EvaluateScoreArgs = BaseEvaluateArgs & { - scorer: string; - input?: string; - output?: string; - context?: string[]; - groundTruth?: string; -}; - -/** - * Evaluate report command arguments - */ -type EvaluateReportArgs = BaseEvaluateArgs & { - input?: string; - output?: string; - context?: string[]; - groundTruth?: string; - "ground-truth"?: string; - pipeline?: string; - scorer?: string[]; - outputFile?: string; - "output-file"?: string; -}; - -/** - * Evaluate presets command arguments - */ -type EvaluatePresetsArgs = { - preset?: string; - json?: boolean; -}; - -/** - * Evaluate scorers (list-scorers) command arguments - */ -type EvaluateScorersArgs = { - category?: string; - type?: string; - json?: boolean; - detailed?: boolean; -}; - -/** - * Run pipeline command arguments - */ -type RunPipelineArgs = BaseEvaluateArgs & { - preset: string; - input: string; - query?: string; - context?: string; - threshold?: number; -}; - /** * Format score result for display */ diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts index 88d47e71b..7f64d4fef 100644 --- a/src/cli/commands/mcp.ts +++ b/src/cli/commands/mcp.ts @@ -13,12 +13,16 @@ import type { CommandModule, Argv } from "yargs"; import type { - UnknownRecord, + AnnotatedToolTarget, + MCPCommandArgs, + MCPDiscoveryResult, MCPServerInfo, MCPStatus, - MCPTransportType, - MCPCommandArgs, MCPToolAnnotations, + MCPToolWithServer, + MCPTransportType, + ToolAnnotationInfo, + UnknownRecord, } from "../../lib/types/index.js"; import { createExternalServerInfo } from "../../lib/utils/mcpDefaults.js"; import { NeuroLink } from "../../lib/neurolink.js"; @@ -139,21 +143,6 @@ const POPULAR_MCP_SERVERS: Record< const MCP_STATUS_TIMEOUT_MS = 30_000; -type ToolAnnotationInfo = { - serverName: string; - serverId: string; - toolName: string; - description: string; - annotations: MCPToolAnnotations; -}; - -type AnnotatedToolTarget = { - name: string; - description: string; - serverId: string; - serverName: string; -}; - /** * MCP CLI command factory */ @@ -1606,24 +1595,7 @@ export class MCPCommandFactory { const sdk = new NeuroLink(); const servers = await sdk.listMCPServers(); - // Collect all tools from all servers - type ToolWithServer = { - name: string; - description: string; - serverId: string; - serverName: string; - inputSchema?: object; - category?: string; - annotations?: { - readOnlyHint?: boolean; - destructiveHint?: boolean; - idempotentHint?: boolean; - requiresConfirmation?: boolean; - tags?: string[]; - }; - }; - - let tools: ToolWithServer[] = []; + let tools: MCPToolWithServer[] = []; const { inferAnnotations } = await import("../../lib/mcp/toolAnnotations.js"); @@ -1807,19 +1779,7 @@ export class MCPCommandFactory { spinner.text = `Discovering tools from ${targetServers.length} servers...`; } - // Collect discovery results - type DiscoveryResult = { - serverId: string; - serverName: string; - toolCount: number; - tools: Array<{ - name: string; - description: string; - annotations: Record; - }>; - }; - - const results: DiscoveryResult[] = []; + const results: MCPDiscoveryResult[] = []; for (const server of targetServers) { const { inferAnnotations } = diff --git a/src/cli/commands/proxy.ts b/src/cli/commands/proxy.ts index 6a620ff2f..cd42a4099 100644 --- a/src/cli/commands/proxy.ts +++ b/src/cli/commands/proxy.ts @@ -29,12 +29,19 @@ import { StateFileManager, } from "../utils/serverUtils.js"; import type { + FallbackInfo, + LoadedProxyConfig, + ProxyGuardArgs, + ProxyNeurolinkRuntime, + ProxySpinner, + ProxyStartApp, ProxyStartArgs, + ProxyStartStrategy, + ProxyState, ProxyStatusArgs, - ProxyGuardArgs, + ProxyTelemetryAction, ProxyTelemetryArgs, - FallbackInfo, - ProxyState, + StatusStats, } from "../../lib/types/index.js"; import type { ModelRouter } from "../../lib/proxy/modelRouter.js"; import { @@ -606,19 +613,6 @@ export function mapClaudeErrorTypeToStatus(errorType?: string): number { } } -type ProxySpinner = ReturnType | null; -type ProxyStartStrategy = "round-robin" | "fill-first"; -type ProxyModelRouterConfig = ConstructorParameters[0]; -type LoadedProxyConfig = { - routing?: Partial & { - strategy?: ProxyStartStrategy; - }; -}; -type ProxyNeurolinkRuntime = Awaited< - ReturnType ->; -type ProxyStartApp = Awaited>; - async function ensureProxyStartAllowed(spinner: ProxySpinner): Promise { const existingState = loadProxyState(); if (existingState) { @@ -1485,24 +1479,6 @@ export const proxyStartCommand: CommandModule = { // STATUS DISPLAY HELPERS // ============================================================================= -type StatusStats = { - totalAttempts?: number; - totalRequests: number; - totalSuccess: number; - totalErrors: number; - totalRateLimits: number; - accounts?: { - label: string; - type: string; - attempts?: number; - requests?: number; - success?: number; - errors?: number; - rateLimits?: number; - cooling: boolean; - }[]; -}; - function printStatusStats(stats: StatusStats): void { console.info(`\n Stats:`); if (stats.totalAttempts !== undefined) { @@ -1745,8 +1721,6 @@ const PROXY_TELEMETRY_ACTIONS = [ "import-dashboard", ] as const; -type ProxyTelemetryAction = (typeof PROXY_TELEMETRY_ACTIONS)[number]; - export const proxyTelemetryCommand: CommandModule = { command: "telemetry ", diff --git a/src/cli/commands/rag.ts b/src/cli/commands/rag.ts index 15a34d0a2..e1db73224 100644 --- a/src/cli/commands/rag.ts +++ b/src/cli/commands/rag.ts @@ -26,7 +26,9 @@ import { InMemoryVectorStore } from "../../lib/rag/retrieval/vectorQueryTool.js" import type { Chunk, ChunkingStrategy, - RAGCommandArgs, + RagChunkArgs, + RagIndexArgs, + RagQueryArgs, } from "../../lib/types/index.js"; import { globalSession } from "../../lib/session/globalSessionState.js"; import { logger } from "../../lib/utils/logger.js"; @@ -207,27 +209,6 @@ async function getEmbeddingModel( /** * Chunk subcommand arguments */ -type ChunkArgs = RAGCommandArgs & { - file: string; - output?: string; - extract?: boolean; -}; - -/** - * Index subcommand arguments - */ -type IndexArgs = RAGCommandArgs & { - file: string; - indexName?: string; -}; - -/** - * Query subcommand arguments - */ -type QueryArgs = RAGCommandArgs & { - query: string; - indexName?: string; -}; /** * In-memory storage for indexed documents @@ -316,7 +297,7 @@ function formatChunks(chunks: Chunk[], format: string): string { /** * Create the chunk subcommand */ -function createChunkCommand(): CommandModule<{}, ChunkArgs> { +function createChunkCommand(): CommandModule<{}, RagChunkArgs> { return { command: "chunk ", describe: "Chunk a document into smaller pieces for processing", @@ -388,8 +369,8 @@ function createChunkCommand(): CommandModule<{}, ChunkArgs> { describe: "Enable verbose output", type: "boolean", default: false, - }) as Argv, - handler: async (args: Arguments) => { + }) as Argv, + handler: async (args: Arguments) => { const spinner = ora("Processing document...").start(); try { @@ -504,7 +485,7 @@ function createChunkCommand(): CommandModule<{}, ChunkArgs> { /** * Create the index subcommand */ -function createIndexCommand(): CommandModule<{}, IndexArgs> { +function createIndexCommand(): CommandModule<{}, RagIndexArgs> { return { command: "index ", describe: "Index a document for semantic search", @@ -571,8 +552,8 @@ function createIndexCommand(): CommandModule<{}, IndexArgs> { describe: "Enable verbose output", type: "boolean", default: false, - }) as Argv, - handler: async (args: Arguments) => { + }) as Argv, + handler: async (args: Arguments) => { const spinner = ora("Indexing document...").start(); try { @@ -737,7 +718,7 @@ function createIndexCommand(): CommandModule<{}, IndexArgs> { /** * Create the query subcommand */ -function createQueryCommand(): CommandModule<{}, QueryArgs> { +function createQueryCommand(): CommandModule<{}, RagQueryArgs> { return { command: "query ", describe: "Query indexed documents", @@ -793,8 +774,8 @@ function createQueryCommand(): CommandModule<{}, QueryArgs> { describe: "Enable verbose output", type: "boolean", default: false, - }) as Argv, - handler: async (args: Arguments) => { + }) as Argv, + handler: async (args: Arguments) => { const spinner = ora("Searching...").start(); try { diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index d4c5b9412..b137dd5e2 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -20,12 +20,12 @@ import { ServerStartError, } from "../../lib/server/errors.js"; import type { - RouteDefinition, - RouteGroup, - ServerAdapterConfig, - ServerFramework, ServeCommandArgs, + ServeState, + ServerAdapterConfig, ServerConfigFile, + ServerFramework, + ServerInstance, } from "../../lib/types/index.js"; import { withTimeout } from "../../lib/utils/errorHandling.js"; import { logger } from "../../lib/utils/logger.js"; @@ -34,31 +34,6 @@ import { isProcessRunning, StateFileManager, } from "../utils/serverUtils.js"; -/** - * Minimal interface for the server instance returned by createServer. - * Avoids importing BaseServerAdapter (which is dynamically loaded). - */ -type ServerInstance = { - initialize: () => Promise; - start: () => Promise; - stop: () => Promise; - registerRouteGroup: (group: RouteGroup) => void; - listRoutes?: () => RouteDefinition[]; -}; - -/** - * Server state stored in state file - */ -type ServeState = { - pid: number; - port: number; - host: string; - framework: string; - startTime: string; - basePath: string; - configFile?: string; -}; - // ============================================ // State Management // ============================================ diff --git a/src/cli/commands/server.ts b/src/cli/commands/server.ts index 9339ef360..d22103e1a 100644 --- a/src/cli/commands/server.ts +++ b/src/cli/commands/server.ts @@ -18,9 +18,11 @@ import { getNeuroLinkDir, } from "../utils/serverUtils.js"; import type { + CliServeFlatRoute, + CliServeRouteGroup, ServerCommandArgs, - ServerState, ServerConfig, + ServerState, } from "../../lib/types/index.js"; // ============================================ @@ -822,28 +824,12 @@ export class ServerCommandFactory { const { createAllRoutes } = await import("../../lib/server/routes/index.js"); - type RouteGroup = { - prefix: string; - routes: Array<{ - method: string; - path: string; - description?: string; - }>; - }; - - type FlatRoute = { - method: string; - path: string; - description?: string; - group: string; - }; - const routeGroups = createAllRoutes( argv.basePath ?? "/api", - ) as RouteGroup[]; + ) as CliServeRouteGroup[]; // Flatten route groups into individual routes with group info - let flatRoutes: FlatRoute[] = []; + let flatRoutes: CliServeFlatRoute[] = []; for (const group of routeGroups) { // Extract group name from prefix (e.g., "/api/agent" -> "agent") const groupName = diff --git a/src/cli/commands/setup-anthropic.ts b/src/cli/commands/setup-anthropic.ts index 9c7ba64b2..821f0c5f9 100644 --- a/src/cli/commands/setup-anthropic.ts +++ b/src/cli/commands/setup-anthropic.ts @@ -17,30 +17,19 @@ import chalk from "chalk"; import ora from "ora"; import { logger } from "../../lib/utils/logger.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; -import { AIProviderName } from "../../lib/types/index.js"; +import { + AIProviderName, + type ProviderSetupArgv, + type ProviderSetupConfig, + type ProviderSetupOptions, +} from "../../lib/types/index.js"; import { maskCredential } from "../utils/maskCredential.js"; -type AnthropicSetupOptions = { - checkOnly?: boolean; - interactive?: boolean; -}; - -type AnthropicSetupArgv = { - check?: boolean; - nonInteractive?: boolean; -}; - -type AnthropicConfig = { - apiKey?: string; - model?: string; - isReconfiguring?: boolean; -}; - export async function handleAnthropicSetup( - argv: AnthropicSetupArgv, + argv: ProviderSetupArgv, ): Promise { try { - const options: AnthropicSetupOptions = { + const options: ProviderSetupOptions = { checkOnly: argv.check || false, interactive: !argv.nonInteractive, }; @@ -73,7 +62,7 @@ export async function handleAnthropicSetup( return; } - const config: AnthropicConfig = {}; + const config: ProviderSetupConfig = {}; // Step 2: Handle existing configuration if (hasApiKey) { @@ -362,7 +351,7 @@ async function promptForModel(): Promise { /** * Update .env file with Anthropic configuration */ -async function updateEnvFile(config: AnthropicConfig): Promise { +async function updateEnvFile(config: ProviderSetupConfig): Promise { const envPath = path.join(process.cwd(), ".env"); const spinner = ora("💾 Updating .env file...").start(); diff --git a/src/cli/commands/setup-azure.ts b/src/cli/commands/setup-azure.ts index e18235ee4..32d329924 100644 --- a/src/cli/commands/setup-azure.ts +++ b/src/cli/commands/setup-azure.ts @@ -20,29 +20,17 @@ import { displayEnvUpdateSummary, } from "../utils/envManager.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; -import { AIProviderName } from "../../lib/types/index.js"; +import { + AIProviderName, + type ProviderSetupArgv, + type ProviderSetupConfig, + type ProviderSetupOptions, +} from "../../lib/types/index.js"; import { maskCredential } from "../utils/maskCredential.js"; -type AzureSetupOptions = { - checkOnly?: boolean; - interactive?: boolean; -}; - -type AzureSetupArgv = { - check?: boolean; - nonInteractive?: boolean; -}; - -type AzureConfig = { - apiKey?: string; - endpoint?: string; - model?: string; - isReconfiguring?: boolean; -}; - -export async function handleAzureSetup(argv: AzureSetupArgv): Promise { +export async function handleAzureSetup(argv: ProviderSetupArgv): Promise { try { - const options: AzureSetupOptions = { + const options: ProviderSetupOptions = { checkOnly: argv.check || false, interactive: !argv.nonInteractive, }; @@ -77,7 +65,7 @@ export async function handleAzureSetup(argv: AzureSetupArgv): Promise { return; } - const config: AzureConfig = {}; + const config: ProviderSetupConfig = {}; // Step 2: Handle existing configuration if (hasApiKey && hasEndpoint) { @@ -430,7 +418,9 @@ async function promptForModel(): Promise { /** * Update .env file with Azure OpenAI configuration using shared utilities */ -async function updateEnvFileWithConfig(config: AzureConfig): Promise { +async function updateEnvFileWithConfig( + config: ProviderSetupConfig, +): Promise { const spinner = ora("💾 Updating .env file...").start(); try { diff --git a/src/cli/commands/setup-bedrock.ts b/src/cli/commands/setup-bedrock.ts index 698d80635..6cec402d4 100644 --- a/src/cli/commands/setup-bedrock.ts +++ b/src/cli/commands/setup-bedrock.ts @@ -16,37 +16,20 @@ import ora from "ora"; import { logger } from "../../lib/utils/logger.js"; import { updateEnvFile as envUpdate } from "../utils/envManager.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; -import { AIProviderName } from "../../lib/types/index.js"; +import { + AIProviderName, + type BedrockConfigData, + type BedrockConfigStatus, + type ProviderSetupArgv, + type ProviderSetupOptions, +} from "../../lib/types/index.js"; import { maskCredential } from "../utils/maskCredential.js"; -type BedrockSetupOptions = { - checkOnly?: boolean; - interactive?: boolean; -}; - -type BedrockSetupArgv = { - check?: boolean; - nonInteractive?: boolean; -}; - -type ConfigData = { - accessKeyId?: string; - secretAccessKey?: string; - region?: string; - model?: string; -}; - -type ConfigStatus = { - hasAccessKey: boolean; - hasSecretKey: boolean; - hasRegion: boolean; -}; - export async function handleBedrockSetup( - argv: BedrockSetupArgv, + argv: ProviderSetupArgv, ): Promise { try { - const options: BedrockSetupOptions = { + const options: ProviderSetupOptions = { checkOnly: argv.check || false, interactive: !argv.nonInteractive, }; @@ -56,7 +39,7 @@ export async function handleBedrockSetup( ); const configStatus = checkExistingConfiguration(); - const config: ConfigData = {}; + const config: BedrockConfigData = {}; // Handle existing credentials if (configStatus.hasAccessKey && configStatus.hasSecretKey) { @@ -209,7 +192,7 @@ async function detectAWSConfig(): Promise<{ } } -function checkExistingConfiguration(): ConfigStatus { +function checkExistingConfiguration(): BedrockConfigStatus { return { hasAccessKey: !!process.env.AWS_ACCESS_KEY_ID, hasSecretKey: !!process.env.AWS_SECRET_ACCESS_KEY, @@ -217,7 +200,7 @@ function checkExistingConfiguration(): ConfigStatus { }; } -function displayCurrentStatus(configStatus: ConfigStatus): void { +function displayCurrentStatus(configStatus: BedrockConfigStatus): void { if (configStatus.hasAccessKey) { logger.always(chalk.green("✔ AWS_ACCESS_KEY_ID found in environment")); } else { @@ -244,9 +227,9 @@ function displayCurrentStatus(configStatus: ConfigStatus): void { } async function handleExistingCredentials( - configStatus: ConfigStatus, - options: BedrockSetupOptions, - config: ConfigData, + configStatus: BedrockConfigStatus, + options: ProviderSetupOptions, + config: BedrockConfigData, ): Promise<{ shouldReturn: boolean }> { logger.always(chalk.green("✔ AWS_ACCESS_KEY_ID found in environment")); logger.always(chalk.green("✔ AWS_SECRET_ACCESS_KEY found in environment")); @@ -295,8 +278,8 @@ async function handleExistingCredentials( } async function detectAndDisplayAWSConfig( - configStatus: ConfigStatus, - config: ConfigData, + configStatus: BedrockConfigStatus, + config: BedrockConfigData, ): Promise { if (!configStatus.hasAccessKey || !configStatus.hasSecretKey) { logger.always(chalk.blue("🔍 Checking for AWS CLI configuration...")); @@ -332,9 +315,9 @@ async function detectAndDisplayAWSConfig( } async function handleInteractiveCredentialSetup( - configStatus: ConfigStatus, - config: ConfigData, - _options: BedrockSetupOptions, + configStatus: BedrockConfigStatus, + config: BedrockConfigData, + _options: ProviderSetupOptions, ): Promise<{ shouldReturn: boolean }> { const isReconfiguring = configStatus.hasAccessKey && configStatus.hasSecretKey; @@ -426,8 +409,8 @@ function displayTerminalInstructions(): void { } async function promptForCredentials( - configStatus: ConfigStatus, - config: ConfigData, + configStatus: BedrockConfigStatus, + config: BedrockConfigData, isReconfiguring: boolean, ): Promise { // Prompt for access key @@ -507,7 +490,7 @@ async function promptForCredentials( } } -async function handleModelSelection(config: ConfigData): Promise { +async function handleModelSelection(config: BedrockConfigData): Promise { const hasModel = !!( process.env.BEDROCK_MODEL || process.env.BEDROCK_MODEL_ID ); @@ -555,8 +538,8 @@ async function handleModelSelection(config: ConfigData): Promise { } async function finalizeSetup( - config: ConfigData, - options: BedrockSetupOptions, + config: BedrockConfigData, + options: ProviderSetupOptions, ): Promise { if ( config.accessKeyId || diff --git a/src/cli/commands/setup-gcp.ts b/src/cli/commands/setup-gcp.ts index a0e4871ae..b3965f100 100644 --- a/src/cli/commands/setup-gcp.ts +++ b/src/cli/commands/setup-gcp.ts @@ -19,40 +19,11 @@ import inquirer from "inquirer"; import chalk from "chalk"; import ora from "ora"; import { logger } from "../../lib/utils/logger.js"; - -type GCPSetupOptions = { - checkOnly?: boolean; - interactive?: boolean; -}; - -type GCPSetupArgv = { - check?: boolean; - nonInteractive?: boolean; -}; - -type AuthMethodStatus = { - method1: { - complete: boolean; - hasCredentials: boolean; - missingVars: string[]; - }; - method2: { - complete: boolean; - hasServiceAccountKey: boolean; - missingVars: string[]; - }; - method3: { - complete: boolean; - hasClientEmail: boolean; - hasPrivateKey: boolean; - missingVars: string[]; - }; - common: { - hasProject: boolean; - hasLocation: boolean; - missingVars: string[]; - }; -}; +import type { + GcpAuthMethodStatus, + ProviderSetupArgv, + ProviderSetupOptions, +} from "../../lib/types/index.js"; enum AuthMethod { FILE_PATH = "file-path", @@ -66,9 +37,9 @@ const AUTH_METHOD_NAMES = { [AuthMethod.INDIVIDUAL_VARS]: "Method 3: Individual Vars", }; -export async function handleGCPSetup(argv: GCPSetupArgv): Promise { +export async function handleGCPSetup(argv: ProviderSetupArgv): Promise { try { - const options: GCPSetupOptions = { + const options: ProviderSetupOptions = { checkOnly: argv.check || false, interactive: !argv.nonInteractive, }; @@ -179,7 +150,7 @@ export async function handleGCPSetup(argv: GCPSetupArgv): Promise { /** * Detect the current status of all authentication methods */ -function detectAuthMethodStatus(): AuthMethodStatus { +function detectAuthMethodStatus(): GcpAuthMethodStatus { const hasCredentials = !!process.env.GOOGLE_APPLICATION_CREDENTIALS; const hasServiceAccountKey = !!process.env.GOOGLE_SERVICE_ACCOUNT_KEY; const hasClientEmail = !!process.env.GOOGLE_AUTH_CLIENT_EMAIL; @@ -187,7 +158,7 @@ function detectAuthMethodStatus(): AuthMethodStatus { const hasProject = !!process.env.GOOGLE_VERTEX_PROJECT; const hasLocation = !!process.env.GOOGLE_VERTEX_LOCATION; - const status: AuthMethodStatus = { + const status: GcpAuthMethodStatus = { method1: { complete: hasCredentials && hasProject, hasCredentials, @@ -246,7 +217,7 @@ function detectAuthMethodStatus(): AuthMethodStatus { /** * Display the current authentication status */ -function displayAuthStatus(status: AuthMethodStatus): void { +function displayAuthStatus(status: GcpAuthMethodStatus): void { if (status.method1.complete) { logger.always(chalk.green("✔ Method 1: Complete")); } else if (status.method1.hasCredentials) { @@ -287,7 +258,7 @@ function displayAuthStatus(status: AuthMethodStatus): void { /** * Check if any authentication method is complete */ -function getCompleteMethod(status: AuthMethodStatus): AuthMethod | null { +function getCompleteMethod(status: GcpAuthMethodStatus): AuthMethod | null { if (status.method1.complete) { return AuthMethod.FILE_PATH; } @@ -303,7 +274,9 @@ function getCompleteMethod(status: AuthMethodStatus): AuthMethod | null { /** * Let user select authentication method */ -async function selectAuthMethod(status: AuthMethodStatus): Promise { +async function selectAuthMethod( + status: GcpAuthMethodStatus, +): Promise { // Check for partially filled methods const partiallyFilledMethods: { method: AuthMethod; @@ -397,7 +370,7 @@ async function selectAuthMethod(status: AuthMethodStatus): Promise { */ async function promptForMissingValues( method: AuthMethod, - status: AuthMethodStatus, + status: GcpAuthMethodStatus, ): Promise<{ credentialsPath?: string; serviceAccountKey?: string; diff --git a/src/cli/commands/setup-google-ai.ts b/src/cli/commands/setup-google-ai.ts index d47ed921e..95a73b73a 100644 --- a/src/cli/commands/setup-google-ai.ts +++ b/src/cli/commands/setup-google-ai.ts @@ -21,25 +21,14 @@ import { displayEnvUpdateSummary, } from "../utils/envManager.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; -import { AIProviderName } from "../../lib/types/index.js"; +import { + AIProviderName, + type ProviderSetupArgv, + type ProviderSetupConfig, + type ProviderSetupOptions, +} from "../../lib/types/index.js"; import { maskCredential } from "../utils/maskCredential.js"; -type GoogleAISetupOptions = { - checkOnly?: boolean; - interactive?: boolean; -}; - -type GoogleAISetupArgv = { - check?: boolean; - nonInteractive?: boolean; -}; - -type GoogleAIConfig = { - apiKey?: string; - model?: string; - isReconfiguring?: boolean; -}; - /** * Get the runtime default model that matches the provider implementation */ @@ -48,10 +37,10 @@ function getRuntimeDefaultModel(): string { } export async function handleGoogleAISetup( - argv: GoogleAISetupArgv, + argv: ProviderSetupArgv, ): Promise { try { - const options: GoogleAISetupOptions = { + const options: ProviderSetupOptions = { checkOnly: argv.check || false, interactive: !argv.nonInteractive, }; @@ -85,7 +74,7 @@ export async function handleGoogleAISetup( return; } - const config: GoogleAIConfig = {}; + const config: ProviderSetupConfig = {}; // Step 2: Handle existing configuration if (hasApiKey && currentApiKey) { @@ -376,7 +365,7 @@ async function promptForModel(): Promise { /** * Update .env file with Google AI Studio configuration */ -async function updateEnvFile(config: GoogleAIConfig): Promise { +async function updateEnvFile(config: ProviderSetupConfig): Promise { const envPath = path.join(process.cwd(), ".env"); const spinner = ora("💾 Updating .env file...").start(); diff --git a/src/cli/commands/setup-huggingface.ts b/src/cli/commands/setup-huggingface.ts index 6bb422a8d..e9e3c9278 100644 --- a/src/cli/commands/setup-huggingface.ts +++ b/src/cli/commands/setup-huggingface.ts @@ -3,16 +3,12 @@ import chalk from "chalk"; import ora from "ora"; import inquirer from "inquirer"; import { logger } from "../../lib/utils/logger.js"; +import type { SetupHuggingFaceArgs } from "../../lib/types/index.js"; import { updateEnvFile as writeEnvFile, displayEnvUpdateSummary, } from "../utils/envManager.js"; -type SetupHuggingFaceArgs = { - check?: boolean; - "non-interactive"?: boolean; -}; - /** * Validates Hugging Face API key format * Must start with "hf_" prefix diff --git a/src/cli/commands/setup-mistral.ts b/src/cli/commands/setup-mistral.ts index 2476ffa19..57b1962e5 100644 --- a/src/cli/commands/setup-mistral.ts +++ b/src/cli/commands/setup-mistral.ts @@ -7,11 +7,7 @@ import path from "path"; import { logger } from "../../lib/utils/logger.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; import { AIProviderName } from "../../lib/types/index.js"; - -type SetupMistralArgs = { - check?: boolean; - "non-interactive"?: boolean; -}; +import type { SetupMistralArgs } from "../../lib/types/index.js"; /** * Validates Mistral API key format diff --git a/src/cli/commands/setup-openai.ts b/src/cli/commands/setup-openai.ts index e9ccfd970..6dd55fa1d 100644 --- a/src/cli/commands/setup-openai.ts +++ b/src/cli/commands/setup-openai.ts @@ -17,28 +17,19 @@ import chalk from "chalk"; import ora from "ora"; import { logger } from "../../lib/utils/logger.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; -import { AIProviderName } from "../../lib/types/index.js"; +import { + AIProviderName, + type ProviderSetupArgv, + type ProviderSetupConfig, + type ProviderSetupOptions, +} from "../../lib/types/index.js"; import { maskCredential } from "../utils/maskCredential.js"; -type OpenAISetupOptions = { - checkOnly?: boolean; - interactive?: boolean; -}; - -type OpenAISetupArgv = { - check?: boolean; - nonInteractive?: boolean; -}; - -type OpenAIConfig = { - apiKey?: string; - model?: string; - isReconfiguring?: boolean; -}; - -export async function handleOpenAISetup(argv: OpenAISetupArgv): Promise { +export async function handleOpenAISetup( + argv: ProviderSetupArgv, +): Promise { try { - const options: OpenAISetupOptions = { + const options: ProviderSetupOptions = { checkOnly: argv.check || false, interactive: !argv.nonInteractive, }; @@ -70,7 +61,7 @@ export async function handleOpenAISetup(argv: OpenAISetupArgv): Promise { return; } - const config: OpenAIConfig = {}; + const config: ProviderSetupConfig = {}; // Step 2: Handle existing configuration if (hasApiKey && process.env.OPENAI_API_KEY) { @@ -351,7 +342,7 @@ async function promptForModel(): Promise { /** * Update .env file with OpenAI configuration */ -async function updateEnvFile(config: OpenAIConfig): Promise { +async function updateEnvFile(config: ProviderSetupConfig): Promise { const envPath = path.join(process.cwd(), ".env"); const spinner = ora("💾 Updating .env file...").start(); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index e7f89af9c..c3f9555ea 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -22,31 +22,10 @@ import { handleBedrockSetup } from "./setup-bedrock.js"; import { handleGCPSetup } from "./setup-gcp.js"; import { handleHuggingFaceSetup } from "./setup-huggingface.js"; import { handleMistralSetup } from "./setup-mistral.js"; - -type SetupArgs = { - provider?: string; - list?: boolean; - status?: boolean; - interactive?: boolean; - help?: boolean; -}; - -type ProviderInfo = { - id: string; - name: string; - emoji: string; - description: string; - setupTime: string; - cost: string; - bestFor: string; - models: string; - strengths: string; - pricing: string; - setupCommand: string; -}; +import type { SetupArgs, SetupProviderInfo } from "../../lib/types/index.js"; // Provider information database -const PROVIDERS: ProviderInfo[] = [ +const PROVIDERS: SetupProviderInfo[] = [ { id: "google-ai", name: "Google AI Studio", diff --git a/src/cli/commands/task.ts b/src/cli/commands/task.ts index 73d96c352..7362c8bcf 100644 --- a/src/cli/commands/task.ts +++ b/src/cli/commands/task.ts @@ -25,10 +25,13 @@ import ora from "ora"; import type { CommandModule } from "yargs"; import type { Task, + TaskCreateArgs, TaskExecutionMode, + TaskLogsArgs, TaskManagerConfig, TaskSchedule, TaskStatus, + TaskUpdateArgs, WorkerState, } from "../../lib/types/index.js"; import { TASK_DEFAULTS } from "../../lib/types/index.js"; @@ -163,7 +166,7 @@ export class TaskCommandFactory { return true; }), async (argv) => { - await TaskCommandFactory.executeCreate(argv as CreateArgs); + await TaskCommandFactory.executeCreate(argv as TaskCreateArgs); }, ) .command( @@ -269,7 +272,7 @@ export class TaskCommandFactory { description: "New execution mode", }), async (argv) => { - await TaskCommandFactory.executeUpdate(argv as UpdateArgs); + await TaskCommandFactory.executeUpdate(argv as TaskUpdateArgs); }, ) .command( @@ -313,7 +316,7 @@ export class TaskCommandFactory { description: "Show full output (no truncation)", }), async (argv) => { - await TaskCommandFactory.executeLogs(argv as LogsArgs); + await TaskCommandFactory.executeLogs(argv as TaskLogsArgs); }, ) .command( @@ -516,7 +519,7 @@ export class TaskCommandFactory { * Create — pure store write, no NeuroLink needed. * Builds the Task object directly, saves to the task store, exits immediately. */ - private static async executeCreate(argv: CreateArgs): Promise { + private static async executeCreate(argv: TaskCreateArgs): Promise { const spinner = ora("Creating task...").start(); try { @@ -792,7 +795,7 @@ export class TaskCommandFactory { } } - private static async executeUpdate(argv: UpdateArgs): Promise { + private static async executeUpdate(argv: TaskUpdateArgs): Promise { try { const store = await TaskCommandFactory.getStore(); @@ -858,7 +861,7 @@ export class TaskCommandFactory { } } - private static async executeLogs(argv: LogsArgs): Promise { + private static async executeLogs(argv: TaskLogsArgs): Promise { try { const store = await TaskCommandFactory.getStore(); const runs = await store.getRuns(argv.taskId, { @@ -1169,35 +1172,3 @@ function formatDuration(ms: number): string { } // ── Arg Types ─────────────────────────────────────────── - -type CreateArgs = { - name: string; - prompt: string; - cron?: string; - timezone?: string; - every?: string; - at?: string; - mode: string; - provider?: string; - model?: string; - maxRuns?: number; - maxTokens?: number; - temperature?: number; - systemPrompt?: string; -}; - -type UpdateArgs = { - taskId: string; - prompt?: string; - cron?: string; - every?: string; - at?: string; - mode?: string; -}; - -type LogsArgs = { - taskId: string; - limit: number; - status?: string; - full?: boolean; -}; diff --git a/src/cli/commands/telemetry.ts b/src/cli/commands/telemetry.ts index 1bb927024..3afa8a6a2 100644 --- a/src/cli/commands/telemetry.ts +++ b/src/cli/commands/telemetry.ts @@ -19,6 +19,7 @@ import { NeuroLink } from "../../lib/neurolink.js"; import { flushOpenTelemetry } from "../../lib/services/server/ai/observability/instrumentation.js"; import { formatRow, formatCost } from "../utils/formatters.js"; import type { + ExporterName, TelemetryStatusArgs as StatusArgs, TelemetryConfigureArgs as ConfigureArgs, TelemetryListExportersArgs as ListExportersArgs, @@ -41,8 +42,6 @@ const AVAILABLE_EXPORTERS = [ "laminar", ] as const; -type ExporterName = (typeof AVAILABLE_EXPORTERS)[number]; - /** * Telemetry Command Factory */ diff --git a/src/cli/commands/voiceServer.ts b/src/cli/commands/voiceServer.ts index 6af6426a6..48d1ec4c6 100644 --- a/src/cli/commands/voiceServer.ts +++ b/src/cli/commands/voiceServer.ts @@ -1,11 +1,8 @@ import type { CommandModule } from "yargs"; +import type { VoiceServerArgs } from "../../lib/types/index.js"; import { startVoiceServer } from "../../lib/server/voice/voiceServerApp.js"; import { configureVoiceServerEnvironment } from "../../lib/server/voice/voiceWebSocketHandler.js"; -type VoiceServerArgs = { - port: number; -}; - export const voiceServerCommand: CommandModule = { command: "voice-server", describe: diff --git a/src/cli/factories/ollamaCommandFactory.ts b/src/cli/factories/ollamaCommandFactory.ts index f14ef2d61..69b787786 100644 --- a/src/cli/factories/ollamaCommandFactory.ts +++ b/src/cli/factories/ollamaCommandFactory.ts @@ -13,17 +13,7 @@ import { logger } from "../../lib/utils/logger.js"; import { OllamaUtils } from "../utils/ollamaUtils.js"; import { getTopModelChoices } from "../../lib/utils/modelChoices.js"; import { AIProviderName } from "../../lib/types/index.js"; - -// Allowed commands for security -type AllowedCommand = - | "ollama" - | "curl" - | "systemctl" - | "pkill" - | "killall" - | "open" - | "taskkill" - | "start"; +import type { AllowedCommand } from "../../lib/types/index.js"; /** * Factory for creating Ollama CLI commands using the Factory Pattern diff --git a/src/cli/loop/session.ts b/src/cli/loop/session.ts index cb0365b83..66586486a 100644 --- a/src/cli/loop/session.ts +++ b/src/cli/loop/session.ts @@ -23,6 +23,8 @@ import { saveCommandToHistory, verifyConversationContext, } from "../../lib/utils/loopUtils.js"; +import { SpanStatusCode } from "@opentelemetry/api"; +import { tracers } from "../../lib/telemetry/tracers.js"; import { handleError } from "../errorHandler.js"; import { ConversationSelector } from "./conversationSelector.js"; import { textGenerationOptionsSchema } from "./optionsSchema.js"; @@ -163,19 +165,42 @@ export class LoopSession { processedCommand = ["stream", command]; } - // Execute the command + // Execute the command within an OTel span for per-turn visibility. // The .fail() handler in cli.ts is now session-aware and will // handle all parsing and validation errors without exiting the loop. // We create a fresh instance for each command to prevent state pollution. - const yargsInstance = this.initializeCliParser(); - await yargsInstance - .scriptName("") - .fail((msg, err) => { - // Re-throw the error to be caught by the outer catch block - throw err || new Error(msg); - }) - .exitProcess(false) - .parse(processedCommand); + await tracers.sdk.startActiveSpan( + "neurolink.cli.turn", + async (turnSpan) => { + try { + turnSpan.setAttribute( + "cli.command", + typeof processedCommand === "string" + ? processedCommand.slice(0, 100) + : (processedCommand[0] ?? "unknown"), + ); + turnSpan.setAttribute("cli.session_id", this.sessionId ?? "none"); + const yargsInstance = this.initializeCliParser(); + await yargsInstance + .scriptName("") + .fail((msg, err) => { + throw err || new Error(msg); + }) + .exitProcess(false) + .parse(processedCommand); + } catch (e) { + const err = e instanceof Error ? e : new Error(String(e)); + turnSpan.recordException(err); + turnSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + throw e; + } finally { + turnSpan.end(); + } + }, + ); // Check context budget after each generation command await this.checkContextBudgetWarning(); diff --git a/src/cli/utils/interactiveSetup.ts b/src/cli/utils/interactiveSetup.ts index 0374f5ada..9c9b95bbe 100644 --- a/src/cli/utils/interactiveSetup.ts +++ b/src/cli/utils/interactiveSetup.ts @@ -10,23 +10,12 @@ import { NeuroLink } from "../../lib/neurolink.js"; import chalk from "chalk"; import ora from "ora"; import { logger } from "../../lib/utils/logger.js"; -import type { SetupResult } from "../../lib/types/index.js"; - -// Provider configuration definitions -type ProviderConfig = { - id: AIProviderName; - name: string; - description: string; - envVars: Array<{ - key: string; - prompt: string; - secure?: boolean; - default?: string; - optional?: boolean; - }>; -}; - -export const PROVIDER_CONFIGS: ProviderConfig[] = [ +import type { + InteractiveProviderConfig, + SetupResult, +} from "../../lib/types/index.js"; + +export const PROVIDER_CONFIGS: InteractiveProviderConfig[] = [ { id: AIProviderName.OPENAI, name: "OpenAI", diff --git a/src/cli/utils/videoFileUtils.ts b/src/cli/utils/videoFileUtils.ts index e818dac56..527a4fd45 100644 --- a/src/cli/utils/videoFileUtils.ts +++ b/src/cli/utils/videoFileUtils.ts @@ -10,21 +10,10 @@ import fs from "fs"; import path from "path"; -import type { VideoGenerationResult } from "../../lib/types/index.js"; - -/** - * Result of saving video to file - */ -type VideoSaveResult = { - /** Whether the save was successful */ - success: boolean; - /** Full path to the saved file */ - path: string; - /** File size in bytes */ - size: number; - /** Error message if failed */ - error?: string; -}; +import type { + VideoGenerationResult, + VideoSaveResult, +} from "../../lib/types/index.js"; /** * Format file size in human-readable format diff --git a/src/lib/action/actionInputs.ts b/src/lib/action/actionInputs.ts index 9a15c56fe..4ae9f748a 100644 --- a/src/lib/action/actionInputs.ts +++ b/src/lib/action/actionInputs.ts @@ -6,21 +6,16 @@ import * as core from "@actions/core"; import type { - ActionInputs, - ActionProviderKeys, - ActionInputValidation, ActionAWSConfig, ActionGoogleCloudConfig, + ActionInputValidation, + ActionInputs, + ActionProviderKeys, + ProviderKeyMap, } from "../types/index.js"; import { AIProviderName } from "../constants/enums.js"; import { ErrorFactory } from "../utils/errorHandling.js"; -/** - * Provider to required keys mapping (verified providers only) - * Uses string keys since they can be from different config objects - */ -type ProviderKeyMap = Record; - const PROVIDER_KEY_MAP: ProviderKeyMap = { [AIProviderName.OPENAI]: ["openaiApiKey"], [AIProviderName.ANTHROPIC]: ["anthropicApiKey"], diff --git a/src/lib/adapters/tts/cartesiaHandler.ts b/src/lib/adapters/tts/cartesiaHandler.ts index e34979717..3c51d67fc 100644 --- a/src/lib/adapters/tts/cartesiaHandler.ts +++ b/src/lib/adapters/tts/cartesiaHandler.ts @@ -2,12 +2,7 @@ import WebSocket from "ws"; import { EventEmitter } from "events"; import { logger } from "../../utils/logger.js"; import { withTimeout } from "../../utils/async/withTimeout.js"; - -type CartesiaMessage = { - data?: string; - done?: boolean; - error?: string; -}; +import type { CartesiaMessage } from "../../types/index.js"; export function getCartesiaWsUrl(): string { const baseUrl = diff --git a/src/lib/adapters/video/directorPipeline.ts b/src/lib/adapters/video/directorPipeline.ts index dc59abe28..e57f1c28c 100644 --- a/src/lib/adapters/video/directorPipeline.ts +++ b/src/lib/adapters/video/directorPipeline.ts @@ -15,9 +15,12 @@ import pLimit from "p-limit"; import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js"; import type { - ImageWithAltText, + ClipGenState, + ClipResult, DirectorModeOptions, DirectorSegment, + ImageWithAltText, + TransitionResult, VideoGenerationResult, VideoOutputOptions, } from "../../types/index.js"; @@ -207,25 +210,6 @@ async function readImageFromDisk( // PHASE 1: PARALLEL CLIP GENERATION (with circuit breaker) // ============================================================================ -type ClipResult = { buffer: Buffer; processingTime: number }; - -/** Completion status for ordered circuit breaker tracking */ -type ClipCompletion = - | { status: "pending" } - | { status: "success"; result: ClipResult } - | { status: "failure"; error: Error }; - -/** State shared across clip-generation tasks for circuit-breaker logic. */ -type ClipGenState = { - consecutiveFailures: number; - circuitOpen: boolean; - results: Array; - /** Track completion status in submission order for ordered circuit breaker */ - completions: ClipCompletion[]; - /** Next index to process for ordered circuit breaker evaluation */ - nextExpectedIndex: number; -}; - /** * Process clip completions in order to maintain an accurate consecutive failure count. * This prevents out-of-order completions from incorrectly resetting the failure streak. @@ -410,14 +394,6 @@ async function generateClips( // PHASE 2: PARALLEL TRANSITION GENERATION // ============================================================================ -type TransitionResult = { - buffer: Buffer | null; - fromSegment: number; - toSegment: number; - duration: number; - processingTime: number; -}; - /** * Extract boundary frames and generate transition clips in parallel. * diff --git a/src/lib/adapters/video/vertexVideoHandler.ts b/src/lib/adapters/video/vertexVideoHandler.ts index 7d3590025..c632ac62d 100644 --- a/src/lib/adapters/video/vertexVideoHandler.ts +++ b/src/lib/adapters/video/vertexVideoHandler.ts @@ -15,6 +15,7 @@ import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js"; import { TIMEOUTS } from "../../constants/timeouts.js"; import { VIDEO_ERROR_CODES } from "../../constants/videoErrors.js"; import type { + VertexOperationResult, VideoGenerationResult, VideoOutputOptions, } from "../../types/index.js"; @@ -553,18 +554,6 @@ export async function generateVideoWithVertex( /** * Vertex AI operation result type for type safety */ -type VertexOperationResult = { - done?: boolean; - response?: { - videos?: Array<{ - bytesBase64Encoded?: string; - gcsUri?: string; - }>; - }; - error?: { - message?: string; - }; -}; /** * Extract video buffer from completed operation result diff --git a/src/lib/agent/directTools.ts b/src/lib/agent/directTools.ts index 7ddba6b66..d4af9fc5e 100644 --- a/src/lib/agent/directTools.ts +++ b/src/lib/agent/directTools.ts @@ -12,6 +12,12 @@ import { logger } from "../utils/logger.js"; import { VertexAI } from "@google-cloud/vertexai"; import { CSVProcessor } from "../utils/csvProcessor.js"; import { shouldEnableBashTool } from "../utils/toolUtils.js"; +import type { + AllToolsMap, + BasicToolsMap, + FilesystemToolsMap, + UtilityToolsMap, +} from "../types/index.js"; const MAX_OUTPUT_BYTES = 102400; // 100KB @@ -942,30 +948,6 @@ if (shouldEnableBashTool()) { (directAgentTools as Record).executeBashCommand = bashTool; } -/** - * Type aliases for specific tool categories - */ -type BasicToolsMap = { - getCurrentTime: typeof directAgentTools.getCurrentTime; - calculateMath: typeof directAgentTools.calculateMath; -}; - -type FilesystemToolsMap = { - readFile: typeof directAgentTools.readFile; - listDirectory: typeof directAgentTools.listDirectory; - writeFile: typeof directAgentTools.writeFile; -}; - -type UtilityToolsMap = { - getCurrentTime: typeof directAgentTools.getCurrentTime; - calculateMath: typeof directAgentTools.calculateMath; - listDirectory: typeof directAgentTools.listDirectory; -}; - -type AllToolsMap = typeof directAgentTools & { - executeBashCommand?: typeof bashTool; -}; - /** * Get a subset of tools for specific use cases with improved type safety */ diff --git a/src/lib/artifacts/artifactStore.ts b/src/lib/artifacts/artifactStore.ts index 2b828c19c..7491314c0 100644 --- a/src/lib/artifacts/artifactStore.ts +++ b/src/lib/artifacts/artifactStore.ts @@ -27,6 +27,7 @@ import type { ArtifactMeta, ArtifactRef, ArtifactStore, + IndexEntry, } from "../types/index.js"; // Re-export so callers can import everything from one place @@ -38,9 +39,6 @@ import type { /** Characters used for the quick preview embedded in surrogate results. */ const DEFAULT_PREVIEW_CHARS = 500; -/** Index entry type (in-memory only). */ -type IndexEntry = ArtifactMeta & { path: string }; - /** * Filesystem-backed artifact store using the OS temp directory. * diff --git a/src/lib/auth/AuthProviderFactory.ts b/src/lib/auth/AuthProviderFactory.ts index a01ab1ecc..089ebc224 100644 --- a/src/lib/auth/AuthProviderFactory.ts +++ b/src/lib/auth/AuthProviderFactory.ts @@ -10,24 +10,12 @@ import { logger } from "../utils/logger.js"; import { AuthError } from "./errors.js"; import type { AuthProviderConfig, + AuthProviderConstructor, AuthProviderMetadata, + AuthProviderRegistration, MastraAuthProvider, } from "../types/index.js"; -// ============================================================================= -// TYPES -// ============================================================================= - -type AuthProviderConstructor = ( - config: AuthProviderConfig, -) => Promise; - -type AuthProviderRegistration = { - factory: AuthProviderConstructor; - aliases: string[]; - metadata?: AuthProviderMetadata; -}; - // ============================================================================= // FACTORY IMPLEMENTATION // ============================================================================= diff --git a/src/lib/auth/anthropicOAuth.ts b/src/lib/auth/anthropicOAuth.ts index 5365563cf..f8af98fbd 100644 --- a/src/lib/auth/anthropicOAuth.ts +++ b/src/lib/auth/anthropicOAuth.ts @@ -26,7 +26,10 @@ import { OAuthTokenRevocationError, OAuthCallbackServerError, } from "../types/index.js"; +import type { ClaudeCodeIdentity } from "../types/index.js"; import { logger } from "../utils/logger.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; /** * HTML-escape a string to prevent XSS when embedding in HTML responses. @@ -98,13 +101,6 @@ export const CLAUDE_CODE_VERSION = "2.1.87.6d6"; export const CLAUDE_CODE_ENTRYPOINT = "sdk-cli"; export const CLAUDE_CLI_USER_AGENT = "claude-cli/2.1.87 (external, sdk-cli)"; -type ClaudeCodeIdentity = { - deviceId: string; - accountUuid: string; - sessionId: string; - metadataUserId: string; -}; - const CLAUDE_CODE_IDENTITY_TTL_MS = 3_600_000; const CLAUDE_CODE_IDENTITY_CACHE_MAX_ENTRIES = 1024; const CLAUDE_CODE_IDENTITY_NAMESPACE = "neurolink-claude-code-identity-v1"; @@ -599,6 +595,21 @@ export class AnthropicOAuth { code: string, codeVerifier: string, config: AnthropicOAuthConfig = {}, + ): Promise { + return withSpan( + { + name: "neurolink.auth.oauth.exchange_code", + tracer: tracers.auth, + attributes: { "auth.oauth.grant_type": "authorization_code" }, + }, + async () => this._exchangeCodeForTokens(code, codeVerifier, config), + ); + } + + private async _exchangeCodeForTokens( + code: string, + codeVerifier: string, + config: AnthropicOAuthConfig = {}, ): Promise { if (!code) { throw new OAuthTokenExchangeError("Authorization code is required"); @@ -710,6 +721,20 @@ export class AnthropicOAuth { async refreshAccessToken( refreshToken: string, config: AnthropicOAuthConfig = {}, + ): Promise { + return withSpan( + { + name: "neurolink.auth.oauth.refresh", + tracer: tracers.auth, + attributes: { "auth.oauth.grant_type": "refresh_token" }, + }, + async () => this._refreshAccessToken(refreshToken, config), + ); + } + + private async _refreshAccessToken( + refreshToken: string, + config: AnthropicOAuthConfig = {}, ): Promise { if (!refreshToken) { throw new OAuthTokenRefreshError("Refresh token is required"); @@ -940,6 +965,20 @@ export class AnthropicOAuth { async revokeToken( token: string, tokenType: "access_token" | "refresh_token" = "access_token", + ): Promise { + return withSpan( + { + name: "neurolink.auth.oauth.revoke", + tracer: tracers.auth, + attributes: { "auth.oauth.token_type": tokenType }, + }, + async () => this._revokeToken(token, tokenType), + ); + } + + private async _revokeToken( + token: string, + tokenType: "access_token" | "refresh_token" = "access_token", ): Promise { if (!token) { throw new OAuthTokenRevocationError("Token is required for revocation"); diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index e461d07f6..9364d301d 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -160,3 +160,19 @@ export { // Server Bridge export { createAuthValidatorFromProvider } from "./serverBridge.js"; + +// ============================================================================= +// AUTH PROVIDER CLASSES — public re-exports (match the module docstring above) +// ============================================================================= + +export { Auth0Provider } from "./providers/auth0.js"; +export { BetterAuthProvider } from "./providers/betterAuth.js"; +export { ClerkProvider } from "./providers/clerk.js"; +export { CognitoProvider } from "./providers/CognitoProvider.js"; +export { CustomAuthProvider } from "./providers/custom.js"; +export { FirebaseAuthProvider } from "./providers/firebase.js"; +export { JWTProvider } from "./providers/jwt.js"; +export { KeycloakProvider } from "./providers/KeycloakProvider.js"; +export { OAuth2Provider } from "./providers/oauth2.js"; +export { SupabaseAuthProvider } from "./providers/supabase.js"; +export { WorkOSProvider } from "./providers/workos.js"; diff --git a/src/lib/auth/middleware/AuthMiddleware.ts b/src/lib/auth/middleware/AuthMiddleware.ts index d43076024..dcb6beb2d 100644 --- a/src/lib/auth/middleware/AuthMiddleware.ts +++ b/src/lib/auth/middleware/AuthMiddleware.ts @@ -15,11 +15,16 @@ import { AuthProviderFactory } from "../AuthProviderFactory.js"; import type { AuthErrorCode, AuthErrorInfo, - AuthenticatedContext, AuthMiddlewareConfig, - AuthorizationResult, + AuthMiddlewareHandler, + AuthMiddlewareResult, AuthRequestContext, - AuthUser, + AuthenticatedContext, + AuthorizationResult, + ExpressMiddleware, + IncomingRequest, + NextFunction, + OutgoingResponse, RBACMiddlewareConfig, TokenExtractionConfig, } from "../../types/index.js"; @@ -70,72 +75,6 @@ function createAuthErrorInfo( // TYPES // ============================================================================= -/** - * Minimal request object accepted by {@link createRequestContext}. - * - * Avoids `any` for Express/Koa/Hono request objects while remaining - * compatible with any framework that exposes these standard fields. - */ -type IncomingRequest = { - method?: string; - url?: string; - path?: string; - headers?: Record; - cookies?: Record; - query?: Record; - body?: unknown; - ip?: string; - /** Populated by auth middleware after successful authentication */ - user?: AuthUser; - /** Populated by auth middleware after successful authentication */ - authContext?: AuthenticatedContext; -}; - -/** - * Minimal response object for Express-style middleware. - */ -type OutgoingResponse = { - status(code: number): OutgoingResponse; - json(body: unknown): void; -}; - -/** - * Middleware handler function type - */ -type MiddlewareHandler = ( - context: TContext, -) => Promise; - -/** - * Middleware result - */ -type MiddlewareResult = { - /** Whether to proceed to next handler */ - proceed: boolean; - /** Updated context (if authenticated) */ - context?: AuthenticatedContext; - /** Error response if not proceeding */ - error?: { - statusCode: number; - message: string; - code?: string; - }; -}; - -/** - * Next function for middleware chaining - */ -type NextFunction = () => Promise; - -/** - * Express-style middleware function - */ -type ExpressMiddleware = ( - req: IncomingRequest, - res: OutgoingResponse, - next: NextFunction, -) => Promise; - // ============================================================================= // TOKEN EXTRACTION // ============================================================================= @@ -246,7 +185,7 @@ export async function extractToken( */ export async function createAuthMiddleware( config: AuthMiddlewareConfig, -): Promise> { +): Promise> { // Create provider instance const provider = await AuthProviderFactory.createProvider( config.provider, @@ -257,7 +196,7 @@ export async function createAuthMiddleware( `[AuthMiddleware] Created middleware with ${config.provider} provider`, ); - return async (context: AuthRequestContext): Promise => { + return async (context: AuthRequestContext): Promise => { try { // Check if route is public if (isPublicRoute(context.path ?? "", config.publicRoutes)) { @@ -411,8 +350,10 @@ export async function createAuthMiddleware( */ export function createRBACMiddleware( config: RBACMiddlewareConfig, -): MiddlewareHandler { - return async (context: AuthenticatedContext): Promise => { +): AuthMiddlewareHandler { + return async ( + context: AuthenticatedContext, + ): Promise => { try { const user = context.user; @@ -605,11 +546,11 @@ export function createRBACMiddleware( export async function createProtectedMiddleware(config: { auth: AuthMiddlewareConfig; rbac?: RBACMiddlewareConfig; -}): Promise> { +}): Promise> { const authMiddleware = await createAuthMiddleware(config.auth); const rbacMiddleware = config.rbac ? createRBACMiddleware(config.rbac) : null; - return async (context: AuthRequestContext): Promise => { + return async (context: AuthRequestContext): Promise => { // Run auth middleware const authResult = await authMiddleware(context); diff --git a/src/lib/auth/middleware/rateLimitByUser.ts b/src/lib/auth/middleware/rateLimitByUser.ts index 2a47d6b0c..b663fba03 100644 --- a/src/lib/auth/middleware/rateLimitByUser.ts +++ b/src/lib/auth/middleware/rateLimitByUser.ts @@ -1,9 +1,16 @@ // src/lib/auth/middleware/rateLimitByUser.ts import type { - AuthenticatedContext, + AtomicConsumeResult, + AuthRateLimitConfig, + AuthRateLimitRedisClient, AuthRequestContext, AuthUser, + AuthenticatedContext, + RateLimitMiddlewareResult, + RateLimitResult, + RateLimitStorage, + TokenBucket, } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; @@ -12,96 +19,6 @@ function maskUserId(id: string): string { return id.length > 4 ? `${id.slice(0, 4)}***` : "***"; } -/** - * Token bucket state for a single user - */ -type TokenBucket = { - /** Current number of tokens available */ - tokens: number; - /** Last time tokens were added */ - lastRefill: number; - /** User identifier */ - userId: string; -}; - -/** - * Rate limit configuration per user or role - */ -type RateLimitConfig = { - /** Maximum requests allowed in the window */ - maxRequests: number; - /** Time window in milliseconds */ - windowMs: number; - /** Optional: Different limits per role (role -> maxRequests) */ - roleLimits?: Record; - /** Optional: Different limits per user ID (userId -> maxRequests) */ - userLimits?: Record; - /** Skip rate limiting for these roles */ - skipRoles?: string[]; - /** Error message when rate limited */ - message?: string; -}; - -/** - * Rate limit result - */ -type RateLimitResult = { - /** Whether the request is allowed */ - allowed: boolean; - /** Remaining requests in the current window */ - remaining: number; - /** Time until the bucket resets (ms) */ - resetIn: number; - /** Total limit for this user */ - limit: number; - /** Error message if rate limited */ - error?: string; -}; - -/** - * Result of an atomic consume operation - */ -type AtomicConsumeResult = { - /** Updated bucket after the operation */ - bucket: TokenBucket; - /** Whether a token was successfully consumed */ - consumed: boolean; -}; - -/** - * Interface for rate limit storage backends - */ -type RateLimitStorage = { - /** Get the current bucket for a user */ - getBucket(userId: string): Promise; - /** Set the bucket for a user */ - setBucket(userId: string, bucket: TokenBucket): Promise; - /** Delete a bucket (for cleanup) */ - deleteBucket(userId: string): Promise; - /** Check storage health */ - healthCheck(): Promise; - /** Cleanup resources */ - cleanup(): Promise; - /** - * Atomically refill and consume a token from the bucket. - * - * Implementations SHOULD perform the refill-and-consume in a single - * atomic step (e.g. Lua script for Redis) to prevent race conditions - * where parallel requests read the same token count and both succeed. - * - * The default in-memory implementation is inherently single-threaded, - * so atomicity comes for free. - * - * @returns null when no bucket exists yet (caller should create one) - */ - atomicConsume?( - userId: string, - limit: number, - windowMs: number, - nowMs: number, - ): Promise; -}; - /** * In-memory storage for rate limiting (single instance deployments) */ @@ -162,8 +79,8 @@ export class RedisRateLimitStorage implements RateLimitStorage { private redisUrl: string; private prefix: string; private ttlSeconds: number; - private client: RedisClient | null = null; - private initPromise: Promise | null = null; + private client: AuthRateLimitRedisClient | null = null; + private initPromise: Promise | null = null; constructor(config: { url: string; @@ -179,7 +96,7 @@ export class RedisRateLimitStorage implements RateLimitStorage { this.ttlSeconds = Math.max(baseTtl, windowTtl); } - private async getClient(): Promise { + private async getClient(): Promise { if (this.client) { return this.client; } @@ -191,13 +108,13 @@ export class RedisRateLimitStorage implements RateLimitStorage { return this.initPromise; } - private async createClient(): Promise { + private async createClient(): Promise { try { // Dynamic import to avoid loading Redis unless needed const { createClient } = await import("redis"); const client = createClient({ url: this.redisUrl }); await client.connect(); - this.client = client as unknown as RedisClient; + this.client = client as unknown as AuthRateLimitRedisClient; return this.client; } catch { this.initPromise = null; @@ -365,15 +282,6 @@ export class RedisRateLimitStorage implements RateLimitStorage { } // Type for Redis client (simplified interface) -type RedisClient = { - connect(): Promise; - quit(): Promise; - ping(): Promise; - get(key: string): Promise; - setEx(key: string, seconds: number, value: string): Promise; - del(key: string): Promise; - eval(script: string, numkeys: number, ...args: string[]): Promise; -}; /** * Token bucket rate limiter implementation @@ -384,9 +292,9 @@ type RedisClient = { */ export class UserRateLimiter { private storage: RateLimitStorage; - private config: RateLimitConfig; + private config: AuthRateLimitConfig; - constructor(config: RateLimitConfig, storage?: RateLimitStorage) { + constructor(config: AuthRateLimitConfig, storage?: RateLimitStorage) { this.config = { message: "Rate limit exceeded. Please try again later.", ...config, @@ -616,14 +524,6 @@ export class UserRateLimiter { /** * Middleware result type */ -type RateLimitMiddlewareResult = { - /** Whether to proceed with the request */ - proceed: boolean; - /** Rate limit result */ - rateLimitResult: RateLimitResult; - /** Error response if rate limited */ - response?: Response; -}; /** * Create rate limiting middleware for authenticated requests @@ -655,7 +555,7 @@ type RateLimitMiddlewareResult = { * ``` */ export function createRateLimitByUserMiddleware( - config: RateLimitConfig, + config: AuthRateLimitConfig, storage?: RateLimitStorage, ): (context: AuthenticatedContext) => Promise { const limiter = new UserRateLimiter(config, storage); @@ -712,7 +612,7 @@ export function createAuthenticatedRateLimitMiddleware( context?: AuthenticatedContext; response?: Response; }>, - rateLimitConfig: RateLimitConfig, + rateLimitConfig: AuthRateLimitConfig, storage?: RateLimitStorage, ): (context: AuthRequestContext) => Promise<{ proceed: boolean; diff --git a/src/lib/auth/providers/CognitoProvider.ts b/src/lib/auth/providers/CognitoProvider.ts index 9278e3385..d0f273018 100644 --- a/src/lib/auth/providers/CognitoProvider.ts +++ b/src/lib/auth/providers/CognitoProvider.ts @@ -8,6 +8,7 @@ import { importJWK, jwtVerify } from "jose"; import { logger } from "../../utils/logger.js"; import type { JsonValue, + AuthJWKSCacheEntry, AuthProviderConfig, AuthUser, CognitoConfig, @@ -22,12 +23,7 @@ import { BaseAuthProvider } from "./BaseAuthProvider.js"; // JWKS CACHE // ============================================================================= -type JWKSCacheEntry = { - jwks: JWKS; - expiresAt: number; -}; - -const jwksCache = new Map(); +const jwksCache = new Map(); // ============================================================================= // COGNITO PROVIDER diff --git a/src/lib/auth/providers/KeycloakProvider.ts b/src/lib/auth/providers/KeycloakProvider.ts index 75170f7f7..f69efe279 100644 --- a/src/lib/auth/providers/KeycloakProvider.ts +++ b/src/lib/auth/providers/KeycloakProvider.ts @@ -7,6 +7,7 @@ import { importJWK, jwtVerify } from "jose"; import { logger } from "../../utils/logger.js"; import type { + AuthJWKSCacheEntry, AuthProviderConfig, AuthUser, JWKS, @@ -21,12 +22,7 @@ import { BaseAuthProvider } from "./BaseAuthProvider.js"; // JWKS CACHE // ============================================================================= -type JWKSCacheEntry = { - jwks: JWKS; - expiresAt: number; -}; - -const jwksCache = new Map(); +const jwksCache = new Map(); // ============================================================================= // KEYCLOAK PROVIDER diff --git a/src/lib/auth/providers/auth0.ts b/src/lib/auth/providers/auth0.ts index e7cb6736e..a0ed62f17 100644 --- a/src/lib/auth/providers/auth0.ts +++ b/src/lib/auth/providers/auth0.ts @@ -3,35 +3,19 @@ import { BaseAuthProvider } from "./BaseAuthProvider.js"; import { AuthError } from "../errors.js"; import type { - AuthProviderConfig, Auth0Config, - AuthUser, - TokenValidationResult, - AuthRequestContext, + Auth0TokenPayload, AuthHealthCheck, + AuthProviderConfig, AuthProviderType, + AuthRequestContext, + AuthUser, + TokenValidationResult, } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; import { createProxyFetch } from "../../proxy/proxyFetch.js"; import * as jose from "jose"; -/** - * Auth0 token payload structure - */ -type Auth0TokenPayload = { - sub: string; - email?: string; - name?: string; - picture?: string; - email_verified?: boolean; - roles?: string[]; - permissions?: string[]; - iat: number; - exp: number; - aud: string | string[]; - iss: string; -}; - /** * Auth0 Authentication Provider * diff --git a/src/lib/auth/sessionManager.ts b/src/lib/auth/sessionManager.ts index dfa370e5e..189d17995 100644 --- a/src/lib/auth/sessionManager.ts +++ b/src/lib/auth/sessionManager.ts @@ -9,6 +9,8 @@ import type { } from "../types/index.js"; import { withTimeout } from "../utils/async/withTimeout.js"; import { logger } from "../utils/logger.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; /** Mask an identifier for safe logging: show first 4 chars + "***" */ function maskId(id: string): string { @@ -19,7 +21,6 @@ function maskId(id: string): string { } const REDIS_CONNECT_TIMEOUT_MS = 5000; -type RedisClient = RedisClientType; /** * In-memory session storage @@ -125,8 +126,8 @@ export class RedisSessionStorage implements SessionManagerStorage { private prefix: string; private ttl: number; private redisUrl: string; - private client: RedisClient | null = null; - private initPromise: Promise | null = null; + private client: RedisClientType | null = null; + private initPromise: Promise | null = null; constructor(config: { url: string; prefix?: string; ttl?: number }) { this.redisUrl = config.url; @@ -134,7 +135,7 @@ export class RedisSessionStorage implements SessionManagerStorage { this.ttl = config.ttl || 3600; } - private async getClient(): Promise { + private async getClient(): Promise { if (this.client) { return this.client; } @@ -146,14 +147,14 @@ export class RedisSessionStorage implements SessionManagerStorage { return this.initPromise; } - private async createClient(): Promise { + private async createClient(): Promise { try { // Use variable indirection to prevent TypeScript from resolving the module at compile time const moduleName = "redis"; const redisModule = (await import( /* @vite-ignore */ moduleName )) as typeof import("redis"); - const client: RedisClient = redisModule.createClient({ + const client: RedisClientType = redisModule.createClient({ url: this.redisUrl, }); client.on("error", (err: Error) => { @@ -412,6 +413,27 @@ export class SessionManager { userAgent?: string; deviceId?: string; }, + ): Promise { + return withSpan( + { + name: "neurolink.auth.session.create", + tracer: tracers.auth, + attributes: { + "auth.user_id": maskId(user.id), + "auth.storage": this.config.storage ?? "memory", + }, + }, + async () => this._createSession(user, metadata), + ); + } + + private async _createSession( + user: AuthUser, + metadata?: { + ipAddress?: string; + userAgent?: string; + deviceId?: string; + }, ): Promise { const sessionId = crypto.randomUUID(); const now = new Date(); @@ -444,6 +466,27 @@ export class SessionManager { async getSession( sessionId: string, autoRefresh = this.config.autoRefresh, + ): Promise { + return withSpan( + { + name: "neurolink.auth.session.get", + tracer: tracers.auth, + attributes: { + "auth.session_id": maskId(sessionId), + "auth.auto_refresh": autoRefresh ?? false, + }, + }, + async (span) => { + const result = await this._getSession(sessionId, autoRefresh); + span.setAttribute("auth.found", result !== null); + return result; + }, + ); + } + + private async _getSession( + sessionId: string, + autoRefresh = this.config.autoRefresh, ): Promise { const session = await this.storage.get(sessionId); @@ -475,19 +518,30 @@ export class SessionManager { * Refresh a session */ async refreshSession(sessionId: string): Promise { - const session = await this.storage.get(sessionId); - - if (!session) { - return null; - } + return withSpan( + { + name: "neurolink.auth.session.refresh", + tracer: tracers.auth, + attributes: { "auth.session_id": maskId(sessionId) }, + }, + async (span) => { + const session = await this.storage.get(sessionId); + + if (!session) { + span.setAttribute("auth.found", false); + return null; + } - const duration = this.config.duration || 3600; - session.expiresAt = new Date(Date.now() + duration * 1000); + const duration = this.config.duration || 3600; + session.expiresAt = new Date(Date.now() + duration * 1000); - await this.storage.set(session); - logger.debug(`Session refreshed: ${maskId(sessionId)}`); + await this.storage.set(session); + span.setAttribute("auth.found", true); + logger.debug(`Session refreshed: ${maskId(sessionId)}`); - return session; + return session; + }, + ); } /** @@ -517,8 +571,19 @@ export class SessionManager { * Validate a session is still active */ async validateSession(sessionId: string): Promise { - const session = await this.storage.get(sessionId); - return session !== null && session.isValid; + return withSpan( + { + name: "neurolink.auth.session.validate", + tracer: tracers.auth, + attributes: { "auth.session_id": maskId(sessionId) }, + }, + async (span) => { + const session = await this.storage.get(sessionId); + const valid = session !== null && session.isValid; + span.setAttribute("auth.valid", valid); + return valid; + }, + ); } /** diff --git a/src/lib/auth/tokenStore.ts b/src/lib/auth/tokenStore.ts index 06ae3050c..d509263e0 100644 --- a/src/lib/auth/tokenStore.ts +++ b/src/lib/auth/tokenStore.ts @@ -20,6 +20,8 @@ import { createHash } from "crypto"; import { logger } from "../utils/logger.js"; import { TokenStoreError } from "../types/index.js"; import { AsyncMutex } from "../utils/asyncMutex.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; import type { TokenStorageData, StoredOAuthTokens, @@ -133,9 +135,21 @@ export class TokenStore { * @throws TokenStoreError if storage fails */ async saveTokens(provider: string, tokens: StoredOAuthTokens): Promise { - return this._mutex.runExclusive(async () => { - await this._saveTokensInternal(provider, tokens); - }); + return withSpan( + { + name: "neurolink.auth.token.save", + tracer: tracers.auth, + attributes: { + "auth.provider": provider, + "auth.has_refresh_token": Boolean(tokens.refreshToken), + "auth.token_type": tokens.tokenType, + }, + }, + async () => + this._mutex.runExclusive(async () => { + await this._saveTokensInternal(provider, tokens); + }), + ); } /** @@ -219,9 +233,23 @@ export class TokenStore { * @throws TokenStoreError if reading fails (other than file not found) */ async loadTokens(provider: string): Promise { - return this._mutex.runExclusive(async () => { - return this._loadTokensInternal(provider); - }); + return withSpan( + { + name: "neurolink.auth.token.load", + tracer: tracers.auth, + attributes: { "auth.provider": provider }, + }, + async (span) => { + const result = await this._mutex.runExclusive(async () => { + return this._loadTokensInternal(provider); + }); + span.setAttribute("auth.found", result !== null); + if (result) { + span.setAttribute("auth.expired", this.isTokenExpired(result, 0)); + } + return result; + }, + ); } /** @@ -265,6 +293,17 @@ export class TokenStore { * @throws TokenStoreError if deletion fails */ async clearTokens(provider: string): Promise { + return withSpan( + { + name: "neurolink.auth.token.clear", + tracer: tracers.auth, + attributes: { "auth.provider": provider }, + }, + async () => this._clearTokensImpl(provider), + ); + } + + private async _clearTokensImpl(provider: string): Promise { return this._mutex.runExclusive(async () => { // Clear in-memory refresh state so re-adding an account starts fresh this.inFlightRefreshes.delete(provider); @@ -333,6 +372,20 @@ export class TokenStore { * @throws TokenStoreError if refresh fails */ async getValidToken(provider: string): Promise { + return withSpan( + { + name: "neurolink.auth.token.get_valid", + tracer: tracers.auth, + attributes: { "auth.provider": provider }, + }, + async (span) => this._getValidTokenImpl(provider, span), + ); + } + + private async _getValidTokenImpl( + provider: string, + span: import("@opentelemetry/api").Span, + ): Promise { // Phase 1: Read token under mutex (fast) const snapshot = await this._mutex.runExclusive(async () => { const tokens = await this._loadTokensInternal(provider); @@ -343,15 +396,22 @@ export class TokenStore { }); if (!snapshot) { + span.setAttribute("auth.found", false); logger.debug("No tokens found for provider", { provider }); return null; } + span.setAttribute("auth.found", true); + // Token is still valid — return immediately if (!this.isTokenExpired(snapshot)) { + span.setAttribute("auth.refreshed", false); + span.setAttribute("auth.expired", false); return snapshot.accessToken; } + span.setAttribute("auth.expired", true); + logger.debug("Token expired or expiring soon", { provider, expiresAt: new Date(snapshot.expiresAt).toISOString(), @@ -449,6 +509,7 @@ export class TokenStore { this.inFlightRefreshes.set(provider, refreshPromise); try { const newTokens = await refreshPromise; + span.setAttribute("auth.refreshed", true); return newTokens.accessToken; } finally { this.inFlightRefreshes.delete(provider); diff --git a/src/lib/autoresearch/tools.ts b/src/lib/autoresearch/tools.ts index 1044e2082..fc12ce7cc 100644 --- a/src/lib/autoresearch/tools.ts +++ b/src/lib/autoresearch/tools.ts @@ -13,26 +13,11 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { tool } from "ai"; import { z } from "zod"; -import type { ExperimentRecord, ResearchConfig } from "../types/index.js"; +import type { ExperimentRecord, ResearchToolsDeps } from "../types/index.js"; import { withTimeout } from "../utils/errorHandling.js"; import { logger } from "../utils/logger.js"; -import type { RepoPolicy } from "./repoPolicy.js"; -import type { ResultRecorder } from "./resultRecorder.js"; -import type { ExperimentRunner } from "./runner.js"; -import type { ResearchStateStore } from "./stateStore.js"; import { parseExperimentSummary } from "./summaryParser.js"; -/** - * Dependencies required to create research tools. - */ -type ResearchToolsDeps = { - config: ResearchConfig; - stateStore: ResearchStateStore; - repoPolicy: RepoPolicy; - runner: ExperimentRunner; - recorder: ResultRecorder; -}; - /** * Create research management tools bound to a research session. * diff --git a/src/lib/client/aiSdkAdapter.ts b/src/lib/client/aiSdkAdapter.ts index c2ce32168..f9d287490 100644 --- a/src/lib/client/aiSdkAdapter.ts +++ b/src/lib/client/aiSdkAdapter.ts @@ -10,13 +10,14 @@ import { logger } from "../utils/logger.js"; import type { + AiSdkStreamChunk, // ClientConfig - not currently used but may be needed for future implementations ClientLanguageModel, ClientLanguageModelCallOptions, ClientLanguageModelResponse, ClientLanguageModelStreamResponse, - NeuroLinkProviderOptions, ClientModelOptions, + NeuroLinkProviderOptions, } from "../types/index.js"; import { createClient, NeuroLinkClient } from "./httpClient.js"; @@ -145,14 +146,7 @@ export class NeuroLinkLanguageModel implements ClientLanguageModel { system ?? messages?.find((m) => m.role === "system")?.content; // ---- Async queue (push/pull pattern) ---- - type StreamChunk = { - type: "text-delta" | "finish"; - textDelta?: string; - finishReason?: string; - usage?: { promptTokens: number; completionTokens: number }; - }; - - const buffer: StreamChunk[] = []; + const buffer: AiSdkStreamChunk[] = []; let finished = false; let notifyConsumer: (() => void) | null = null; @@ -166,7 +160,7 @@ export class NeuroLinkLanguageModel implements ClientLanguageModel { } /** Push a chunk into the queue and wake the consumer. */ - function push(chunk: StreamChunk): void { + function push(chunk: AiSdkStreamChunk): void { buffer.push(chunk); wake(); } @@ -236,7 +230,7 @@ export class NeuroLinkLanguageModel implements ClientLanguageModel { }); // ---- Async iterable that pulls from the queue ---- - async function* createStream(): AsyncIterable { + async function* createStream(): AsyncIterable { while (true) { // Drain anything already buffered. while (buffer.length > 0) { diff --git a/src/lib/client/httpClient.ts b/src/lib/client/httpClient.ts index 743280942..26bbefc73 100644 --- a/src/lib/client/httpClient.ts +++ b/src/lib/client/httpClient.ts @@ -39,6 +39,8 @@ import type { } from "../types/index.js"; import { HttpError, ClientNetworkError, ClientTimeoutError } from "./errors.js"; import { logger } from "../utils/logger.js"; +import { tracers } from "../telemetry/tracers.js"; +import { withClientSpan } from "../telemetry/withSpan.js"; // ============================================================================= // Shared Utilities @@ -250,6 +252,26 @@ export class NeuroLinkClient { path: string, body?: unknown, options?: ClientRequestOptions, + ): Promise> { + return withClientSpan( + { + name: "neurolink.client.http.request", + tracer: tracers.http, + attributes: { + "http.method": method, + "http.route": path, + "http.url": `${this.config.baseUrl}${path}`, + }, + }, + async () => this._doRequest(method, path, body, options), + ); + } + + private async _doRequest( + method: string, + path: string, + body?: unknown, + options?: ClientRequestOptions, ): Promise> { const url = `${this.config.baseUrl}${path}`; const requestId = this.generateRequestId(); diff --git a/src/lib/client/sseClient.ts b/src/lib/client/sseClient.ts index db9649c28..85116975c 100644 --- a/src/lib/client/sseClient.ts +++ b/src/lib/client/sseClient.ts @@ -13,31 +13,16 @@ import type { ClientStreamEvent as StreamEvent, ClientStreamResult as StreamResult, ClientApiError, + ClientInternalConfig, SSEConfig, SSEEventHandlers, SSERequestOptions, SSEState, } from "../types/index.js"; +import { SpanStatusCode } from "@opentelemetry/api"; import { logger } from "../utils/logger.js"; -// ============================================================================= -// Types -// ============================================================================= -// ============================================================================= -// Internal Types -// ============================================================================= - -type InternalConfig = { - baseUrl: string; - apiKey: string; - token: string; - timeout: number; - headers: Record; - autoReconnect: boolean; - maxReconnectAttempts: number; - reconnectDelay: number; - maxReconnectDelay: number; - useNativeEventSource: boolean; -}; +import { withClientSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; // ============================================================================= // SSE Client Implementation @@ -66,7 +51,7 @@ type InternalConfig = { * ``` */ export class NeuroLinkSSE { - private config: InternalConfig; + private config: ClientInternalConfig; private state: SSEState = "disconnected"; private abortController: AbortController | null = null; private reconnectAttempts = 0; @@ -108,6 +93,40 @@ export class NeuroLinkSSE { path: string, options: SSERequestOptions = {}, callbacks: ClientStreamCallbacks = {}, + ): Promise { + return withClientSpan( + { + name: "neurolink.client.sse.stream", + tracer: tracers.http, + attributes: { + "http.method": options.body ? "POST" : "GET", + "http.route": path, + "http.url": this.buildUrl(path), + "sse.auto_reconnect": this.config.autoReconnect, + "sse.native_event_source": this.config.useNativeEventSource, + }, + }, + async (span) => { + // Wrap onError callback so the span is marked failed on stream errors + const wrappedCallbacks: ClientStreamCallbacks = { + ...callbacks, + onError: (error) => { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error?.message ?? "SSE stream error", + }); + callbacks.onError?.(error); + }, + }; + return this._streamInternal(path, options, wrappedCallbacks); + }, + ); + } + + private async _streamInternal( + path: string, + options: SSERequestOptions = {}, + callbacks: ClientStreamCallbacks = {}, ): Promise { const url = this.buildUrl(path); const isGetRequest = !options.body; diff --git a/src/lib/client/streamingClient.ts b/src/lib/client/streamingClient.ts index c638f937b..6e8f1b8ec 100644 --- a/src/lib/client/streamingClient.ts +++ b/src/lib/client/streamingClient.ts @@ -26,6 +26,8 @@ import type { } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { combineSignals, sleep } from "./httpClient.js"; +import { withClientSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; // ============================================================================= // Types // ============================================================================= @@ -87,6 +89,25 @@ export class SSEClient { body?: unknown; headers?: Record; } = {}, + ): Promise { + return withClientSpan( + { + name: "neurolink.client.streaming.sse.connect", + tracer: tracers.http, + attributes: { + "http.url": this.url, + "http.method": requestOptions.body ? "POST" : "GET", + }, + }, + async () => this._connectSSE(requestOptions), + ); + } + + private async _connectSSE( + requestOptions: { + body?: unknown; + headers?: Record; + } = {}, ): Promise { if (this.state === "connected" || this.state === "connecting") { return; @@ -462,6 +483,17 @@ export class WebSocketStreamingClient { * Connect to WebSocket server */ async connect(): Promise { + return withClientSpan( + { + name: "neurolink.client.streaming.ws.connect", + tracer: tracers.http, + attributes: { "http.url": this.options.url }, + }, + async () => this._connectWS(), + ); + } + + private async _connectWS(): Promise { if (typeof WebSocket === "undefined") { throw new Error( "WebSocket is not available. Please use a polyfill for non-browser environments.", diff --git a/src/lib/client/wsClient.ts b/src/lib/client/wsClient.ts index 64ce3bcd9..664c50962 100644 --- a/src/lib/client/wsClient.ts +++ b/src/lib/client/wsClient.ts @@ -8,35 +8,18 @@ * @module @neurolink/client/wsClient */ +import { SpanKind, SpanStatusCode, type Span } from "@opentelemetry/api"; import type { ClientStreamCallbacks, ClientStreamEvent as StreamEvent, ClientStreamResult as StreamResult, WebSocketEventHandlers, ClientClientWebSocketState, + ClientInternalConfig, ClientWebSocketMessage, ClientWebSocketConfig, } from "../types/index.js"; -// ============================================================================= -// Type Aliases (re-export canonical types under the original public names) -// ============================================================================= -// ============================================================================= -// Internal Types -// ============================================================================= - -type InternalConfig = { - baseUrl: string; - apiKey: string; - token: string; - timeout: number; - headers: Record; - autoReconnect: boolean; - maxReconnectAttempts: number; - reconnectDelay: number; - maxReconnectDelay: number; - heartbeatInterval: number; - queueSize: number; -}; +import { tracers } from "../telemetry/tracers.js"; // ============================================================================= // WebSocket Client @@ -69,7 +52,7 @@ type InternalConfig = { */ export class NeuroLinkWebSocket { private ws: WebSocket | null = null; - private config: InternalConfig; + private config: ClientInternalConfig; private state: ClientClientWebSocketState = "disconnected"; private reconnectAttempts = 0; private heartbeatTimer: ReturnType | null = null; @@ -77,6 +60,12 @@ export class NeuroLinkWebSocket { private eventHandlers: WebSocketEventHandlers = {}; private subscriptions = new Map(); private pendingAuth = false; + /** + * Active OTel span for the current WebSocket connection lifecycle. + * Created at connect() and ended on close/error so we capture connection + * lifetime, reconnect counts, and error attribution in Langfuse. + */ + private connectionSpan: Span | null = null; /** * Local flag to suppress reconnection during an explicit disconnect(). @@ -129,6 +118,33 @@ export class NeuroLinkWebSocket { this.eventHandlers = handlers ?? {}; this.setState("connecting"); + // End any orphaned span from a prior connect() attempt (e.g., re-entrant call + // while a previous attempt was still connecting). + if (this.connectionSpan) { + this.connectionSpan.setAttribute("ws.superseded", true); + this.connectionSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: "Connection attempt superseded by new connect() call", + }); + this.connectionSpan.end(); + this.connectionSpan = null; + } + + // Start an OTel span that tracks the lifetime of this connection attempt. + // Ended in onclose/onerror/disconnect so metrics capture connection + // duration and error attribution. + this.connectionSpan = tracers.http.startSpan( + "neurolink.client.ws.connect", + { + kind: SpanKind.CLIENT, + attributes: { + "http.url": this.config.baseUrl, + "ws.auto_reconnect": this.config.autoReconnect, + "ws.reconnect_attempt": this.reconnectAttempts, + }, + }, + ); + // Build WebSocket URL (credentials are sent via headers, not query params, // to avoid leaking secrets in server logs, browser history, and HTTP referers) const url = new URL(this.config.baseUrl); @@ -151,17 +167,28 @@ export class NeuroLinkWebSocket { typeof globalThis.process !== "undefined" && typeof globalThis.process.versions?.node === "string"; - if (isNode && Object.keys(authHeaders).length > 0) { - // The `ws` npm package accepts a second `options` object with a `headers` - // property. The DOM WebSocket type does not model this, so we cast - // through `unknown` to satisfy TypeScript while remaining correct at - // runtime under Node.js. - this.ws = new (WebSocket as unknown as new ( - url: string, - opts: { headers: Record }, - ) => WebSocket)(url.toString(), { headers: authHeaders }); - } else { - this.ws = new WebSocket(url.toString()); + try { + if (isNode && Object.keys(authHeaders).length > 0) { + this.ws = new (WebSocket as unknown as new ( + url: string, + opts: { headers: Record }, + ) => WebSocket)(url.toString(), { headers: authHeaders }); + } else { + this.ws = new WebSocket(url.toString()); + } + } catch (error) { + if (this.connectionSpan) { + this.connectionSpan.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + this.connectionSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + this.connectionSpan.end(); + this.connectionSpan = null; + } + throw error; } this.pendingAuth = !isNode && (!!this.config.apiKey || !!this.config.token); @@ -184,6 +211,16 @@ export class NeuroLinkWebSocket { } this.setState("disconnected"); + + // Let onclose finalize the span — it fires from ws.close(1000,...) and + // has the close code context. We only end here if ws is already null + // (e.g. connect was never called) to avoid leaking. + if (this.connectionSpan && !this.ws) { + this.connectionSpan.setAttribute("ws.close_reason", "client_disconnect"); + this.connectionSpan.setStatus({ code: SpanStatusCode.OK }); + this.connectionSpan.end(); + this.connectionSpan = null; + } } /** @@ -194,7 +231,7 @@ export class NeuroLinkWebSocket { this.ws.send(JSON.stringify(message)); } else { // Queue message for when connected - if (this.messageQueue.length < this.config.queueSize) { + if (this.messageQueue.length < (this.config.queueSize ?? 100)) { this.messageQueue.push(message); } } @@ -261,6 +298,10 @@ export class NeuroLinkWebSocket { this.setState("connected"); this.reconnectAttempts = 0; + if (this.connectionSpan) { + this.connectionSpan.setAttribute("ws.connected", true); + } + // In browser environments, send credentials as the first message // since the browser WebSocket API does not support custom headers. if (this.pendingAuth && this.ws) { @@ -289,6 +330,24 @@ export class NeuroLinkWebSocket { this.stopHeartbeat(); this.eventHandlers.onClose?.(event.code, event.reason); + if (this.connectionSpan) { + this.connectionSpan.setAttribute("ws.close_code", event.code); + if (event.reason) { + this.connectionSpan.setAttribute("ws.close_reason", event.reason); + } + // 1000 = normal closure; other codes are abnormal. + if (event.code === 1000) { + this.connectionSpan.setStatus({ code: SpanStatusCode.OK }); + } else { + this.connectionSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: `WebSocket closed with code ${event.code}${event.reason ? `: ${event.reason}` : ""}`, + }); + } + this.connectionSpan.end(); + this.connectionSpan = null; + } + // Only attempt reconnection when auto-reconnect is enabled AND this // was not an intentional disconnect (code 1000 or explicit call). if ( @@ -304,6 +363,17 @@ export class NeuroLinkWebSocket { this.setState("error"); const error = new Error("WebSocket connection error"); this.eventHandlers.onError?.(error); + + if (this.connectionSpan) { + this.connectionSpan.recordException(error); + this.connectionSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error.message, + }); + // Do not end here — onclose will fire next and end the span with + // the precise close code. Keeping the span open until close gives + // us the full connection lifetime on Langfuse. + } }; this.ws.onmessage = (event) => { @@ -380,7 +450,7 @@ export class NeuroLinkWebSocket { if (this.isConnected()) { this.send({ type: "ping" }); } - }, this.config.heartbeatInterval); + }, this.config.heartbeatInterval ?? 30000); } private stopHeartbeat(): void { diff --git a/src/lib/context/budgetChecker.ts b/src/lib/context/budgetChecker.ts index 947fbc7c7..1663c5332 100644 --- a/src/lib/context/budgetChecker.ts +++ b/src/lib/context/budgetChecker.ts @@ -20,6 +20,7 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { getActiveTraceContext } from "../telemetry/traceContext.js"; /** Default compaction threshold (80% of available input) */ const DEFAULT_COMPACTION_THRESHOLD = 0.8; @@ -36,12 +37,15 @@ const TOKENS_PER_TOOL_DEFINITION = 200; export function checkContextBudget( params: BudgetCheckParams, ): BudgetCheckResult { + const { traceId, parentSpanId } = getActiveTraceContext(); const span = SpanSerializer.createSpan( SpanType.CONTEXT_COMPACTION, "context.budgetCheck", { "context.operation": "budgetCheck", }, + parentSpanId, + traceId, ); const startTime = Date.now(); diff --git a/src/lib/context/contextCompactor.ts b/src/lib/context/contextCompactor.ts index c6dcdf44f..1159df3d3 100644 --- a/src/lib/context/contextCompactor.ts +++ b/src/lib/context/contextCompactor.ts @@ -25,6 +25,9 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { getActiveTraceContext } from "../telemetry/traceContext.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; import { pruneToolOutputs } from "./stages/toolOutputPruner.js"; import { deduplicateFileReads } from "./stages/fileReadDeduplicator.js"; import { truncateWithSlidingWindow } from "./stages/slidingWindowTruncator.js"; @@ -61,221 +64,245 @@ export class ContextCompactor { memoryConfig?: Partial, requestId?: string, ): Promise { - let span = SpanSerializer.createSpan( - SpanType.CONTEXT_COMPACTION, - "context.compact", + return withSpan( { - "context.operation": "compact", - "context.targetTokens": targetTokens, + name: "neurolink.context.compact", + tracer: tracers.context, + attributes: { + "context.target_tokens": targetTokens, + "context.message_count": messages.length, + }, }, - ); - const spanStartTime = Date.now(); - - try { - const provider = this.config.provider || undefined; - const tokensBefore = estimateMessagesTokens(messages, provider); - const stagesUsed: CompactionStage[] = []; - let currentMessages = [...messages]; - - logger.info("[Compaction] Starting", { - requestId, - estimatedTokens: tokensBefore, - budgetTokens: targetTokens, - }); - - // Stage 1: Tool Output Pruning - if ( - this.config.enablePrune && - estimateMessagesTokens(currentMessages, provider) > targetTokens - ) { - const stageTokensBefore = estimateMessagesTokens( - currentMessages, - provider, - ); - const pruneResult = pruneToolOutputs(currentMessages, { - protectTokens: this.config.pruneProtectTokens, - minimumSavings: this.config.pruneMinimumSavings, - protectedTools: this.config.pruneProtectedTools, - provider, - }); - if (pruneResult.pruned) { - currentMessages = pruneResult.messages; - stagesUsed.push("prune"); - } - const stageTokensAfter = estimateMessagesTokens( - currentMessages, - provider, + async () => { + const { traceId, parentSpanId } = getActiveTraceContext(); + let span = SpanSerializer.createSpan( + SpanType.CONTEXT_COMPACTION, + "context.compact", + { + "context.operation": "compact", + "context.targetTokens": targetTokens, + }, + parentSpanId, + traceId, ); - logger.info("[Compaction] Stage 1 (prune)", { - requestId, - ran: pruneResult.pruned, - tokensBefore: stageTokensBefore, - tokensAfter: stageTokensAfter, - saved: stageTokensBefore - stageTokensAfter, - }); - } + const spanStartTime = Date.now(); - // Stage 2: File Read Deduplication - if ( - this.config.enableDeduplicate && - estimateMessagesTokens(currentMessages, provider) > targetTokens - ) { - const stageTokensBefore = estimateMessagesTokens( - currentMessages, - provider, - ); - const dedupResult = deduplicateFileReads(currentMessages); - if (dedupResult.deduplicated) { - currentMessages = dedupResult.messages; - stagesUsed.push("deduplicate"); - } - const stageTokensAfter = estimateMessagesTokens( - currentMessages, - provider, - ); - logger.info("[Compaction] Stage 2 (deduplicate)", { - requestId, - ran: dedupResult.deduplicated, - tokensBefore: stageTokensBefore, - tokensAfter: stageTokensAfter, - saved: stageTokensBefore - stageTokensAfter, - }); - } - - // Stage 3: LLM Summarization - if ( - this.config.enableSummarize && - estimateMessagesTokens(currentMessages, provider) > targetTokens - ) { - const stageTokensBefore = estimateMessagesTokens( - currentMessages, - provider, - ); try { - const summarizeResult = await withTimeout( - summarizeMessages(currentMessages, { - provider: this.config.summarizationProvider, - model: this.config.summarizationModel, - keepRecentRatio: this.config.keepRecentRatio, - memoryConfig, - targetTokens, - }), - 120_000, - "LLM summarization timed out after 120s", - ); - if (summarizeResult.summarized) { - currentMessages = summarizeResult.messages; - stagesUsed.push("summarize"); - } - const stageTokensAfter = estimateMessagesTokens( - currentMessages, - provider, - ); - logger.info("[Compaction] Stage 3 (summarize)", { - requestId, - ran: summarizeResult.summarized, - tokensBefore: stageTokensBefore, - tokensAfter: stageTokensAfter, - saved: stageTokensBefore - stageTokensAfter, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); + const provider = this.config.provider || undefined; + const tokensBefore = estimateMessagesTokens(messages, provider); + const stagesUsed: CompactionStage[] = []; + let currentMessages = [...messages]; - logger.warn("[Compaction] Stage 3 (summarize) FAILED", { + logger.info("[Compaction] Starting", { requestId, - error: err.message, - errorName: err.name, - tokensBefore: stageTokensBefore, - tokensAfter: stageTokensBefore, - saved: 0, + estimatedTokens: tokensBefore, + budgetTokens: targetTokens, }); - // Record failure on the compaction span for trace visibility - span = SpanSerializer.updateAttributes(span, { - "compaction.stage3.error": err.message, - "compaction.stage3.errorName": err.name, - "compaction.stage3.tokensBefore": stageTokensBefore, - "compaction.stage3_failed": true, - }); + // Stage 1: Tool Output Pruning + if ( + this.config.enablePrune && + estimateMessagesTokens(currentMessages, provider) > targetTokens + ) { + const stageTokensBefore = estimateMessagesTokens( + currentMessages, + provider, + ); + const pruneResult = pruneToolOutputs(currentMessages, { + protectTokens: this.config.pruneProtectTokens, + minimumSavings: this.config.pruneMinimumSavings, + protectedTools: this.config.pruneProtectedTools, + provider, + }); + if (pruneResult.pruned) { + currentMessages = pruneResult.messages; + stagesUsed.push("prune"); + } + const stageTokensAfter = estimateMessagesTokens( + currentMessages, + provider, + ); + logger.info("[Compaction] Stage 1 (prune)", { + requestId, + ran: pruneResult.pruned, + tokensBefore: stageTokensBefore, + tokensAfter: stageTokensAfter, + saved: stageTokensBefore - stageTokensAfter, + }); + } - // Fall through to Stage 4 truncation as before - } - } + // Stage 2: File Read Deduplication + if ( + this.config.enableDeduplicate && + estimateMessagesTokens(currentMessages, provider) > targetTokens + ) { + const stageTokensBefore = estimateMessagesTokens( + currentMessages, + provider, + ); + const dedupResult = deduplicateFileReads(currentMessages); + if (dedupResult.deduplicated) { + currentMessages = dedupResult.messages; + stagesUsed.push("deduplicate"); + } + const stageTokensAfter = estimateMessagesTokens( + currentMessages, + provider, + ); + logger.info("[Compaction] Stage 2 (deduplicate)", { + requestId, + ran: dedupResult.deduplicated, + tokensBefore: stageTokensBefore, + tokensAfter: stageTokensAfter, + saved: stageTokensBefore - stageTokensAfter, + }); + } - // Stage 4: Sliding Window Truncation (fallback) - if ( - this.config.enableTruncate && - estimateMessagesTokens(currentMessages, provider) > targetTokens - ) { - const stageTokensBefore = estimateMessagesTokens( - currentMessages, - provider, - ); - const truncResult = truncateWithSlidingWindow(currentMessages, { - fraction: this.config.truncationFraction, - currentTokens: stageTokensBefore, - targetTokens: targetTokens, - provider: provider, - adaptiveBuffer: 0.15, - maxIterations: 6, - }); - if (truncResult.truncated) { - currentMessages = truncResult.messages; - stagesUsed.push("truncate"); - } - const stageTokensAfter = estimateMessagesTokens( - currentMessages, - provider, - ); - logger.info("[Compaction] Stage 4 (truncate)", { - requestId, - ran: truncResult.truncated, - tokensBefore: stageTokensBefore, - tokensAfter: stageTokensAfter, - saved: stageTokensBefore - stageTokensAfter, - }); - } + // Stage 3: LLM Summarization + if ( + this.config.enableSummarize && + estimateMessagesTokens(currentMessages, provider) > targetTokens + ) { + const stageTokensBefore = estimateMessagesTokens( + currentMessages, + provider, + ); + try { + const summarizeResult = await withTimeout( + summarizeMessages(currentMessages, { + provider: this.config.summarizationProvider, + model: this.config.summarizationModel, + keepRecentRatio: this.config.keepRecentRatio, + memoryConfig, + targetTokens, + }), + 120_000, + "LLM summarization timed out after 120s", + ); + if (summarizeResult.summarized) { + currentMessages = summarizeResult.messages; + stagesUsed.push("summarize"); + } + const stageTokensAfter = estimateMessagesTokens( + currentMessages, + provider, + ); + logger.info("[Compaction] Stage 3 (summarize)", { + requestId, + ran: summarizeResult.summarized, + tokensBefore: stageTokensBefore, + tokensAfter: stageTokensAfter, + saved: stageTokensBefore - stageTokensAfter, + }); + } catch (error) { + const err = + error instanceof Error ? error : new Error(String(error)); + + logger.warn("[Compaction] Stage 3 (summarize) FAILED", { + requestId, + error: err.message, + errorName: err.name, + tokensBefore: stageTokensBefore, + tokensAfter: stageTokensBefore, + saved: 0, + }); + + // Record failure on the compaction span for trace visibility + span = SpanSerializer.updateAttributes(span, { + "compaction.stage3.error": err.message, + "compaction.stage3.errorName": err.name, + "compaction.stage3.tokensBefore": stageTokensBefore, + "compaction.stage3_failed": true, + }); + + // Fall through to Stage 4 truncation as before + } + } - const tokensAfter = estimateMessagesTokens(currentMessages, provider); + // Stage 4: Sliding Window Truncation (fallback) + if ( + this.config.enableTruncate && + estimateMessagesTokens(currentMessages, provider) > targetTokens + ) { + const stageTokensBefore = estimateMessagesTokens( + currentMessages, + provider, + ); + const truncResult = truncateWithSlidingWindow(currentMessages, { + fraction: this.config.truncationFraction, + currentTokens: stageTokensBefore, + targetTokens: targetTokens, + provider: provider, + adaptiveBuffer: 0.15, + maxIterations: 6, + }); + if (truncResult.truncated) { + currentMessages = truncResult.messages; + stagesUsed.push("truncate"); + } + const stageTokensAfter = estimateMessagesTokens( + currentMessages, + provider, + ); + logger.info("[Compaction] Stage 4 (truncate)", { + requestId, + ran: truncResult.truncated, + tokensBefore: stageTokensBefore, + tokensAfter: stageTokensAfter, + saved: stageTokensBefore - stageTokensAfter, + }); + } - logger.info("[Compaction] Complete", { - requestId, - tokensBefore, - tokensAfter, - totalSaved: tokensBefore - tokensAfter, - stagesUsed, - durationMs: Date.now() - spanStartTime, - }); + const tokensAfter = estimateMessagesTokens(currentMessages, provider); - const result: CompactionResult = { - compacted: stagesUsed.length > 0, - stagesUsed, - tokensBefore, - tokensAfter, - tokensSaved: tokensBefore - tokensAfter, - messages: currentMessages, - }; + logger.info("[Compaction] Complete", { + requestId, + tokensBefore, + tokensAfter, + totalSaved: tokensBefore - tokensAfter, + stagesUsed, + durationMs: Date.now() - spanStartTime, + }); - span.durationMs = Date.now() - spanStartTime; - const endedSpan = SpanSerializer.endSpan( - SpanSerializer.updateAttributes(span, { - "context.stage": stagesUsed.join(",") || "none", - "context.tokensBefore": tokensBefore, - "context.tokensAfter": tokensAfter, - "context.tokensSaved": tokensBefore - tokensAfter, - }), - SpanStatus.OK, - ); - getMetricsAggregator().recordSpan(endedSpan); + const result: CompactionResult = { + compacted: stagesUsed.length > 0, + stagesUsed, + tokensBefore, + tokensAfter, + tokensSaved: tokensBefore - tokensAfter, + messages: currentMessages, + }; - return result; - } catch (error) { - span.durationMs = Date.now() - spanStartTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); - endedSpan.statusMessage = - error instanceof Error ? error.message : String(error); - getMetricsAggregator().recordSpan(endedSpan); - throw error; - } + span.durationMs = Date.now() - spanStartTime; + const compactionSucceeded = tokensAfter <= targetTokens; + const finalStatus = compactionSucceeded + ? SpanStatus.OK + : SpanStatus.WARNING; + const finalMessage = compactionSucceeded + ? undefined + : `Compaction insufficient: ${tokensAfter} tokens remain (target: ${targetTokens})`; + const endedSpan = SpanSerializer.endSpan( + SpanSerializer.updateAttributes(span, { + "context.stage": stagesUsed.join(",") || "none", + "context.tokensBefore": tokensBefore, + "context.tokensAfter": tokensAfter, + "context.tokensSaved": tokensBefore - tokensAfter, + }), + finalStatus, + finalMessage, + ); + getMetricsAggregator().recordSpan(endedSpan); + + return result; + } catch (error) { + span.durationMs = Date.now() - spanStartTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); + endedSpan.statusMessage = + error instanceof Error ? error.message : String(error); + getMetricsAggregator().recordSpan(endedSpan); + throw error; + } + }, + ); // end withSpan } } diff --git a/src/lib/context/fileSummarizationService.ts b/src/lib/context/fileSummarizationService.ts index 1dc2ea482..e6d90d30e 100644 --- a/src/lib/context/fileSummarizationService.ts +++ b/src/lib/context/fileSummarizationService.ts @@ -18,24 +18,13 @@ import { planFileSummarization, } from "./fileSummarizer.js"; import type { - FileSummarizationCheckParams, FileForSummarization, + FileSummarizationCheckParams, + FileSummarizationServiceOptions, RawFileInput, SummarizedFile, } from "../types/index.js"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -/** Constructor options. */ -type FileSummarizationServiceOptions = { - /** Provider used for the summarization LLM call (default: "vertex") */ - provider?: string; - /** Model used for the summarization LLM call (default: "gemini-2.5-flash") */ - model?: string; -}; - // --------------------------------------------------------------------------- // MIME → human label mapping // --------------------------------------------------------------------------- diff --git a/src/lib/context/summarizationEngine.ts b/src/lib/context/summarizationEngine.ts index 315d4fe7a..58538c977 100644 --- a/src/lib/context/summarizationEngine.ts +++ b/src/lib/context/summarizationEngine.ts @@ -16,6 +16,8 @@ import { generateSummary, } from "../utils/conversationMemory.js"; import { RECENT_MESSAGES_RATIO } from "../config/conversationMemory.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; import { logger } from "../utils/logger.js"; /** @@ -38,32 +40,46 @@ export class SummarizationEngine { logPrefix = "[SummarizationEngine]", requestId?: string, ): Promise { - const contextMessages = buildContextFromPointer(session, requestId); - const tokenCount = this.estimateTokens(contextMessages); - - session.lastTokenCount = tokenCount; - session.lastCountedAt = Date.now(); - - logger.info("[Summarization] Check", { - requestId, - sessionId: session.sessionId, - tokenCount, - threshold, - willSummarize: tokenCount >= threshold, - }); - - if (tokenCount >= threshold) { - await this.summarizeSession( - session, - threshold, - config, - logPrefix, - requestId, - ); - return true; - } + return withSpan( + { + name: "neurolink.memory.summarize", + tracer: tracers.memory, + attributes: { + "memory.session_id": session.sessionId ?? "unknown", + "memory.threshold": threshold, + }, + }, + async (span) => { + const contextMessages = buildContextFromPointer(session, requestId); + const tokenCount = this.estimateTokens(contextMessages); + + session.lastTokenCount = tokenCount; + session.lastCountedAt = Date.now(); + + logger.info("[Summarization] Check", { + requestId, + sessionId: session.sessionId, + tokenCount, + threshold, + willSummarize: tokenCount >= threshold, + }); - return false; + if (tokenCount >= threshold) { + await this.summarizeSession( + session, + threshold, + config, + logPrefix, + requestId, + ); + span.setAttribute("memory.summarized", true); + return true; + } + + span.setAttribute("memory.summarized", false); + return false; + }, + ); // end withSpan } /** diff --git a/src/lib/core/baseProvider.ts b/src/lib/core/baseProvider.ts index d1c0cccd6..64e8ccc78 100644 --- a/src/lib/core/baseProvider.ts +++ b/src/lib/core/baseProvider.ts @@ -13,8 +13,6 @@ import { IMAGE_GENERATION_MODELS } from "../core/constants.js"; import type { EvaluationData } from "../index.js"; import { MiddlewareFactory } from "../middleware/factory.js"; import type { NeuroLink } from "../neurolink.js"; -import { SpanStatus, SpanType } from "../types/index.js"; -import { SpanSerializer } from "../observability/utils/spanSerializer.js"; import { ATTR, tracers } from "../telemetry/index.js"; import type { JsonValue, @@ -33,7 +31,6 @@ import type { } from "../types/index.js"; import { isAbortError } from "../utils/errorHandling.js"; import { logger } from "../utils/logger.js"; -import { calculateCost } from "../utils/pricing.js"; import { composeAbortSignals, createTimeoutController, @@ -130,6 +127,7 @@ export abstract class BaseProvider implements AIProvider { options, timestamp, ), + () => this.neurolink?.getEventEmitter(), ); this.utilities = new Utilities( this.providerName, @@ -174,178 +172,132 @@ export abstract class BaseProvider implements AIProvider { ): Promise { let options = this.normalizeStreamOptions(optionsOrPrompt); - // Observability: create metrics span for provider.stream - const metricsSpan = SpanSerializer.createSpan( - SpanType.MODEL_GENERATION, - "provider.stream", - { - "ai.provider": this.providerName || "unknown", - "ai.model": this.modelName || options.model || "unknown", - "ai.temperature": options.temperature, - "ai.max_tokens": options.maxTokens, - }, - this._traceContext?.parentSpanId, - this._traceContext?.traceId, - ); - let metricsSpanRecorded = false; + logger.info(`Starting stream`, { + provider: this.providerName, + hasTools: !options.disableTools && this.supportsTools(), + disableTools: !!options.disableTools, + supportsTools: this.supportsTools(), + inputLength: options.input?.text?.length || 0, + maxTokens: options.maxTokens, + temperature: options.temperature, + timestamp: Date.now(), + }); - // OTEL span for provider-level stream tracing - const otelStreamSpan = tracers.provider.startSpan( - "neurolink.provider.stream", - { - kind: SpanKind.CLIENT, - attributes: { - [ATTR.GEN_AI_SYSTEM]: this.providerName || "unknown", - [ATTR.GEN_AI_MODEL]: this.modelName || options.model || "unknown", - [ATTR.GEN_AI_OPERATION]: "stream", - [ATTR.NL_PROVIDER]: this.providerName || "unknown", - }, - }, + // ===== EARLY MULTIMODAL DETECTION ===== + const hasFileInput = + !!options.input?.files?.length || !!options.input?.videoFiles?.length; + if (hasFileInput) { + // ===== VIDEO ANALYSIS DETECTION ===== + // Check if video frames are present and handle with fake streaming + const messages = await this.buildMessagesForStream(options); + if (hasVideoFrames(messages)) { + logger.info( + `Video frames detected in stream, using fake streaming for video analysis`, + { + provider: this.providerName, + model: this.modelName, + }, + ); + return await this.executeFakeStreaming(options, analysisSchema); + } + } + + // CRITICAL: Image generation models don't support real streaming + // Force fake streaming for image models to ensure image output is yielded. + // Skip this path when the caller explicitly requests non-image output (e.g. + // JSON analysis) so dual-mode models like gemini-3.1-flash-image-preview + // can still perform text/structured generation. + const isImageModel = IMAGE_GENERATION_MODELS.some((m) => + this.modelName.includes(m), ); + const requestsNonImageOutput = + options.output?.format === "json" || + options.output?.format === "structured" || + options.output?.format === "text"; + if (isImageModel && !requestsNonImageOutput) { + logger.info(`Image model detected, forcing fake streaming`, { + provider: this.providerName, + model: this.modelName, + reason: + "Image generation requires fake streaming to yield image output", + }); + + // Skip real streaming, go directly to fake streaming + return await this.executeFakeStreaming(options, analysisSchema); + } + + // Central tool merge: Pre-merge base tools (MCP/built-in) with user-provided + // tools (e.g. RAG tools) into options.tools. This way, every provider's + // executeStream() can simply use options.tools (or getAllTools() + options.tools) + // and get the complete tool set without needing per-provider merge logic. + if (!options.disableTools && this.supportsTools()) { + const mergedTools = await this.getToolsForStream(options); + options = { ...options, tools: mergedTools }; + } else { + options = { ...options, tools: {} }; + } + + // CRITICAL FIX: Always prefer real streaming over fake streaming + // Try real streaming first, use fake streaming only as fallback try { - logger.info(`Starting stream`, { + logger.debug(`Attempting real streaming`, { provider: this.providerName, - hasTools: !options.disableTools && this.supportsTools(), - disableTools: !!options.disableTools, - supportsTools: this.supportsTools(), - inputLength: options.input?.text?.length || 0, - maxTokens: options.maxTokens, - temperature: options.temperature, timestamp: Date.now(), }); - // ===== EARLY MULTIMODAL DETECTION ===== - const hasFileInput = - !!options.input?.files?.length || !!options.input?.videoFiles?.length; - if (hasFileInput) { - // ===== VIDEO ANALYSIS DETECTION ===== - // Check if video frames are present and handle with fake streaming - const messages = await this.buildMessagesForStream(options); - if (hasVideoFrames(messages)) { - logger.info( - `Video frames detected in stream, using fake streaming for video analysis`, - { - provider: this.providerName, - model: this.modelName, - }, - ); - return await this.executeFakeStreaming(options, analysisSchema); - } - } - - // CRITICAL: Image generation models don't support real streaming - // Force fake streaming for image models to ensure image output is yielded. - // Skip this path when the caller explicitly requests non-image output (e.g. - // JSON analysis) so dual-mode models like gemini-3.1-flash-image-preview - // can still perform text/structured generation. - const isImageModel = IMAGE_GENERATION_MODELS.some((m) => - this.modelName.includes(m), + const realStreamResult = await this.executeStream( + options, + analysisSchema, ); - const requestsNonImageOutput = - options.output?.format === "json" || - options.output?.format === "structured" || - options.output?.format === "text"; - if (isImageModel && !requestsNonImageOutput) { - logger.info(`Image model detected, forcing fake streaming`, { - provider: this.providerName, - model: this.modelName, - reason: - "Image generation requires fake streaming to yield image output", - }); - - // Skip real streaming, go directly to fake streaming - return await this.executeFakeStreaming(options, analysisSchema); - } + logger.info(`Real streaming succeeded`, { + provider: this.providerName, + timestamp: Date.now(), + }); - // Central tool merge: Pre-merge base tools (MCP/built-in) with user-provided - // tools (e.g. RAG tools) into options.tools. This way, every provider's - // executeStream() can simply use options.tools (or getAllTools() + options.tools) - // and get the complete tool set without needing per-provider merge logic. - if (!options.disableTools && this.supportsTools()) { - const mergedTools = await this.getToolsForStream(options); - options = { ...options, tools: mergedTools }; - } else { - options = { ...options, tools: {} }; + // If real streaming succeeds, return it (with tools support via Vercel AI SDK) + return realStreamResult; + } catch (realStreamError) { + // Don't retry on terminal/abort errors — only fall back for + // "real streaming with tools is unsupported" style failures. + const errMsg = + realStreamError instanceof Error + ? realStreamError.message + : String(realStreamError); + const errName = + realStreamError instanceof Error ? realStreamError.name : ""; + if ( + errName === "AbortError" || + errMsg.includes("abort") || + errMsg.includes("timeout") || + errMsg.includes("401") || + errMsg.includes("403") || + errMsg.includes("quota") || + errMsg.includes("rate limit") || + errMsg.includes("authentication") + ) { + throw this.handleProviderError(realStreamError); } - // CRITICAL FIX: Always prefer real streaming over fake streaming - // Try real streaming first, use fake streaming only as fallback - try { - logger.debug(`Attempting real streaming`, { - provider: this.providerName, - timestamp: Date.now(), - }); - - const realStreamResult = await this.executeStream( - options, - analysisSchema, - ); - - logger.info(`Real streaming succeeded`, { - provider: this.providerName, + logger.warn( + `Real streaming failed for ${this.providerName}, falling back to fake streaming:`, + { + error: errMsg, timestamp: Date.now(), - }); - - // If real streaming succeeds, return it (with tools support via Vercel AI SDK) - return realStreamResult; - } catch (realStreamError) { - logger.warn( - `Real streaming failed for ${this.providerName}, falling back to fake streaming:`, - { - error: - realStreamError instanceof Error - ? realStreamError.message - : String(realStreamError), - timestamp: Date.now(), - }, - ); - - // Fallback to fake streaming only if real streaming fails AND tools are enabled - if (!options.disableTools && this.supportsTools()) { - return await this.executeFakeStreaming(options, analysisSchema); - } else { - // If real streaming failed and no tools are enabled, re-throw the original error - logger.error( - `Real streaming failed for ${this.providerName}:`, - realStreamError, - ); - throw this.handleProviderError(realStreamError); - } - } - } catch (error) { - // Observability: record failed stream span - metricsSpanRecorded = true; - const _endedStreamSpan = SpanSerializer.endSpan( - metricsSpan, - SpanStatus.ERROR, - error instanceof Error ? error.message : String(error), + }, ); - // Note: Do NOT record to getMetricsAggregator() here — neurolink.ts - // stream:complete listener handles authoritative metrics to avoid double-counting. - - otelStreamSpan.setStatus({ - code: SpanStatusCode.ERROR, - message: error instanceof Error ? error.message : String(error), - }); - otelStreamSpan.end(); - throw error; - } finally { - // Observability: record successful stream span (only if not already ended via error path) - if (!metricsSpanRecorded) { - const _endedStreamSpan = SpanSerializer.endSpan( - metricsSpan, - SpanStatus.OK, + // Fallback to fake streaming only if real streaming fails AND tools are enabled + if (!options.disableTools && this.supportsTools()) { + return await this.executeFakeStreaming(options, analysisSchema); + } else { + // If real streaming failed and no tools are enabled, re-throw the original error + logger.error( + `Real streaming failed for ${this.providerName}:`, + realStreamError, ); - // Note: Do NOT record to getMetricsAggregator() here — neurolink.ts - // stream:complete listener handles authoritative metrics to avoid double-counting. - } - // End OTEL span on success (only if not already ended via error path) - if (otelStreamSpan.isRecording()) { - otelStreamSpan.setStatus({ code: SpanStatusCode.OK }); - otelStreamSpan.end(); + throw this.handleProviderError(realStreamError); } } } @@ -740,20 +692,6 @@ export abstract class BaseProvider implements AIProvider { this.validateOptions(options); const startTime = Date.now(); - // Observability: create metrics span for provider.generate - const metricsSpan = SpanSerializer.createSpan( - SpanType.MODEL_GENERATION, - "provider.generate", - { - "ai.provider": this.providerName || "unknown", - "ai.model": this.modelName || options.model || "unknown", - "ai.temperature": options.temperature, - "ai.max_tokens": options.maxTokens, - }, - this._traceContext?.parentSpanId, - this._traceContext?.traceId, - ); - // OTEL span for provider-level generate tracing // Use startActiveSpan pattern via context.with() so child spans become descendants const otelSpan = tracers.provider.startSpan("neurolink.provider.generate", { @@ -773,7 +711,6 @@ export abstract class BaseProvider implements AIProvider { this.runGenerateInActiveContext( options, startTime, - metricsSpan, otelSpan, otelSpanState, ), @@ -792,7 +729,6 @@ export abstract class BaseProvider implements AIProvider { private async runGenerateInActiveContext( options: TextGenerationOptions, startTime: number, - metricsSpan: ReturnType, otelSpan: ReturnType, otelSpanState: { ended: boolean }, ): Promise { @@ -840,17 +776,11 @@ export abstract class BaseProvider implements AIProvider { return await this.executeStandardGenerateFlow( options, startTime, - metricsSpan, model, messages, tools, ); } catch (error) { - SpanSerializer.endSpan( - metricsSpan, - SpanStatus.ERROR, - error instanceof Error ? error.message : String(error), - ); otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), @@ -999,7 +929,6 @@ export abstract class BaseProvider implements AIProvider { private async executeStandardGenerateFlow( options: TextGenerationOptions, startTime: number, - metricsSpan: ReturnType, model: LanguageModel, messages: ModelMessage[], tools: Record, @@ -1050,29 +979,6 @@ export abstract class BaseProvider implements AIProvider { options, ); - let enrichedGenerateSpan = { ...metricsSpan }; - if (enhancedResult?.usage) { - enrichedGenerateSpan = SpanSerializer.enrichWithTokenUsage( - enrichedGenerateSpan, - { - promptTokens: enhancedResult.usage.input || 0, - completionTokens: enhancedResult.usage.output || 0, - totalTokens: enhancedResult.usage.total || 0, - }, - ); - const cost = calculateCost(this.providerName, this.modelName, { - input: enhancedResult.usage.input || 0, - output: enhancedResult.usage.output || 0, - total: enhancedResult.usage.total || 0, - }); - if (cost && cost > 0) { - enrichedGenerateSpan = SpanSerializer.enrichWithCost( - enrichedGenerateSpan, - { totalCost: cost }, - ); - } - } - SpanSerializer.endSpan(enrichedGenerateSpan, SpanStatus.OK); return this.enhanceResult(enhancedResult, options, startTime); } @@ -1482,7 +1388,38 @@ export abstract class BaseProvider implements AIProvider { ? error : new DOMException("The operation was aborted", "AbortError"); } - return this.formatProviderError(error); + const formatted = this.formatProviderError(error); + + // P3 fix: Classify error and set error.type on the active OTel span + try { + const activeSpan = trace.getSpan(context.active()); + if (activeSpan) { + let errorType = "provider_error"; + const errName = formatted?.constructor?.name ?? ""; + if (errName === "RateLimitError") { + errorType = "rate_limit"; + } else if (errName === "AuthenticationError") { + errorType = "auth_failure"; + } else if (errName === "NetworkError") { + errorType = "network"; + } else if (errName === "InvalidModelError") { + errorType = "invalid_model"; + } else if (errName === "TimeoutError") { + errorType = "timeout"; + } + activeSpan.setAttribute("error.type", errorType); + if (formatted instanceof Error) { + activeSpan.setAttribute( + "error.message", + formatted.message.substring(0, 500), + ); + } + } + } catch { + // Non-blocking — telemetry failures shouldn't mask the original error + } + + return formatted; } /** diff --git a/src/lib/core/infrastructure/baseRegistry.ts b/src/lib/core/infrastructure/baseRegistry.ts index f22e07f30..39070c48b 100644 --- a/src/lib/core/infrastructure/baseRegistry.ts +++ b/src/lib/core/infrastructure/baseRegistry.ts @@ -1,17 +1,8 @@ import { logger } from "../../utils/logger.js"; import type { InfraRegistryEntry } from "../../types/index.js"; -/** - * Local alias: the canonical type was renamed to InfraRegistryEntry to avoid - * collision with other RegistryEntry types in the codebase. - */ -type RegistryEntry = InfraRegistryEntry< - TItem, - TMetadata ->; - export abstract class BaseRegistry { - protected items = new Map>(); + protected items = new Map>(); protected initialized = false; protected initPromise: Promise | null = null; diff --git a/src/lib/core/modules/GenerationHandler.ts b/src/lib/core/modules/GenerationHandler.ts index ab2e656ff..92a169ca1 100644 --- a/src/lib/core/modules/GenerationHandler.ts +++ b/src/lib/core/modules/GenerationHandler.ts @@ -30,10 +30,13 @@ import type { AISDKGenerateResult, EnhancedGenerateResult, ExtendedTool, + NeuroLinkEvents, StandardRecord, TextGenerationOptions, + TypedEventEmitter, } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; +import { emitToolEndFromStepFinish } from "../../utils/toolEndEmitter.js"; import { calculateCost } from "../../utils/pricing.js"; import { withProviderRetry } from "../../utils/providerRetry.js"; import { @@ -86,6 +89,9 @@ export class GenerationHandler { options: TextGenerationOptions, timestamp: Date, ) => Promise, + private readonly getEmitterFn?: () => + | TypedEventEmitter + | undefined, ) {} /** @@ -213,6 +219,20 @@ export class GenerationHandler { onStepFinish: ({ toolCalls, toolResults }) => { logger.info("Tool execution completed", { toolResults, toolCalls }); + // Emit tool:end events for Pipeline B (metrics aggregator). + // This surfaces AI-SDK-driven tool completions as telemetry events + // so that tool spans are created even when the SDK runs tools + // internally (gaps G5 / S2). + emitToolEndFromStepFinish( + this.getEmitterFn?.(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); + // Handle tool execution storage this.handleToolStorageFn( toolCalls, diff --git a/src/lib/core/modules/StreamHandler.ts b/src/lib/core/modules/StreamHandler.ts index d094c3c27..791cfbb17 100644 --- a/src/lib/core/modules/StreamHandler.ts +++ b/src/lib/core/modules/StreamHandler.ts @@ -156,6 +156,15 @@ export class StreamHandler { logger.warn( `${providerName}: Stream produced no output (NoOutputGeneratedError), returning empty stream`, ); + // S4 fix: yield a sentinel chunk so Pipeline B can detect the empty stream + // and set the span to WARNING status instead of OK + yield { + content: "", + metadata: { + noOutput: true, + errorType: "NoOutputGeneratedError", + }, + }; } else { throw error; } diff --git a/src/lib/core/modules/ToolsManager.ts b/src/lib/core/modules/ToolsManager.ts index 8c230326e..3c08bf92d 100644 --- a/src/lib/core/modules/ToolsManager.ts +++ b/src/lib/core/modules/ToolsManager.ts @@ -610,7 +610,14 @@ export class ToolsManager { "tool.result.status", errorResult ? "error" : "success", ); - customToolSpan.setStatus({ code: SpanStatusCode.OK }); + if (errorResult) { + customToolSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: `Tool ${toolName} returned isError: true`, + }); + } else { + customToolSpan.setStatus({ code: SpanStatusCode.OK }); + } return convertedResult; } @@ -668,7 +675,14 @@ export class ToolsManager { "tool.result.status", errorResult ? "error" : "success", ); - customToolSpan.setStatus({ code: SpanStatusCode.OK }); + if (errorResult) { + customToolSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: `Tool ${toolName} returned isError: true`, + }); + } else { + customToolSpan.setStatus({ code: SpanStatusCode.OK }); + } return convertedResult; } catch (error) { diff --git a/src/lib/evaluation/BatchEvaluator.ts b/src/lib/evaluation/BatchEvaluator.ts index 28ffcfba0..5bb5937da 100644 --- a/src/lib/evaluation/BatchEvaluator.ts +++ b/src/lib/evaluation/BatchEvaluator.ts @@ -3,12 +3,13 @@ * Enables parallel evaluation with configurable concurrency and error handling. */ -import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; import type { - GenerateResult, - EvaluationConfig, EvaluationData, AutoEvaluationConfig, + BatchEvaluationConfig, + BatchEvaluationItem, + BatchEvaluationItemResult, + BatchEvaluationResult, } from "../types/index.js"; import { Evaluator } from "./index.js"; @@ -19,105 +20,6 @@ import { import { logger } from "../utils/logger.js"; import { NeuroLinkFeatureError } from "../core/infrastructure/index.js"; -/** - * Configuration for batch evaluation. - */ -type BatchEvaluationConfig = EvaluationConfig & { - /** Maximum number of concurrent evaluations (default: 5) */ - concurrency?: number; - /** Whether to continue on individual failures (default: true) */ - continueOnError?: boolean; - /** Maximum retries for retryable errors (default: 2) */ - maxRetries?: number; - /** Delay between retries in milliseconds (default: 1000) */ - retryDelay?: number; - /** Callback for progress updates */ - onProgress?: (progress: BatchProgress) => void; - /** Callback for individual evaluation completion */ - onItemComplete?: (result: BatchEvaluationItemResult) => void; -}; - -/** - * Progress information for batch evaluation. - */ -type BatchProgress = { - /** Total items to evaluate */ - total: number; - /** Items completed (success + failed) */ - completed: number; - /** Items that succeeded */ - succeeded: number; - /** Items that failed */ - failed: number; - /** Items still pending */ - pending: number; - /** Percentage complete */ - percentComplete: number; -}; - -/** - * Input item for batch evaluation. - */ -type BatchEvaluationItem = { - /** Unique identifier for this item */ - id: string; - /** The generation options */ - options: LanguageModelV3CallOptions; - /** The generation result to evaluate */ - result: GenerateResult; - /** Optional item-specific threshold override */ - threshold?: number; -}; - -/** - * Result for a single item in batch evaluation. - */ -type BatchEvaluationItemResult = { - /** The item ID */ - id: string; - /** Whether the evaluation succeeded */ - success: boolean; - /** The evaluation data (if successful) */ - data?: EvaluationData; - /** Error information (if failed) */ - error?: { - message: string; - code?: string; - retryable?: boolean; - }; - /** Time taken for this evaluation in milliseconds */ - duration: number; - /** Number of retry attempts (if any) */ - retryCount: number; -}; - -/** - * Result of a batch evaluation operation. - */ -type BatchEvaluationResult = { - /** All item results */ - results: BatchEvaluationItemResult[]; - /** Summary statistics */ - summary: { - /** Total items evaluated */ - total: number; - /** Number of successful evaluations */ - succeeded: number; - /** Number of failed evaluations */ - failed: number; - /** Average evaluation score (for successful items) */ - averageScore: number; - /** Average evaluation time in milliseconds */ - averageDuration: number; - /** Total time for batch evaluation */ - totalDuration: number; - /** Passing rate (percentage of items meeting threshold) */ - passingRate: number; - }; - /** Whether all evaluations succeeded */ - allSucceeded: boolean; -}; - function hasEvaluationData( result: BatchEvaluationItemResult, ): result is BatchEvaluationItemResult & { data: EvaluationData } { diff --git a/src/lib/evaluation/EvaluationAggregator.ts b/src/lib/evaluation/EvaluationAggregator.ts index 3397f02e4..b376babe2 100644 --- a/src/lib/evaluation/EvaluationAggregator.ts +++ b/src/lib/evaluation/EvaluationAggregator.ts @@ -3,131 +3,17 @@ * Provides statistical analysis, trend detection, and summary generation. */ -import type { EvaluationData } from "../types/index.js"; +import type { + AggregationResult, + AlertSummary, + DimensionAnalysis, + EvaluationData, + ScoreDistribution, + ScoreStatistics, + TrendAnalysis, +} from "../types/index.js"; import { evaluationErrors } from "./errors/EvaluationError.js"; -/** - * Statistical summary of evaluation scores. - */ -type ScoreStatistics = { - /** Minimum score */ - min: number; - /** Maximum score */ - max: number; - /** Mean (average) score */ - mean: number; - /** Median score */ - median: number; - /** Standard deviation */ - stdDev: number; - /** Variance */ - variance: number; - /** 25th percentile */ - p25: number; - /** 75th percentile */ - p75: number; - /** 90th percentile */ - p90: number; - /** 95th percentile */ - p95: number; -}; - -/** - * Score distribution across ranges. - */ -type ScoreDistribution = { - /** Items scoring 1-3 (poor) */ - poor: number; - /** Items scoring 4-5 (below average) */ - belowAverage: number; - /** Items scoring 6-7 (average) */ - average: number; - /** Items scoring 8-9 (good) */ - good: number; - /** Items scoring 10 (excellent) */ - excellent: number; -}; - -/** - * Trend analysis results. - */ -type TrendAnalysis = { - /** Direction of the trend */ - direction: "improving" | "declining" | "stable"; - /** Slope of the linear regression */ - slope: number; - /** R-squared value (fit quality) */ - rSquared: number; - /** Percentage change from first to last */ - percentChange: number; - /** Moving average of last N evaluations */ - movingAverage: number; -}; - -/** - * Dimension-specific analysis for RAGAS metrics. - */ -type DimensionAnalysis = { - /** Relevance score statistics */ - relevance: ScoreStatistics; - /** Accuracy score statistics */ - accuracy: ScoreStatistics; - /** Completeness score statistics */ - completeness: ScoreStatistics; - /** Overall score statistics */ - overall: ScoreStatistics; - /** Correlation matrix between dimensions */ - correlations: { - relevanceAccuracy: number; - relevanceCompleteness: number; - accuracyCompleteness: number; - }; -}; - -/** - * Quality alerts summary. - */ -type AlertSummary = { - /** Total number of alerts */ - total: number; - /** Number of high severity alerts */ - high: number; - /** Number of medium severity alerts */ - medium: number; - /** Number of items marked as off-topic */ - offTopic: number; - /** Alert rate as percentage */ - alertRate: number; -}; - -/** - * Comprehensive aggregation result. - */ -type AggregationResult = { - /** Number of evaluations aggregated */ - count: number; - /** Statistics for overall scores */ - statistics: ScoreStatistics; - /** Score distribution */ - distribution: ScoreDistribution; - /** Dimension-specific analysis */ - dimensions: DimensionAnalysis; - /** Sequence trend analysis based on insertion order (not time-based) */ - sequenceTrend?: TrendAnalysis; - /** Alert summary */ - alerts: AlertSummary; - /** Passing rate based on threshold */ - passingRate: number; - /** Average evaluation time */ - avgEvaluationTime: number; - /** Aggregation metadata */ - metadata: { - aggregatedAt: string; - threshold: number; - evaluationModels: string[]; - }; -}; - /** * EvaluationAggregator - Aggregates evaluation results and provides analytics. * Supports statistical analysis, trend detection, and quality monitoring. diff --git a/src/lib/evaluation/EvaluatorFactory.ts b/src/lib/evaluation/EvaluatorFactory.ts index e4f5fcc37..ef19163b2 100644 --- a/src/lib/evaluation/EvaluatorFactory.ts +++ b/src/lib/evaluation/EvaluatorFactory.ts @@ -4,22 +4,10 @@ */ import { BaseFactory } from "../core/infrastructure/index.js"; -import type { EvaluationConfig } from "../types/index.js"; +import type { EvaluationConfig, EvaluatorPreset } from "../types/index.js"; import { Evaluator } from "./index.js"; import { createConfigurationError } from "./errors/EvaluationError.js"; -/** - * Configuration presets for common evaluation scenarios. - */ -type EvaluatorPreset = { - /** Preset name for identification */ - name: string; - /** Description of the preset use case */ - description: string; - /** The underlying evaluation configuration (optional for built-in presets) */ - config?: EvaluationConfig; -}; - /** * Factory for creating Evaluator instances with various configurations. * Supports presets for common use cases and custom configurations. diff --git a/src/lib/evaluation/EvaluatorRegistry.ts b/src/lib/evaluation/EvaluatorRegistry.ts index f2d6ae2f6..46d835cfb 100644 --- a/src/lib/evaluation/EvaluatorRegistry.ts +++ b/src/lib/evaluation/EvaluatorRegistry.ts @@ -6,67 +6,14 @@ import { BaseRegistry } from "../core/infrastructure/index.js"; import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; import type { + EvaluationStrategyConfig, + EvaluationStrategyFunction, + EvaluationStrategyMetadata, GenerateResult, - EvaluationResult, - EnhancedEvaluationContext, } from "../types/index.js"; import { createStrategyNotFoundError } from "./errors/EvaluationError.js"; import { withTimeout, ErrorFactory } from "../utils/errorHandling.js"; -/** - * A function that performs evaluation and returns results. - */ -type EvaluationStrategyFunction = ( - options: LanguageModelV3CallOptions, - result: GenerateResult, - config?: EvaluationStrategyConfig, -) => Promise<{ - evaluationResult: EvaluationResult; - evalContext: EnhancedEvaluationContext; -}>; - -/** - * Configuration for evaluation strategies. - */ -type EvaluationStrategyConfig = { - /** The model to use for evaluation */ - evaluationModel?: string; - /** The provider to use for evaluation */ - provider?: string; - /** The passing threshold (1-10) */ - threshold?: number; - /** Custom prompt generator */ - promptGenerator?: (context: { - userQuery: string; - history: string; - tools: string; - retryInfo: string; - aiResponse: string; - }) => string; - /** Additional strategy-specific options */ - options?: Record; -}; - -/** - * Metadata for registered evaluation strategies. - */ -type EvaluationStrategyMetadata = { - /** Human-readable name for the strategy */ - name: string; - /** Description of what the strategy does */ - description: string; - /** Whether the strategy requires an external LLM */ - requiresLLM: boolean; - /** Default model for the strategy (if requiresLLM is true) */ - defaultModel?: string; - /** Default provider for the strategy (if requiresLLM is true) */ - defaultProvider?: string; - /** Version of the strategy */ - version: string; - /** Supported features */ - features: string[]; -}; - /** * Registry for evaluation strategies. * Allows dynamic registration and retrieval of evaluation strategies. diff --git a/src/lib/evaluation/errors/EvaluationError.ts b/src/lib/evaluation/errors/EvaluationError.ts index 227354f12..4f5b7563c 100644 --- a/src/lib/evaluation/errors/EvaluationError.ts +++ b/src/lib/evaluation/errors/EvaluationError.ts @@ -7,7 +7,11 @@ import { NeuroLinkFeatureError, createErrorFactory, } from "../../core/infrastructure/index.js"; -import type { EnhancedEvaluationContext } from "../../types/index.js"; +import type { + EnhancedEvaluationContext, + EvaluationErrorCode, + EvaluationErrorContext, +} from "../../types/index.js"; /** * Error codes for the Evaluation feature. @@ -40,12 +44,6 @@ export const EvaluationErrorCodes = { RATE_LIMIT_ERROR: "RATE_LIMIT_ERROR", } as const; -/** - * Type for evaluation error codes - */ -type EvaluationErrorCode = - (typeof EvaluationErrorCodes)[keyof typeof EvaluationErrorCodes]; - /** * Factory for creating typed evaluation errors. * Uses the createErrorFactory pattern from core infrastructure. @@ -55,31 +53,6 @@ export const evaluationErrors = createErrorFactory( EvaluationErrorCodes, ); -/** - * Extended evaluation context for error details. - * Provides rich debugging information when errors occur. - */ -type EvaluationErrorContext = { - /** Length of the user query (redacted for safety) */ - userQueryLength?: number; - /** Length of the AI response (redacted for safety) */ - aiResponseLength?: number; - /** The current attempt number */ - attemptNumber?: number; - /** Previous evaluation scores if any */ - previousScores?: number[]; - /** The evaluation strategy in use */ - strategy?: string; - /** The evaluation model being used */ - evaluationModel?: string; - /** The provider being used */ - provider?: string; - /** Length of the raw response (redacted for safety) */ - rawResponseLength?: number; - /** Any additional context */ - additionalContext?: Record; -}; - /** * Checks if an error is retryable based on its code. * Transient errors (timeout, rate limit, some provider errors) are retryable. diff --git a/src/lib/evaluation/hooks/langfuseAdapter.ts b/src/lib/evaluation/hooks/langfuseAdapter.ts index e80c73386..ac4061e72 100644 --- a/src/lib/evaluation/hooks/langfuseAdapter.ts +++ b/src/lib/evaluation/hooks/langfuseAdapter.ts @@ -3,48 +3,15 @@ * Integration with Langfuse for LLM observability */ -import type { ScoreResult, PipelineResult } from "../../types/index.js"; +import type { + LangfuseAdapterConfig, + LangfuseClient, + PipelineResult, + ScoreResult, +} from "../../types/index.js"; import { logger } from "../../utils/logger.js"; import { observabilityHooks } from "./observabilityHooks.js"; -/** - * Langfuse client interface (minimal for type safety) - */ -type LangfuseClient = { - score: (params: { - name: string; - value: number; - traceId?: string; - observationId?: string; - comment?: string; - metadata?: Record; - }) => Promise; - trace?: (params: { - name: string; - metadata?: Record; - tags?: string[]; - }) => { id: string }; - shutdown?: () => Promise; -}; - -/** - * Langfuse adapter configuration - */ -type LangfuseAdapterConfig = { - /** Langfuse client instance */ - client: LangfuseClient; - /** Prefix for score names */ - scorePrefix?: string; - /** Include detailed metadata */ - includeMetadata?: boolean; - /** Tags to add to all scores */ - tags?: string[]; - /** Whether to send pipeline-level scores */ - sendPipelineScores?: boolean; - /** Whether to send individual scorer scores */ - sendScorerScores?: boolean; -}; - /** * Langfuse adapter for evaluation observability */ diff --git a/src/lib/evaluation/hooks/observabilityHooks.ts b/src/lib/evaluation/hooks/observabilityHooks.ts index 24b8a384f..5a55f915e 100644 --- a/src/lib/evaluation/hooks/observabilityHooks.ts +++ b/src/lib/evaluation/hooks/observabilityHooks.ts @@ -4,62 +4,14 @@ */ import type { + EvaluationEvents, + EvaluationSpanAttributes, EvaluationTraceContext, - ScoreResult, + EventHandler, PipelineResult, + ScoreResult, } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; -/** - * Event handler type - */ -type EventHandler = (event: T) => void | Promise; - -/** - * Evaluation events - */ -type EvaluationEvents = { - "scorer:start": { - scorerId: string; - scorerName: string; - timestamp: number; - traceContext?: EvaluationTraceContext; - }; - "scorer:end": { - scorerId: string; - scorerName: string; - result: ScoreResult; - timestamp: number; - duration: number; - traceContext?: EvaluationTraceContext; - }; - "scorer:error": { - scorerId: string; - scorerName: string; - error: string; - timestamp: number; - traceContext?: EvaluationTraceContext; - }; - "pipeline:start": { - pipelineName: string; - scorerCount: number; - timestamp: number; - correlationId: string; - traceContext?: EvaluationTraceContext; - }; - "pipeline:end": { - pipelineName: string; - result: PipelineResult; - timestamp: number; - duration: number; - traceContext?: EvaluationTraceContext; - }; - "pipeline:error": { - pipelineName: string; - error: string; - timestamp: number; - traceContext?: EvaluationTraceContext; - }; -}; /** * Observability hooks manager @@ -258,12 +210,12 @@ export function createMetricsCollectorHook(collector: { /** * OpenTelemetry span attributes */ -type SpanAttributes = Record; - /** * Create span attributes from scorer result */ -export function scorerToSpanAttributes(result: ScoreResult): SpanAttributes { +export function scorerToSpanAttributes( + result: ScoreResult, +): EvaluationSpanAttributes { return { "scorer.id": result.scorerId, "scorer.name": result.scorerName, @@ -284,7 +236,7 @@ export function scorerToSpanAttributes(result: ScoreResult): SpanAttributes { */ export function pipelineToSpanAttributes( result: PipelineResult, -): SpanAttributes { +): EvaluationSpanAttributes { return { "pipeline.name": result.pipelineConfig.name ?? "unnamed", "pipeline.overallScore": result.overallScore, diff --git a/src/lib/evaluation/pipeline/strategies/batchStrategy.ts b/src/lib/evaluation/pipeline/strategies/batchStrategy.ts index 26ed54201..3a82f4001 100644 --- a/src/lib/evaluation/pipeline/strategies/batchStrategy.ts +++ b/src/lib/evaluation/pipeline/strategies/batchStrategy.ts @@ -7,66 +7,12 @@ import type { ScorerInput, PipelineExecutionOptions, PipelineResult, + BatchEvaluationConfig, + BatchEvaluationResult, + BatchItemResult, } from "../../../types/index.js"; import type { EvaluationPipeline } from "../evaluationPipeline.js"; -/** - * Batch processing configuration - */ -type BatchConfig = { - /** Maximum concurrent evaluations */ - concurrency?: number; - /** Delay between batches (ms) */ - batchDelay?: number; - /** Continue on individual failures */ - continueOnError?: boolean; - /** Progress callback */ - onProgress?: (progress: BatchProgress) => void; - /** Individual result callback */ - onResult?: (result: BatchItemResult) => void; -}; - -/** - * Batch progress information - */ -type BatchProgress = { - total: number; - completed: number; - failed: number; - remaining: number; - percentComplete: number; - estimatedTimeRemaining?: number; -}; - -/** - * Individual batch item result - */ -type BatchItemResult = { - index: number; - input: ScorerInput; - result?: PipelineResult; - error?: string; - duration: number; -}; - -/** - * Batch evaluation result - */ -type BatchResult = { - /** All individual results */ - results: BatchItemResult[]; - /** Summary statistics */ - summary: { - total: number; - successful: number; - failed: number; - averageScore: number; - passRate: number; - totalDuration: number; - averageDuration: number; - }; -}; - function hasPipelineResult( result: BatchItemResult, ): result is BatchItemResult & { result: PipelineResult } { @@ -76,7 +22,12 @@ function hasPipelineResult( /** * Default batch configuration */ -const DEFAULT_BATCH_CONFIG: Required = { +const DEFAULT_BATCH_CONFIG: Required< + Pick< + BatchEvaluationConfig, + "concurrency" | "batchDelay" | "continueOnError" | "onProgress" | "onResult" + > +> = { concurrency: 5, batchDelay: 0, continueOnError: true, @@ -89,9 +40,18 @@ const DEFAULT_BATCH_CONFIG: Required = { */ export class BatchStrategy { private _pipeline: EvaluationPipeline; - private _config: Required; - - constructor(pipeline: EvaluationPipeline, config?: BatchConfig) { + private _config: Required< + Pick< + BatchEvaluationConfig, + | "concurrency" + | "batchDelay" + | "continueOnError" + | "onProgress" + | "onResult" + > + >; + + constructor(pipeline: EvaluationPipeline, config?: BatchEvaluationConfig) { this._pipeline = pipeline; this._config = { ...DEFAULT_BATCH_CONFIG, ...config }; } @@ -102,7 +62,7 @@ export class BatchStrategy { async evaluate( inputs: ScorerInput[], options?: PipelineExecutionOptions, - ): Promise { + ): Promise { const startTime = Date.now(); const results: BatchItemResult[] = []; const durations: number[] = []; @@ -140,7 +100,7 @@ export class BatchStrategy { total: inputs.length, completed: results.length, failed: results.filter((r) => r.error).length, - remaining: inputs.length - results.length, + pending: inputs.length - results.length, percentComplete: (results.length / inputs.length) * 100, estimatedTimeRemaining: this._estimateRemainingTime( durations, @@ -167,13 +127,13 @@ export class BatchStrategy { results, summary: { total: inputs.length, - successful: successfulResults.length, + succeeded: successfulResults.length, failed: results.length - successfulResults.length, averageScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0, - passRate: + passingRate: successfulResults.length > 0 ? passed.length / successfulResults.length : 0, @@ -247,7 +207,7 @@ export class BatchStrategy { /** * Update configuration */ - configure(config: Partial): void { + configure(config: Partial): void { this._config = { ...this._config, ...config }; } } @@ -257,7 +217,7 @@ export class BatchStrategy { */ export function createBatchStrategy( pipeline: EvaluationPipeline, - config?: BatchConfig, + config?: BatchEvaluationConfig, ): BatchStrategy { return new BatchStrategy(pipeline, config); } @@ -268,8 +228,8 @@ export function createBatchStrategy( export async function evaluateBatch( pipeline: EvaluationPipeline, inputs: ScorerInput[], - config?: BatchConfig, -): Promise { + config?: BatchEvaluationConfig, +): Promise { const strategy = new BatchStrategy(pipeline, config); return strategy.evaluate(inputs); } @@ -280,8 +240,8 @@ export async function evaluateBatch( export async function* streamBatchEvaluation( pipeline: EvaluationPipeline, inputs: ScorerInput[], - config?: Omit, -): AsyncGenerator { + config?: Omit, +): AsyncGenerator { const results: BatchItemResult[] = []; const durations: number[] = []; const startTime = Date.now(); @@ -328,13 +288,13 @@ export async function* streamBatchEvaluation( const earlyPassed = successfulResults.filter((r) => r.result.passed); return { total: inputs.length, - successful: successfulResults.length, + succeeded: successfulResults.length, failed: results.length - successfulResults.length, averageScore: earlyScores.length > 0 ? earlyScores.reduce((a, b) => a + b, 0) / earlyScores.length : 0, - passRate: + passingRate: successfulResults.length > 0 ? earlyPassed.length / successfulResults.length : 0, @@ -366,11 +326,11 @@ export async function* streamBatchEvaluation( return { total: inputs.length, - successful: successfulResults.length, + succeeded: successfulResults.length, failed: results.length - successfulResults.length, averageScore: scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : 0, - passRate: + passingRate: successfulResults.length > 0 ? passed.length / successfulResults.length : 0, diff --git a/src/lib/evaluation/ragasEvaluator.ts b/src/lib/evaluation/ragasEvaluator.ts index 2d5a8de82..441f4a509 100644 --- a/src/lib/evaluation/ragasEvaluator.ts +++ b/src/lib/evaluation/ragasEvaluator.ts @@ -12,6 +12,8 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; /** * Implements a RAGAS-style evaluator that uses a "judge" LLM to score the @@ -53,61 +55,94 @@ export class RAGASEvaluator { public async evaluate( context: EnhancedEvaluationContext, ): Promise { - const span = SpanSerializer.createSpan( - SpanType.EVALUATION, - "evaluation.ragas", + return withSpan( { - "evaluation.dimension": "relevance|accuracy|completeness", - "ai.provider": this.providerName, - "ai.model": this.evaluationModel, + name: "neurolink.evaluation.ragas", + tracer: tracers.sdk, + attributes: { + "evaluation.provider": this.providerName, + "evaluation.model": this.evaluationModel, + }, }, - ); - const startTime = Date.now(); - try { - const prompt = this.promptBuilder.buildEvaluationPrompt( - context, - this.promptGenerator, - ); + async (otelSpan) => { + const span = SpanSerializer.createSpan( + SpanType.EVALUATION, + "evaluation.ragas", + { + "evaluation.dimension": "relevance|accuracy|completeness", + "ai.provider": this.providerName, + "ai.model": this.evaluationModel, + }, + ); + const startTime = Date.now(); + try { + const prompt = this.promptBuilder.buildEvaluationPrompt( + context, + this.promptGenerator, + ); - const provider = await AIProviderFactory.createProvider( - this.providerName, - this.evaluationModel, - ); + const provider = await AIProviderFactory.createProvider( + this.providerName, + this.evaluationModel, + ); - const result = await provider.generate({ - input: { text: prompt }, - }); + const result = await provider.generate({ + input: { text: prompt }, + }); - if (!result) { - throw new Error("Evaluation generation failed to return a result."); - } + if (!result) { + throw new Error("Evaluation generation failed to return a result."); + } - const rawEvaluationResponse = result.content; - const parsedResult = this.parseEvaluationResponse(rawEvaluationResponse); - const evaluationTime = Date.now() - startTime; + const rawEvaluationResponse = result.content; + const parsedResult = this.parseEvaluationResponse( + rawEvaluationResponse, + ); + const evaluationTime = Date.now() - startTime; - const finalResult: EvaluationResult = { - ...parsedResult, - isPassing: parsedResult.finalScore >= this.threshold, // This will be recalculated, but is needed for the type - evaluationModel: this.evaluationModel, - evaluationTime, - attemptNumber: context.attemptNumber, - rawEvaluationResponse, - }; + const finalResult: EvaluationResult = { + ...parsedResult, + isPassing: parsedResult.finalScore >= this.threshold, // This will be recalculated, but is needed for the type + evaluationModel: this.evaluationModel, + evaluationTime, + attemptNumber: context.attemptNumber, + rawEvaluationResponse, + }; - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); - getMetricsAggregator().recordSpan(endedSpan); + // Write evaluation scores to OTel span for Langfuse visibility + otelSpan.setAttribute( + "evaluation.relevance_score", + finalResult.relevanceScore, + ); + otelSpan.setAttribute( + "evaluation.accuracy_score", + finalResult.accuracyScore, + ); + otelSpan.setAttribute( + "evaluation.completeness_score", + finalResult.completenessScore, + ); + otelSpan.setAttribute( + "evaluation.final_score", + finalResult.finalScore, + ); + otelSpan.setAttribute("evaluation.is_passing", finalResult.isPassing); - return finalResult; - } catch (error) { - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); - endedSpan.statusMessage = - error instanceof Error ? error.message : String(error); - getMetricsAggregator().recordSpan(endedSpan); - throw error; - } + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); + getMetricsAggregator().recordSpan(endedSpan); + + return finalResult; + } catch (error) { + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); + endedSpan.statusMessage = + error instanceof Error ? error.message : String(error); + getMetricsAggregator().recordSpan(endedSpan); + throw error; + } + }, + ); // end withSpan } /** diff --git a/src/lib/evaluation/reporting/metricsCollector.ts b/src/lib/evaluation/reporting/metricsCollector.ts index 51c7c2e6c..b904016e1 100644 --- a/src/lib/evaluation/reporting/metricsCollector.ts +++ b/src/lib/evaluation/reporting/metricsCollector.ts @@ -3,67 +3,13 @@ * Collect and aggregate evaluation metrics */ -import type { ScoreResult, PipelineResult } from "../../types/index.js"; -/** - * Scorer metrics - */ -type ScorerMetrics = { - scorerId: string; - scorerName: string; - totalExecutions: number; - successfulExecutions: number; - failedExecutions: number; - passedCount: number; - failedCount: number; - totalScore: number; - minScore: number; - maxScore: number; - totalDuration: number; - averageDuration: number; - averageScore: number; - passRate: number; - lastExecutionTime: number; -}; - -/** - * Pipeline metrics - */ -type PipelineMetrics = { - pipelineName: string; - totalExecutions: number; - passedCount: number; - failedCount: number; - totalScore: number; - minScore: number; - maxScore: number; - totalDuration: number; - averageDuration: number; - averageScore: number; - passRate: number; - lastExecutionTime: number; - scorerMetrics: Map; -}; - -/** - * Aggregated metrics - */ -type AggregatedMetrics = { - totalEvaluations: number; - overallPassRate: number; - averageScore: number; - averageDuration: number; - scoreDistribution: { - excellent: number; // 9-10 - good: number; // 7-8.9 - fair: number; // 5-6.9 - poor: number; // 3-4.9 - failing: number; // 0-2.9 - }; - pipelineMetrics: Map; - scorerMetrics: Map; - collectionStartTime: number; - lastUpdateTime: number; -}; +import type { + AggregatedMetrics, + PipelineMetrics, + PipelineResult, + ScoreResult, + ScorerMetrics, +} from "../../types/index.js"; /** * Metrics collector for evaluation data diff --git a/src/lib/evaluation/reporting/reportGenerator.ts b/src/lib/evaluation/reporting/reportGenerator.ts index 757f20280..43dd163b5 100644 --- a/src/lib/evaluation/reporting/reportGenerator.ts +++ b/src/lib/evaluation/reporting/reportGenerator.ts @@ -4,27 +4,12 @@ */ import type { + GeneratedReport, JsonObject, PipelineResult, - ReportData, ReportConfig, - ReportFormat, + ReportData, } from "../../types/index.js"; -/** - * Generated report - */ -type GeneratedReport = { - /** Report format */ - format: ReportFormat; - /** Report content */ - content: string; - /** Metadata */ - metadata: { - generatedAt: number; - format: ReportFormat; - config: ReportConfig; - }; -}; /** * Default report configuration diff --git a/src/lib/evaluation/scorers/llm/biasDetectionScorer.ts b/src/lib/evaluation/scorers/llm/biasDetectionScorer.ts index 65cc0e1a6..d0bc368ca 100644 --- a/src/lib/evaluation/scorers/llm/biasDetectionScorer.ts +++ b/src/lib/evaluation/scorers/llm/biasDetectionScorer.ts @@ -4,6 +4,7 @@ */ import type { + BiasInstance, LLMScorerConfig, ScoreResult, ScorerInput, @@ -115,12 +116,6 @@ export class BiasDetectionScorer extends BaseLLMScorer { }; } - type BiasInstance = { - type?: string; - text?: string; - explanation?: string; - severity?: string; - }; const biasInstances: BiasInstance[] = Array.isArray(json.biasInstances) ? (json.biasInstances as BiasInstance[]) : []; diff --git a/src/lib/evaluation/scorers/llm/contextRelevancyScorer.ts b/src/lib/evaluation/scorers/llm/contextRelevancyScorer.ts index 57b64efa3..fb4b94c1e 100644 --- a/src/lib/evaluation/scorers/llm/contextRelevancyScorer.ts +++ b/src/lib/evaluation/scorers/llm/contextRelevancyScorer.ts @@ -4,6 +4,7 @@ */ import type { + ContextScoreItem, LLMScorerConfig, ScoreResult, ScorerInput, @@ -100,12 +101,6 @@ export class ContextRelevancyScorer extends BaseLLMScorer { }; } - type ContextScoreItem = { - index?: number; - score?: number; - reasoning?: string; - keyInfo?: string[]; - }; const contextScores: ContextScoreItem[] = Array.isArray(json.contextScores) ? (json.contextScores as ContextScoreItem[]) : []; diff --git a/src/lib/evaluation/scorers/llm/faithfulnessScorer.ts b/src/lib/evaluation/scorers/llm/faithfulnessScorer.ts index 46a1c7bea..5fa053da7 100644 --- a/src/lib/evaluation/scorers/llm/faithfulnessScorer.ts +++ b/src/lib/evaluation/scorers/llm/faithfulnessScorer.ts @@ -4,6 +4,7 @@ */ import type { + ClaimItem, LLMScorerConfig, ScoreResult, ScorerInput, @@ -112,7 +113,6 @@ export class FaithfulnessScorer extends BaseLLMScorer { }; } - type ClaimItem = { claim?: string; supported?: boolean; evidence?: string }; const claims: ClaimItem[] = Array.isArray(json.claims) ? (json.claims as ClaimItem[]) : []; diff --git a/src/lib/evaluation/scorers/llm/hallucinationScorer.ts b/src/lib/evaluation/scorers/llm/hallucinationScorer.ts index 6a47e7fab..7fb8b9ce9 100644 --- a/src/lib/evaluation/scorers/llm/hallucinationScorer.ts +++ b/src/lib/evaluation/scorers/llm/hallucinationScorer.ts @@ -4,6 +4,7 @@ */ import type { + HallucinationItem, LLMScorerConfig, ScoreResult, ScorerInput, @@ -134,11 +135,6 @@ export class HallucinationScorer extends BaseLLMScorer { }; } - type HallucinationItem = { - text?: string; - reason?: string; - severity?: string; - }; const hallucinations: HallucinationItem[] = Array.isArray( json.hallucinations, ) diff --git a/src/lib/evaluation/scorers/llm/toneConsistencyScorer.ts b/src/lib/evaluation/scorers/llm/toneConsistencyScorer.ts index f3324aaa7..d2f151174 100644 --- a/src/lib/evaluation/scorers/llm/toneConsistencyScorer.ts +++ b/src/lib/evaluation/scorers/llm/toneConsistencyScorer.ts @@ -7,6 +7,7 @@ import type { LLMScorerConfig, ScoreResult, ScorerInput, + ToneShift, } from "../../../types/index.js"; import { BaseLLMScorer } from "./baseLLMScorer.js"; @@ -97,12 +98,6 @@ export class ToneConsistencyScorer extends BaseLLMScorer { }; } - type ToneShift = { - location?: string; - from?: string; - to?: string; - severity?: string; - }; const toneShifts: ToneShift[] = Array.isArray(json.toneShifts) ? (json.toneShifts as ToneShift[]) : []; diff --git a/src/lib/evaluation/scorers/llm/toxicityScorer.ts b/src/lib/evaluation/scorers/llm/toxicityScorer.ts index 7d25faa82..2fefc4439 100644 --- a/src/lib/evaluation/scorers/llm/toxicityScorer.ts +++ b/src/lib/evaluation/scorers/llm/toxicityScorer.ts @@ -4,6 +4,7 @@ */ import type { + FlaggedItem, LLMScorerConfig, ScoreResult, ScorerInput, @@ -116,7 +117,6 @@ export class ToxicityScorer extends BaseLLMScorer { }; } - type FlaggedItem = { text?: string; category?: string; severity?: string }; const flaggedContent: FlaggedItem[] = Array.isArray(json.flaggedContent) ? (json.flaggedContent as FlaggedItem[]) : []; diff --git a/src/lib/evaluation/scorers/rule/contentSimilarityScorer.ts b/src/lib/evaluation/scorers/rule/contentSimilarityScorer.ts index 488f5cdcf..e51d8d7d7 100644 --- a/src/lib/evaluation/scorers/rule/contentSimilarityScorer.ts +++ b/src/lib/evaluation/scorers/rule/contentSimilarityScorer.ts @@ -4,59 +4,16 @@ */ import type { - RuleScorerConfig, + ContentSimilarityConfig, ScoreResult, ScorerInput, ScorerMetadata, + SimilarityDetails, + SimilarityMetric, } from "../../../types/index.js"; import { BaseScorer } from "../baseScorer.js"; import { DEFAULT_RULE_SCORER_CONFIG } from "./baseRuleScorer.js"; -/** - * Similarity metric types - */ -type SimilarityMetric = - | "jaccard" - | "cosine" - | "levenshtein" - | "dice" - | "overlap"; - -/** - * Configuration specific to content similarity scoring - */ -type ContentSimilarityConfig = RuleScorerConfig & { - /** Similarity metric to use */ - metric?: SimilarityMetric; - /** Multiple metrics to combine */ - metrics?: SimilarityMetric[]; - /** How to combine multiple metrics */ - metricCombination?: "average" | "min" | "max" | "weighted"; - /** Weights for each metric (if weighted combination) */ - metricWeights?: Record; - /** Whether to normalize text before comparison */ - normalizeText?: boolean; - /** Whether to use word-level or character-level comparison */ - tokenLevel?: "word" | "character" | "ngram"; - /** N-gram size if using ngram tokenization */ - ngramSize?: number; - /** Compare against ground truth, context, or custom reference */ - compareWith?: "groundTruth" | "context" | "custom"; - /** Custom reference text if compareWith is "custom" */ - referenceText?: string; -}; - -/** - * Similarity calculation result - */ -type SimilarityDetails = { - metric: SimilarityMetric; - score: number; - responseTokens: number; - referenceTokens: number; - commonTokens?: number; -}; - /** * Scorer metadata for content similarity */ diff --git a/src/lib/evaluation/scorers/rule/formatScorer.ts b/src/lib/evaluation/scorers/rule/formatScorer.ts index a88232f65..6139a227b 100644 --- a/src/lib/evaluation/scorers/rule/formatScorer.ts +++ b/src/lib/evaluation/scorers/rule/formatScorer.ts @@ -4,7 +4,9 @@ */ import type { - RuleScorerConfig, + FormatScorerConfig, + FormatType, + FormatValidationResult, ScoreResult, ScorerInput, ScorerMetadata, @@ -16,83 +18,6 @@ import { DEFAULT_RULE_SCORER_CONFIG, } from "./baseRuleScorer.js"; -/** - * Expected format types - */ -type FormatType = - | "json" - | "markdown" - | "code" - | "list" - | "numbered-list" - | "bullet-list" - | "table" - | "yaml" - | "xml" - | "plain" - | "html" - | "custom"; - -/** - * Code language types for code format validation - */ -type CodeLanguage = - | "javascript" - | "typescript" - | "python" - | "java" - | "c" - | "cpp" - | "csharp" - | "go" - | "rust" - | "sql" - | "bash" - | "any"; - -/** - * Configuration specific to format scoring - */ -type FormatScorerConfig = RuleScorerConfig & { - /** Expected format type */ - expectedFormat?: FormatType; - /** Multiple allowed formats */ - allowedFormats?: FormatType[]; - /** For code format: expected language */ - codeLanguage?: CodeLanguage; - /** For JSON format: validate against schema */ - jsonSchema?: object; - /** For markdown: required elements */ - markdownRequirements?: { - hasHeadings?: boolean; - hasCodeBlocks?: boolean; - hasLinks?: boolean; - hasLists?: boolean; - minHeadingLevel?: number; - maxHeadingLevel?: number; - }; - /** For list format: requirements */ - listRequirements?: { - minItems?: number; - maxItems?: number; - itemPattern?: string; - }; - /** Custom format regex pattern */ - customPattern?: string; - /** Whether format must be exclusive (no other content) */ - strictFormat?: boolean; -}; - -/** - * Format validation result - */ -type FormatValidationResult = { - isValid: boolean; - detectedFormat: FormatType | null; - issues: string[]; - structureAnalysis?: object; -}; - /** * Scorer metadata for format */ diff --git a/src/lib/evaluation/scorers/rule/keywordCoverageScorer.ts b/src/lib/evaluation/scorers/rule/keywordCoverageScorer.ts index 68a605bfd..083fbbfd3 100644 --- a/src/lib/evaluation/scorers/rule/keywordCoverageScorer.ts +++ b/src/lib/evaluation/scorers/rule/keywordCoverageScorer.ts @@ -4,7 +4,8 @@ */ import type { - RuleScorerConfig, + KeywordCoverageConfig, + KeywordCoverageDetails, ScoreResult, ScorerInput, ScorerMetadata, @@ -12,35 +13,6 @@ import type { } from "../../../types/index.js"; import { BaseRuleScorer } from "./baseRuleScorer.js"; -/** - * Configuration specific to keyword coverage scoring - */ -type KeywordCoverageConfig = RuleScorerConfig & { - /** Keywords to check for */ - keywords?: string[]; - /** Minimum coverage ratio (0-1) to pass */ - minCoverage?: number; - /** Whether to use case-insensitive matching */ - caseInsensitive?: boolean; - /** Whether to use word boundary matching */ - wordBoundary?: boolean; - /** Synonyms map for flexible matching */ - synonyms?: Record; - /** Weight different keywords differently */ - keywordWeights?: Record; -}; - -/** - * Keyword coverage result details - */ -type KeywordCoverageDetails = { - totalKeywords: number; - foundKeywords: string[]; - missingKeywords: string[]; - coverageRatio: number; - weightedCoverage: number; -}; - /** * Scorer metadata for keyword coverage */ diff --git a/src/lib/evaluation/scorers/rule/lengthScorer.ts b/src/lib/evaluation/scorers/rule/lengthScorer.ts index e63a9deba..711e8d0b4 100644 --- a/src/lib/evaluation/scorers/rule/lengthScorer.ts +++ b/src/lib/evaluation/scorers/rule/lengthScorer.ts @@ -4,7 +4,9 @@ */ import type { - RuleScorerConfig, + LengthMeasurement, + LengthScorerConfig, + LengthUnit, ScoreResult, ScorerInput, ScorerMetadata, @@ -15,56 +17,6 @@ import { DEFAULT_RULE_SCORER_CONFIG, } from "./baseRuleScorer.js"; -/** - * Length measurement unit - */ -type LengthUnit = - | "words" - | "characters" - | "sentences" - | "paragraphs" - | "tokens"; - -/** - * Length constraint type - */ -type LengthConstraintType = "exact" | "range" | "minimum" | "maximum" | "ratio"; - -/** - * Configuration specific to length scoring - */ -type LengthScorerConfig = RuleScorerConfig & { - /** Unit of measurement */ - unit?: LengthUnit; - /** Constraint type */ - constraintType?: LengthConstraintType; - /** Minimum length (for range/minimum constraints) */ - minLength?: number; - /** Maximum length (for range/maximum constraints) */ - maxLength?: number; - /** Exact length (for exact constraint) */ - exactLength?: number; - /** Tolerance for exact length (as percentage) */ - tolerance?: number; - /** Compare ratio with query/context length */ - ratioTarget?: number; - /** Ratio reference: compare against query or context */ - ratioReference?: "query" | "context"; - /** Scoring mode: binary (pass/fail) or proportional */ - scoringMode?: "binary" | "proportional"; -}; - -/** - * Length measurement result - */ -type LengthMeasurement = { - words: number; - characters: number; - sentences: number; - paragraphs: number; - estimatedTokens: number; -}; - /** * Scorer metadata for length */ diff --git a/src/lib/evaluation/scorers/scorerRegistry.ts b/src/lib/evaluation/scorers/scorerRegistry.ts index da709ef48..dee52032e 100644 --- a/src/lib/evaluation/scorers/scorerRegistry.ts +++ b/src/lib/evaluation/scorers/scorerRegistry.ts @@ -4,6 +4,7 @@ */ import type { + BuiltInScorerDefinition, Scorer, ScorerCategory, ScorerConfig, @@ -14,12 +15,6 @@ import type { } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; -type BuiltInScorerDefinition = { - metadata: ScorerMetadata; - factory: ScorerFactory; - aliases?: string[]; -}; - const BUILT_IN_LLM_SCORERS: BuiltInScorerDefinition[] = [ { metadata: { diff --git a/src/lib/factories/providerFactory.ts b/src/lib/factories/providerFactory.ts index b51d2dddc..6d82ad6f4 100644 --- a/src/lib/factories/providerFactory.ts +++ b/src/lib/factories/providerFactory.ts @@ -1,8 +1,10 @@ import type { AIProviderName } from "../constants/enums.js"; import type { - UnknownRecord, AIProvider, NeurolinkCredentials, + ProviderConstructor, + ProviderRegistration, + UnknownRecord, } from "../types/index.js"; import { logger } from "../utils/logger.js"; @@ -10,34 +12,6 @@ import { logger } from "../utils/logger.js"; // Pure factory pattern with no hardcoded imports // All providers loaded dynamically via registry to avoid circular dependencies -/** - * Provider constructor interface - supports both sync constructors and async factory functions - */ -type ProviderConstructor = - | { - new ( - modelName?: string, - providerName?: string, - sdk?: UnknownRecord, - region?: string, - ): AIProvider; - } - | (( - modelName?: string, - providerName?: string, - sdk?: UnknownRecord, - region?: string, - ) => Promise); - -/** - * Provider registration entry - */ -type ProviderRegistration = { - constructor: ProviderConstructor; - defaultModel?: string; // Optional - provider can read from env - aliases?: string[]; -}; - /** * True Factory Pattern implementation for AI Providers * Uses registration-based approach to eliminate switch statements diff --git a/src/lib/image-gen/ImageGenService.ts b/src/lib/image-gen/ImageGenService.ts index 69fe65657..a03b1bfa4 100644 --- a/src/lib/image-gen/ImageGenService.ts +++ b/src/lib/image-gen/ImageGenService.ts @@ -31,15 +31,13 @@ import type { ImageGenConfig, ImageGenOptions, ImageGenResult, + NeuroLinkInstance, } from "../types/index.js"; import { DEFAULT_IMAGE_GEN_CONFIG } from "../types/index.js"; /** * NeuroLink instance type (avoiding circular dependencies) */ -type NeuroLinkInstance = { - generate: (options: Record) => Promise; -}; /** * Image generation service for AI-powered image creation diff --git a/src/lib/mcp/batching/requestBatcher.ts b/src/lib/mcp/batching/requestBatcher.ts index 904878257..a45a39f66 100644 --- a/src/lib/mcp/batching/requestBatcher.ts +++ b/src/lib/mcp/batching/requestBatcher.ts @@ -11,25 +11,15 @@ import { EventEmitter } from "events"; import { logger } from "../../utils/logger.js"; import { ErrorFactory } from "../../utils/errorHandling.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import type { BatchConfig, BatchExecutor, BatchResult, + PendingRequest, } from "../../types/index.js"; -/** - * Pending request in the batch queue - */ -type PendingRequest = { - id: string; - tool: string; - args: unknown; - serverId?: string; - resolve: (value: T) => void; - reject: (error: Error) => void; - addedAt: number; -}; - /** * Request Batcher - Efficient batch processing for MCP tool calls * @@ -282,106 +272,139 @@ export class RequestBatcher extends EventEmitter { this.emit("batchStarted", { batchId, size: batchRequests.length }); - try { - // Guard against missing executor - if (!this.executor) { - throw ErrorFactory.missingConfiguration("batchExecutor", { - hint: "Call setExecutor() before executing batches", - }); - } - - // Execute the batch with a timeout to prevent indefinite hangs - const executorPromise = this.executor( - batchRequests.map((r) => ({ - tool: r.tool, - args: r.args, - serverId: r.serverId, - })), - ); - const timeoutMs = Math.max( - 5000, - Number(process.env.MCP_TOOL_TIMEOUT) || 60000, - ); - let timeoutHandle: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout( - () => reject(ErrorFactory.toolTimeout("batchExecution", timeoutMs)), - timeoutMs, - ); - }); - const results = await Promise.race([ - executorPromise, - timeoutPromise, - ]).finally(() => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - }); - - // Process results - const batchResults: BatchResult[] = []; - - for (let i = 0; i < batchRequests.length; i++) { - const request = batchRequests[i]; - const result = results[i]; - const executionTime = Date.now() - startTime; + await withSpan( + { + name: "neurolink.mcp.batch.execute", + tracer: tracers.mcp, + attributes: { + "mcp.batch.id": batchId, + "mcp.batch.size": batchRequests.length, + "mcp.batch.active_batches": this.activeBatches, + }, + }, + async (span) => { + let successCount = 0; + let errorCount = 0; + + try { + // Guard against missing executor + if (!this.executor) { + throw ErrorFactory.missingConfiguration("batchExecutor", { + hint: "Call setExecutor() before executing batches", + }); + } - if (!result) { - const noResultError = ErrorFactory.toolExecutionFailed( - request.tool, - new Error(`Batch executor returned no result for request ${i}`), + // Execute the batch with a timeout to prevent indefinite hangs + const executorPromise = this.executor( + batchRequests.map((r) => ({ + tool: r.tool, + args: r.args, + serverId: r.serverId, + })), + ); + const timeoutMs = Math.max( + 5000, + Number(process.env.MCP_TOOL_TIMEOUT) || 60000, ); - request.reject(noResultError); - batchResults.push({ - id: request.id, - success: false, - error: noResultError, - executionTime, + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout( + () => + reject(ErrorFactory.toolTimeout("batchExecution", timeoutMs)), + timeoutMs, + ); }); - continue; - } - - if (result.success) { - request.resolve(result.result as T); - batchResults.push({ - id: request.id, - success: true, - result: result.result, - executionTime, + // Suppress unhandled rejection if executorPromise rejects after timeout wins + void executorPromise.catch((_e: unknown) => { + // Intentionally swallowed — timeout already handled the failure }); - } else { - const error = - result.error ?? - ErrorFactory.toolExecutionFailed( - request.tool, - new Error("Unknown batch execution error"), - ); - request.reject(error); - batchResults.push({ - id: request.id, - success: false, - error, - executionTime, + const results = await Promise.race([ + executorPromise, + timeoutPromise, + ]).finally(() => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } }); - } - } - this.emit("batchCompleted", { batchId, results: batchResults }); - } catch (error) { - // Batch-level failure - reject all requests - const batchError = - error instanceof Error - ? error - : ErrorFactory.toolExecutionFailed("batch", new Error(String(error))); + // Process results + const batchResults: BatchResult[] = []; + + for (let i = 0; i < batchRequests.length; i++) { + const request = batchRequests[i]; + const result = results[i]; + const executionTime = Date.now() - startTime; + + if (!result) { + const noResultError = ErrorFactory.toolExecutionFailed( + request.tool, + new Error(`Batch executor returned no result for request ${i}`), + ); + request.reject(noResultError); + batchResults.push({ + id: request.id, + success: false, + error: noResultError, + executionTime, + }); + errorCount++; + continue; + } + + if (result.success) { + request.resolve(result.result as T); + batchResults.push({ + id: request.id, + success: true, + result: result.result, + executionTime, + }); + successCount++; + } else { + const error = + result.error ?? + ErrorFactory.toolExecutionFailed( + request.tool, + new Error("Unknown batch execution error"), + ); + request.reject(error); + batchResults.push({ + id: request.id, + success: false, + error, + executionTime, + }); + errorCount++; + } + } - for (const request of batchRequests) { - request.reject(batchError); - } + span.setAttribute("mcp.batch.success_count", successCount); + span.setAttribute("mcp.batch.error_count", errorCount); + + this.emit("batchCompleted", { batchId, results: batchResults }); + } catch (error) { + // Batch-level failure - reject all requests + const batchError = + error instanceof Error + ? error + : ErrorFactory.toolExecutionFailed( + "batch", + new Error(String(error)), + ); + + for (const request of batchRequests) { + request.reject(batchError); + } - this.emit("batchFailed", { batchId, error: batchError }); - } finally { - this.activeBatches--; - } + this.emit("batchFailed", { batchId, error: batchError }); + throw batchError; + } finally { + this.activeBatches--; + } + }, + ).catch((error) => { + logger.error("Batch span execution failed:", error); + }); // Schedule next batch if there are more pending requests if (this.pending.size > 0) { diff --git a/src/lib/mcp/caching/toolCache.ts b/src/lib/mcp/caching/toolCache.ts index 144a50590..df25d5102 100644 --- a/src/lib/mcp/caching/toolCache.ts +++ b/src/lib/mcp/caching/toolCache.ts @@ -11,19 +11,11 @@ import { createHash } from "crypto"; import { EventEmitter } from "events"; import { withTimeout } from "../../utils/async/withTimeout.js"; -import type { CacheStats, McpCacheConfig } from "../../types/index.js"; - -/** - * Cached entry with metadata - */ -type CacheEntry = { - value: T; - expires: number; - createdAt: number; - accessedAt: number; - accessCount: number; - key: string; -}; +import type { + CacheStats, + McpCacheConfig, + McpCacheEntry, +} from "../../types/index.js"; /** * Tool Cache - High-performance caching for MCP tool results @@ -47,7 +39,7 @@ type CacheEntry = { * ``` */ export class ToolCache extends EventEmitter { - private cache: Map> = new Map(); + private cache: Map> = new Map(); private config: Required; private stats: CacheStats; private cleanupTimer?: ReturnType; @@ -125,7 +117,7 @@ export class ToolCache extends EventEmitter { this.evictOne(); } - const entry: CacheEntry = { + const entry: McpCacheEntry = { value, expires: now + effectiveTtl, createdAt: now, @@ -322,7 +314,7 @@ export class ToolCache extends EventEmitter { return this.config.namespace ? `${this.config.namespace}:${key}` : key; } - private isExpired(entry: CacheEntry): boolean { + private isExpired(entry: McpCacheEntry): boolean { return Date.now() > entry.expires; } @@ -353,7 +345,7 @@ export class ToolCache extends EventEmitter { } } - private selectEvictionCandidate(): CacheEntry | undefined { + private selectEvictionCandidate(): McpCacheEntry | undefined { if (this.cache.size === 0) { return undefined; } @@ -370,8 +362,8 @@ export class ToolCache extends EventEmitter { } } - private findLRU(): CacheEntry | undefined { - let oldest: CacheEntry | undefined; + private findLRU(): McpCacheEntry | undefined { + let oldest: McpCacheEntry | undefined; let oldestTime = Infinity; for (const entry of this.cache.values()) { @@ -384,8 +376,8 @@ export class ToolCache extends EventEmitter { return oldest; } - private findFIFO(): CacheEntry | undefined { - let oldest: CacheEntry | undefined; + private findFIFO(): McpCacheEntry | undefined { + let oldest: McpCacheEntry | undefined; let oldestTime = Infinity; for (const entry of this.cache.values()) { @@ -398,8 +390,8 @@ export class ToolCache extends EventEmitter { return oldest; } - private findLFU(): CacheEntry | undefined { - let leastFrequent: CacheEntry | undefined; + private findLFU(): McpCacheEntry | undefined { + let leastFrequent: McpCacheEntry | undefined; let lowestCount = Infinity; for (const entry of this.cache.values()) { diff --git a/src/lib/mcp/httpRateLimiter.ts b/src/lib/mcp/httpRateLimiter.ts index 307e13278..91bfeb327 100644 --- a/src/lib/mcp/httpRateLimiter.ts +++ b/src/lib/mcp/httpRateLimiter.ts @@ -15,6 +15,7 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { getActiveTraceContext } from "../telemetry/traceContext.js"; /** * Default rate limit configuration * Provides sensible defaults for most MCP HTTP transport use cases @@ -95,6 +96,7 @@ export class HTTPRateLimiter { * @throws Error if the wait queue is too long */ async acquire(): Promise { + const { traceId, parentSpanId } = getActiveTraceContext(); const span = SpanSerializer.createSpan( SpanType.MCP_TRANSPORT, "mcp.rateLimit", @@ -104,6 +106,8 @@ export class HTTPRateLimiter { "mcp.rateLimit.tokensAvailable": this.tokens, "mcp.rateLimit.maxBurst": this.config.maxBurst, }, + parentSpanId, + traceId, ); const startTime = Date.now(); diff --git a/src/lib/mcp/httpRetryHandler.ts b/src/lib/mcp/httpRetryHandler.ts index 83e1cf8c4..e19b8fd02 100644 --- a/src/lib/mcp/httpRetryHandler.ts +++ b/src/lib/mcp/httpRetryHandler.ts @@ -15,6 +15,7 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { getActiveTraceContext } from "../telemetry/traceContext.js"; /** * Default HTTP retry configuration */ @@ -171,11 +172,18 @@ export async function withHTTPRetry( ...config, }; - const span = SpanSerializer.createSpan(SpanType.MCP_TRANSPORT, "mcp.retry", { - "mcp.transport": "http", - "mcp.operation": "retry", - "mcp.maxAttempts": mergedConfig.maxAttempts, - }); + const { traceId, parentSpanId } = getActiveTraceContext(); + const span = SpanSerializer.createSpan( + SpanType.MCP_TRANSPORT, + "mcp.retry", + { + "mcp.transport": "http", + "mcp.operation": "retry", + "mcp.maxAttempts": mergedConfig.maxAttempts, + }, + parentSpanId, + traceId, + ); const startTime = Date.now(); let lastError: unknown; diff --git a/src/lib/mcp/mcpClientFactory.ts b/src/lib/mcp/mcpClientFactory.ts index 77715b575..f68a80f3e 100644 --- a/src/lib/mcp/mcpClientFactory.ts +++ b/src/lib/mcp/mcpClientFactory.ts @@ -40,6 +40,7 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { getActiveTraceContext } from "../telemetry/traceContext.js"; /** * Default timeout for MCP client creation in milliseconds. * Configurable via MCP_CLIENT_TIMEOUT env var. @@ -78,6 +79,7 @@ export class MCPClientFactory { timeout = DEFAULT_CLIENT_TIMEOUT, ): Promise { const startTime = Date.now(); + const { traceId, parentSpanId } = getActiveTraceContext(); const obsSpan = SpanSerializer.createSpan( SpanType.MCP_TRANSPORT, "mcp.connect", @@ -86,6 +88,8 @@ export class MCPClientFactory { "mcp.operation": "connect", "mcp.server_id": config.id, }, + parentSpanId, + traceId, ); try { diff --git a/src/lib/mcp/multiServerManager.ts b/src/lib/mcp/multiServerManager.ts index 09fb43a59..ecaf5d73e 100644 --- a/src/lib/mcp/multiServerManager.ts +++ b/src/lib/mcp/multiServerManager.ts @@ -17,28 +17,17 @@ import { EventEmitter } from "events"; import type { - MCPServerInfo, JsonObject, LoadBalancingStrategy, + MCPServerInfo, MultiServerManagerConfig, ServerGroup, + ServerMetrics, UnifiedTool, } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { ErrorFactory } from "../utils/errorHandling.js"; -/** - * Server metrics for load balancing - */ -type ServerMetrics = { - activeRequests: number; - totalRequests: number; - completedRequests: number; - averageResponseTime: number; - errorRate: number; - lastHealthCheck?: Date; - isHealthy: boolean; -}; /** * Multi-Server Manager diff --git a/src/lib/mcp/servers/aiProviders/aiAnalysisTools.ts b/src/lib/mcp/servers/aiProviders/aiAnalysisTools.ts index 42d919bb8..084ddc32a 100644 --- a/src/lib/mcp/servers/aiProviders/aiAnalysisTools.ts +++ b/src/lib/mcp/servers/aiProviders/aiAnalysisTools.ts @@ -6,10 +6,13 @@ import { z } from "zod"; import type { - NeuroLinkMCPTool, + AIProvider, + AnalyzeUsageParams, + BenchmarkParams, NeuroLinkExecutionContext, + NeuroLinkMCPTool, + OptimizeParametersParams, ToolResult, - AIProvider, } from "../../../types/index.js"; import { AIProviderFactory } from "../../../core/factory.js"; import { @@ -19,9 +22,13 @@ import { import { logger } from "../../../utils/logger.js"; /** - * Input Schemas for AI Analysis Tools + * Input Schemas for AI Analysis Tools. + * + * Each schema is annotated with `z.ZodType` so any drift + * between the structural type (declared in src/lib/types/mcp.ts) and the + * runtime zod schema fails at compile time. */ -const AnalyzeUsageSchema = z.object({ +const AnalyzeUsageSchema: z.ZodType = z.object({ sessionId: z.string().optional(), timeRange: z.enum(["1h", "24h", "7d", "30d"]).default("24h"), provider: z @@ -41,11 +48,7 @@ const AnalyzeUsageSchema = z.object({ includeCostEstimation: z.boolean().default(true), }); -type AnalyzeUsageParams = z.infer; -type BenchmarkParams = z.infer; -type OptimizeParametersParams = z.infer; - -const BenchmarkSchema = z.object({ +const BenchmarkSchema: z.ZodType = z.object({ providers: z .array( z.enum([ @@ -69,7 +72,7 @@ const BenchmarkSchema = z.object({ maxTokens: z.number().positive().default(100), }); -const OptimizeParametersSchema = z.object({ +const OptimizeParametersSchema: z.ZodType = z.object({ prompt: z.string().min(1, "Prompt is required for optimization"), provider: z .enum([ diff --git a/src/lib/mcp/servers/aiProviders/aiWorkflowTools.ts b/src/lib/mcp/servers/aiProviders/aiWorkflowTools.ts index 7010c36d5..1e361e441 100644 --- a/src/lib/mcp/servers/aiProviders/aiWorkflowTools.ts +++ b/src/lib/mcp/servers/aiProviders/aiWorkflowTools.ts @@ -5,12 +5,15 @@ import { z } from "zod"; import type { - Unknown, - UnknownRecord, AIProvider, - NeuroLinkMCPTool, + DebugResult, + DocumentationResult, NeuroLinkExecutionContext, + NeuroLinkMCPTool, + RefactoringResult, ToolResult, + Unknown, + UnknownRecord, } from "../../../types/index.js"; import { AIProviderFactory } from "../../../core/factory.js"; import { getBestProvider } from "../../../utils/providerUtils.js"; @@ -115,43 +118,6 @@ const debugAIOutputSchema = z.object({ }); // Type definitions for tool results -type _TestCase = { - name: string; - type: string; - code: string; - description: string; - assertions: number; -}; - -type RefactoringResult = { - refactoredCode: string; - changes: string[]; - improvements: string[]; - metrics: { - linesReduced: number; - complexityReduction: number; - readabilityScore: number; - }; -}; - -type DocumentationResult = { - documentation: string; - sections: string[]; - examples: string[]; - coverage: number; -}; - -type DebugResult = { - issues: Array<{ - type: string; - severity: "low" | "medium" | "high"; - description: string; - location?: string; - }>; - suggestions: string[]; - possibleCauses: string[]; - fixedOutput?: string; -}; /** * Generate test cases for code functions diff --git a/src/lib/mcp/toolDiscoveryService.ts b/src/lib/mcp/toolDiscoveryService.ts index cbbc8f988..70ceaa711 100644 --- a/src/lib/mcp/toolDiscoveryService.ts +++ b/src/lib/mcp/toolDiscoveryService.ts @@ -31,6 +31,7 @@ import { import { withTimeout } from "../utils/errorHandling.js"; import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; import { tracers } from "../telemetry/tracers.js"; +import { withSpan } from "../telemetry/withSpan.js"; import type { McpOutputNormalizer } from "./mcpOutputNormalizer.js"; const mcpTracer = tracers.mcp; @@ -79,96 +80,115 @@ export class ToolDiscoveryService extends EventEmitter { client: Client, timeout = DEFAULT_TOOL_TIMEOUT, ): Promise { - const startTime = Date.now(); - - try { - // Prevent concurrent discovery for same server - if (this.discoveryInProgress.has(serverId)) { - return { - success: false, - error: `Discovery already in progress for server: ${serverId}`, - toolCount: 0, - tools: [], - duration: Date.now() - startTime, - serverId, - }; - } - - this.discoveryInProgress.add(serverId); + return withSpan( + { + name: "neurolink.mcp.discoverTools", + tracer: tracers.mcp, + attributes: { "mcp.server_id": serverId }, + }, + async (span) => { + const startTime = Date.now(); + + try { + // Prevent concurrent discovery for same server + if (this.discoveryInProgress.has(serverId)) { + return { + success: false, + error: `Discovery already in progress for server: ${serverId}`, + toolCount: 0, + tools: [], + duration: Date.now() - startTime, + serverId, + }; + } + + this.discoveryInProgress.add(serverId); + + mcpLogger.info( + `[ToolDiscoveryService] Starting tool discovery for server: ${serverId}`, + ); - mcpLogger.info( - `[ToolDiscoveryService] Starting tool discovery for server: ${serverId}`, - ); + // Create circuit breaker for tool discovery + const circuitBreaker = globalCircuitBreakerManager.getBreaker( + `tool-discovery-${serverId}`, + { + failureThreshold: 2, + resetTimeout: 60000, + operationTimeout: timeout, + }, + ); - // Create circuit breaker for tool discovery - const circuitBreaker = globalCircuitBreakerManager.getBreaker( - `tool-discovery-${serverId}`, - { - failureThreshold: 2, - resetTimeout: 60000, - operationTimeout: timeout, - }, - ); + // Discover tools with circuit breaker protection + const tools = await circuitBreaker.execute(async () => { + return await this.performToolDiscovery(serverId, client, timeout); + }); - // Discover tools with circuit breaker protection - const tools = await circuitBreaker.execute(async () => { - return await this.performToolDiscovery(serverId, client, timeout); - }); + // Register discovered tools + const registeredTools = await this.registerDiscoveredTools( + serverId, + tools, + ); - // Register discovered tools - const registeredTools = await this.registerDiscoveredTools( - serverId, - tools, - ); + span.setAttribute("mcp.tools_discovered", registeredTools.length); - const result: ToolDiscoveryResult = { - success: true, - toolCount: registeredTools.length, - tools: registeredTools, - duration: Date.now() - startTime, - serverId, - }; + const result: ToolDiscoveryResult = { + success: true, + toolCount: registeredTools.length, + tools: registeredTools, + duration: Date.now() - startTime, + serverId, + }; - // Emit discovery completed event - this.emit("discoveryCompleted", { - serverId, - toolCount: registeredTools.length, - duration: result.duration, - timestamp: new Date(), - } satisfies ToolRegistryEvents["discoveryCompleted"]); + // Emit discovery completed event + this.emit("discoveryCompleted", { + serverId, + toolCount: registeredTools.length, + duration: result.duration, + timestamp: new Date(), + } satisfies ToolRegistryEvents["discoveryCompleted"]); - mcpLogger.info( - `[ToolDiscoveryService] Discovery completed for ${serverId}: ${registeredTools.length} tools`, - ); + mcpLogger.info( + `[ToolDiscoveryService] Discovery completed for ${serverId}: ${registeredTools.length} tools`, + ); - return result; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); - mcpLogger.error( - `[ToolDiscoveryService] Discovery failed for ${serverId}:`, - error, - ); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage, + }); + span.recordException( + error instanceof Error ? error : new Error(errorMessage), + ); - // Emit discovery failed event - this.emit("discoveryFailed", { - serverId, - error: errorMessage, - timestamp: new Date(), - } satisfies ToolRegistryEvents["discoveryFailed"]); + mcpLogger.error( + `[ToolDiscoveryService] Discovery failed for ${serverId}:`, + error, + ); - return { - success: false, - error: errorMessage, - toolCount: 0, - tools: [], - duration: Date.now() - startTime, - serverId, - }; - } finally { - this.discoveryInProgress.delete(serverId); - } + // Emit discovery failed event + this.emit("discoveryFailed", { + serverId, + error: errorMessage, + timestamp: new Date(), + } satisfies ToolRegistryEvents["discoveryFailed"]); + + return { + success: false, + error: errorMessage, + toolCount: 0, + tools: [], + duration: Date.now() - startTime, + serverId, + }; + } finally { + this.discoveryInProgress.delete(serverId); + } + }, + ); } /** diff --git a/src/lib/mcp/toolRegistry.ts b/src/lib/mcp/toolRegistry.ts index 97888fbb3..2c3d09568 100644 --- a/src/lib/mcp/toolRegistry.ts +++ b/src/lib/mcp/toolRegistry.ts @@ -25,6 +25,7 @@ import { ErrorFactory } from "../utils/errorHandling.js"; import { HITLUserRejectedError, HITLTimeoutError } from "../hitl/hitlErrors.js"; import { withSpan, tracers, ATTR } from "../telemetry/index.js"; +import { SpanStatusCode } from "@opentelemetry/api"; import { getAuthContext } from "../auth/authContext.js"; export class MCPToolRegistry extends MCPRegistry { @@ -328,7 +329,7 @@ export class MCPToolRegistry extends MCPRegistry { return withSpan( { - name: "neurolink.tool.execute", + name: "neurolink.tool.registry.execute", tracer: tracers.mcp, attributes: { [ATTR.GEN_AI_TOOL_NAME]: toolName, @@ -550,6 +551,16 @@ export class MCPToolRegistry extends MCPRegistry { throw error; } + // Explicitly set ERROR status — we're returning (not throwing) so withSpan + // won't automatically detect this as an error (gap T3 fix) + span.setStatus({ + code: SpanStatusCode.ERROR, + message: errMsg, + }); + if (error instanceof Error) { + span.recordException(error); + } + // Return runtime execution errors in ToolResult format const errorResult = { success: false, diff --git a/src/lib/memory/memoryRetrievalTools.ts b/src/lib/memory/memoryRetrievalTools.ts index 1758683a3..995a6ecd8 100644 --- a/src/lib/memory/memoryRetrievalTools.ts +++ b/src/lib/memory/memoryRetrievalTools.ts @@ -8,6 +8,7 @@ */ import { tool } from "ai"; +import { SpanStatusCode } from "@opentelemetry/api"; import { z } from "zod"; import type { RedisConversationMemoryManager } from "../core/redisConversationMemoryManager.js"; import type { ArtifactStore } from "../types/index.js"; @@ -19,6 +20,8 @@ import { SpanStatus, getMetricsAggregator, } from "../observability/index.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; /** Maximum characters returned per retrieval request */ const DEFAULT_RETRIEVAL_LIMIT = 50_000; @@ -104,191 +107,261 @@ export function createMemoryRetrievalTools( "Returns matching lines with line numbers.", ), }), - execute: async (args) => { - // ── Artifact resolution path ──────────────────────────────────────── - // When the caller supplies an artifactId we short-circuit to the - // artifact store (bypassing Redis) and return the full payload with - // optional offset/limit pagination. - if (args.artifactId) { - if (!artifactStore) { - logger.warn( - "[MemoryRetrievalTools] retrieve_context called with artifactId " + - "but no ArtifactStore is configured", - ); - return { - error: - "Artifact store not configured — " + - "mcp.outputLimits.strategy must be set to 'externalize' to use artifactId retrieval", - artifactId: args.artifactId, - }; - } - const content = await withTimeout( - artifactStore.retrieve(args.artifactId), - 10_000, - new Error( - `ArtifactStore.retrieve() timed out for artifact "${args.artifactId}"`, + execute: async (args) => + withSpan( + { + name: "neurolink.memory.retrieve_context", + tracer: tracers.memory, + attributes: { + "memory.operation": args.artifactId + ? "artifact.fetch" + : "session.retrieve", + "memory.has_artifact_id": Boolean(args.artifactId), + "memory.has_session_id": Boolean(args.sessionId), + "memory.role": args.role ?? "any", + "memory.search": Boolean(args.search), + }, + }, + async (otelSpan) => + executeRetrieveContext( + args, + memoryManager, + artifactStore, + otelSpan, ), - ); - if (content === null) { - return { - error: "Artifact not found or has expired", - artifactId: args.artifactId, - }; - } - const charLimit = Math.min( - args.limit ?? DEFAULT_RETRIEVAL_LIMIT, - MAX_RETRIEVAL_LIMIT, - ); - const start = args.offset ?? 0; - const slice = content.slice(start, start + charLimit); - return { - artifactId: args.artifactId, - content: slice, - totalSize: content.length, - hasMore: start + charLimit < content.length, - offset: start, - limit: charLimit, - }; - } - // ── End artifact resolution ───────────────────────────────────────── - - if (!args.sessionId) { - return { - error: "sessionId is required when artifactId is not provided", - }; - } + ), + }), + }; +} - if (!memoryManager) { - return { - error: - "Session history retrieval requires Redis conversation memory — " + - "enable mcp.conversationMemory with a Redis backend, or use " + - "artifactId to retrieve an externalized MCP tool output.", - }; - } +async function executeRetrieveContext( + args: { + sessionId?: string; + artifactId?: string; + messageId?: string; + role?: "user" | "assistant" | "system" | "tool_call" | "tool_result"; + lastN?: number; + offset?: number; + limit?: number; + search?: string; + }, + memoryManager: RedisConversationMemoryManager | undefined, + artifactStore: ArtifactStore | undefined, + otelSpan: import("@opentelemetry/api").Span, +) { + // ── Artifact resolution path ──────────────────────────────────────── + // When the caller supplies an artifactId we short-circuit to the + // artifact store (bypassing Redis) and return the full payload with + // optional offset/limit pagination. + if (args.artifactId) { + if (!artifactStore) { + logger.warn( + "[MemoryRetrievalTools] retrieve_context called with artifactId " + + "but no ArtifactStore is configured", + ); + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: "Artifact store not configured", + }); + return { + error: + "Artifact store not configured — " + + "mcp.outputLimits.strategy must be set to 'externalize' to use artifactId retrieval", + artifactId: args.artifactId, + }; + } + const content = await withTimeout( + artifactStore.retrieve(args.artifactId), + 10_000, + new Error( + `ArtifactStore.retrieve() timed out for artifact "${args.artifactId}"`, + ), + ); + if (content === null) { + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: "Artifact not found or has expired", + }); + return { + error: "Artifact not found or has expired", + artifactId: args.artifactId, + }; + } + const charLimit = Math.min( + args.limit ?? DEFAULT_RETRIEVAL_LIMIT, + MAX_RETRIEVAL_LIMIT, + ); + const start = args.offset ?? 0; + const slice = content.slice(start, start + charLimit); + otelSpan.setAttribute("memory.artifact_size", content.length); + otelSpan.setAttribute("memory.returned_bytes", slice.length); + return { + artifactId: args.artifactId, + content: slice, + totalSize: content.length, + hasMore: start + charLimit < content.length, + offset: start, + limit: charLimit, + }; + } + // ── End artifact resolution ───────────────────────────────────────── - const span = SpanSerializer.createSpan( - SpanType.MEMORY, - "memory.retrieve", - { - "memory.operation": "retrieve", - "memory.store": "redis", - "memory.query": - args.search || args.messageId || `lastN:${args.lastN ?? "all"}`, - }, - ); - const startTime = Date.now(); - // args.sessionId is guaranteed non-null here — we returned early above - // when it was missing. Cast via string coercion to satisfy eslint. - const sessionId = String(args.sessionId); - try { - const conversation = await withTimeout( - memoryManager.getSessionRaw(sessionId), - 10_000, - new Error(`getSessionRaw() timed out for session "${sessionId}"`), - ); - if (!conversation) { - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); - getMetricsAggregator().recordSpan(endedSpan); - return { error: "Session not found", sessionId }; - } + if (!args.sessionId) { + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: "sessionId is required when artifactId is not provided", + }); + return { + error: "sessionId is required when artifactId is not provided", + }; + } - let messages = conversation.messages; + if (!memoryManager) { + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: "Memory manager not configured", + }); + return { + error: + "Session history retrieval requires Redis conversation memory — " + + "enable mcp.conversationMemory with a Redis backend, or use " + + "artifactId to retrieve an externalized MCP tool output.", + }; + } - // Filter by specific messageId - if (args.messageId) { - const msg = messages.find((m) => m.id === args.messageId); - if (!msg) { - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); - getMetricsAggregator().recordSpan(endedSpan); - return { error: "Message not found", messageId: args.messageId }; - } - messages = [msg]; - } + const span = SpanSerializer.createSpan(SpanType.MEMORY, "memory.retrieve", { + "memory.operation": "retrieve", + "memory.store": "redis", + "memory.query": + args.search || args.messageId || `lastN:${args.lastN ?? "all"}`, + }); + const startTime = Date.now(); + // args.sessionId is guaranteed non-null here — we returned early above + // when it was missing. Cast via string coercion to satisfy eslint. + const sessionId = String(args.sessionId); + try { + const conversation = await withTimeout( + memoryManager.getSessionRaw(sessionId), + 10_000, + new Error(`getSessionRaw() timed out for session "${sessionId}"`), + ); + if (!conversation) { + const endedSpan = SpanSerializer.endSpan( + span, + SpanStatus.ERROR, + `Session not found: ${sessionId}`, + ); + getMetricsAggregator().recordSpan(endedSpan); + return { error: "Session not found", sessionId }; + } - // Filter by role - if (args.role) { - messages = messages.filter((m) => m.role === args.role); - } + let messages = conversation.messages; - // Take last N - if (args.lastN) { - messages = messages.slice(-args.lastN); - } + // Filter by specific messageId + if (args.messageId) { + const msg = messages.find((m) => m.id === args.messageId); + if (!msg) { + const endedSpan = SpanSerializer.endSpan( + span, + SpanStatus.ERROR, + `Message not found: ${args.messageId}`, + ); + getMetricsAggregator().recordSpan(endedSpan); + return { error: "Message not found", messageId: args.messageId }; + } + messages = [msg]; + } - const charLimit = Math.min( - args.limit ?? DEFAULT_RETRIEVAL_LIMIT, - MAX_RETRIEVAL_LIMIT, - ); + // Filter by role + if (args.role) { + messages = messages.filter((m) => m.role === args.role); + } - const results = messages.map((msg) => { - const content = msg.content ?? ""; + // Take last N + if (args.lastN) { + messages = messages.slice(-args.lastN); + } - // Search mode: return matching lines with line numbers - if (args.search) { - try { - const pattern = args.search; - // Validate regex length to mitigate ReDoS from LLM-provided input - if (pattern.length > 200) { - return { - id: msg.id, - error: "Search pattern too long (max 200 chars)", - }; - } - const regex = new RegExp(pattern, "i"); // no 'g' flag — avoids stateful .test() bug - const lines = content.split("\n"); - const matches = lines - .map((line, i) => ({ line: i + 1, text: line })) - .filter((l) => regex.test(l.text)) - .slice(0, MAX_SEARCH_MATCHES); - return { - id: msg.id, - role: msg.role, - tool: msg.tool, - matchCount: matches.length, - matches, - totalSize: content.length, - }; - } catch { - return { id: msg.id, error: "Invalid regex pattern" }; - } - } + const charLimit = Math.min( + args.limit ?? DEFAULT_RETRIEVAL_LIMIT, + MAX_RETRIEVAL_LIMIT, + ); - // Paginated read mode - const start = args.offset ?? 0; - const end = start + charLimit; - const slice = content.slice(start, end); + const results = messages.map((msg) => { + const content = msg.content ?? ""; + // Search mode: return matching lines with line numbers + if (args.search) { + try { + const pattern = args.search; + // Validate regex length to mitigate ReDoS from LLM-provided input + if (pattern.length > 200) { return { id: msg.id, - role: msg.role, - tool: msg.tool, - content: slice, - totalSize: content.length, - hasMore: end < content.length, + error: "Search pattern too long (max 200 chars)", }; - }); + } + // Treat user input as literal search to prevent ReDoS. + // Regex metacharacters are escaped so patterns like "foo|bar" match literally. + const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const regex = new RegExp(escaped, "i"); + const lines = content.split("\n"); + const matches = lines + .map((line, i) => ({ line: i + 1, text: line })) + .filter((l) => regex.test(l.text)) + .slice(0, MAX_SEARCH_MATCHES); + return { + id: msg.id, + role: msg.role, + tool: msg.tool, + matchCount: matches.length, + matches, + totalSize: content.length, + }; + } catch { + return { id: msg.id, error: "Invalid regex pattern" }; + } + } - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); - getMetricsAggregator().recordSpan(endedSpan); + // Paginated read mode + const start = args.offset ?? 0; + const end = start + charLimit; + const slice = content.slice(start, end); - return { messages: results, totalMessages: results.length }; - } catch (error) { - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); - endedSpan.statusMessage = - error instanceof Error ? error.message : String(error); - getMetricsAggregator().recordSpan(endedSpan); + return { + id: msg.id, + role: msg.role, + tool: msg.tool, + content: slice, + totalSize: content.length, + hasMore: end < content.length, + }; + }); - logger.error("[MemoryRetrievalTools] Error retrieving context", { - error: error instanceof Error ? error.message : String(error), - }); - return { error: "Failed to retrieve context" }; - } - }, - }), - }; + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); + getMetricsAggregator().recordSpan(endedSpan); + + otelSpan.setAttribute("memory.message_count", results.length); + + return { messages: results, totalMessages: results.length }; + } catch (error) { + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); + endedSpan.statusMessage = + error instanceof Error ? error.message : String(error); + getMetricsAggregator().recordSpan(endedSpan); + + logger.error("[MemoryRetrievalTools] Error retrieving context", { + error: error instanceof Error ? error.message : String(error), + }); + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + otelSpan.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + return { error: "Failed to retrieve context" }; + } } diff --git a/src/lib/neurolink.ts b/src/lib/neurolink.ts index 3baaa9238..17f27da51 100644 --- a/src/lib/neurolink.ts +++ b/src/lib/neurolink.ts @@ -17,7 +17,7 @@ try { } import type { Hippocampus } from "@juspay/hippocampus"; -import { SpanKind, SpanStatusCode } from "@opentelemetry/api"; +import { SpanKind, SpanStatusCode, context, trace } from "@opentelemetry/api"; import { AsyncLocalStorage } from "async_hooks"; import { EventEmitter } from "events"; import pLimit from "p-limit"; @@ -96,6 +96,7 @@ import type { OrchestrationResult, MCPTool, RoutingDecision, + MetricsTraceContext, } from "./types/index.js"; import { emergencyContentTruncation } from "./context/emergencyTruncation.js"; import { @@ -292,6 +293,30 @@ function mcpCategoryToErrorCategory( } } +/** + * Extract a human-readable error string from an MCP isError result object. + * Returns an empty string if nothing useful can be extracted. + */ +function extractMcpErrorText(raw: unknown): string { + try { + const resultObj = + typeof raw === "string" ? (JSON.parse(raw) as unknown) : raw; + if (!resultObj || typeof resultObj !== "object") { + return ""; + } + const content = (resultObj as Record).content; + if (!Array.isArray(content)) { + return ""; + } + const texts = (content as Array<{ type?: string; text?: string }>) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text as string); + return texts.join(" ").substring(0, 500); + } catch { + return ""; + } +} + /** * Check if an error is a non-retryable provider error that should immediately * stop the retry/fallback chain. These errors represent permanent failures @@ -419,12 +444,6 @@ function isNonRetryableProviderError(error: unknown): boolean { * @since 1.0.0 */ -/** Per-request metrics trace context stored in AsyncLocalStorage to avoid races. */ -type MetricsTraceContext = { - traceId: string; - parentSpanId: string; -}; - /** * Module-level AsyncLocalStorage for per-request metrics trace context. * Eliminates the race condition where overlapping generate/stream calls on the @@ -720,6 +739,26 @@ export class NeuroLink { traceId: string; parentSpanId: string; } { + // Attempt to reuse the active OTel trace context so Pipeline B spans + // land in the same Langfuse trace as Pipeline A spans. + const activeSpan = trace.getSpan(context.active()); + if (activeSpan) { + const spanCtx = activeSpan.spanContext(); + // Only use the OTel context if it has a valid trace ID. + // parentSpanId stores the active span's ID as a parent reference; + // each Pipeline B span must generate its own unique spanId to comply + // with the OTel/W3C requirement that spanIds are unique per trace. + if ( + spanCtx.traceId && + spanCtx.traceId !== "00000000000000000000000000000000" + ) { + return { + traceId: spanCtx.traceId, + parentSpanId: spanCtx.spanId, + }; + } + } + // Fallback: no active OTel context (e.g. standalone Pipeline B usage) return { traceId: crypto.randomUUID().replace(/-/g, ""), parentSpanId: crypto.randomUUID().replace(/-/g, "").substring(0, 16), @@ -3052,6 +3091,13 @@ Current user's request: ${currentInput}`; private initializeMetricsListeners(): void { this.emitter.on("generation:end", ((...args: unknown[]) => { const data = args[0] as Record; + // A2 fix: When Pipeline A (AI SDK → @langfuse/otel) already creates a + // GENERATION observation, skip the Pipeline B span to avoid duplicates. + // Native providers (Bedrock, Ollama, Gemini 3) do NOT set this flag — + // Pipeline B remains their only observation source. + if (data.pipelineAHandled) { + return; + } try { const result = data.result as Record | undefined; const usage = result?.usage as @@ -3075,10 +3121,10 @@ Current user's request: ${currentInput}`; temperature: data.temperature as number | undefined, maxTokens: data.maxTokens as number | undefined, }); - // Make this the root span by using the pre-generated rootSpanId + // Link to the OTel parent span; each Pipeline B span keeps its own + // unique spanId to comply with OTel/W3C uniqueness requirements. if (traceCtx) { - span.spanId = traceCtx.parentSpanId; - span.parentSpanId = undefined; + span.parentSpanId = traceCtx.parentSpanId; } // Mark failed generations with ERROR status so metrics count them correctly const spanStatus = @@ -3092,6 +3138,26 @@ Current user's request: ${currentInput}`; ); span.durationMs = responseTime; + // G2 fix: Check finishReason and escalate to WARNING for partial failures + const finishReason = + (result?.finishReason as string | undefined) ?? + (data.finishReason as string | undefined); + if (finishReason) { + span.attributes["gen_ai.finish_reason"] = finishReason; + if (finishReason === "content-filter" || finishReason === "length") { + span = SpanSerializer.endSpan( + span, + SpanStatus.WARNING, + `Generation stopped: finishReason=${finishReason}`, + ); + } + } + + // G6 fix: Record retry count on Pipeline B span + if (data.retryCount !== undefined) { + span.attributes["gen_ai.retry_count"] = data.retryCount as number; + } + if (usage) { span = SpanSerializer.enrichWithTokenUsage(span, { promptTokens: usage.input || 0, @@ -3160,16 +3226,33 @@ Current user's request: ${currentInput}`; name: `gen_ai.${provider}.stream`, traceId: traceCtx?.traceId, }); - // Make this the root span by using the pre-generated rootSpanId + // Link to the OTel parent span; keep unique spanId per W3C spec. if (traceCtx) { - span.spanId = traceCtx.parentSpanId; - span.parentSpanId = undefined; + span.parentSpanId = traceCtx.parentSpanId; } span = SpanSerializer.endSpan(span, SpanStatus.OK); span.durationMs = durationMs; span.attributes["stream.chunk_count"] = chunkCount; span.attributes["stream.content_length"] = totalLength; + // S3 fix: Record finishReason on Pipeline B stream span + const streamFinishReason = + (metadata?.finishReason as string | undefined) ?? + (data.finishReason as string | undefined); + if (streamFinishReason) { + span.attributes["gen_ai.finish_reason"] = streamFinishReason; + if ( + streamFinishReason === "content-filter" || + streamFinishReason === "length" + ) { + span = SpanSerializer.endSpan( + span, + SpanStatus.WARNING, + `Stream stopped: finishReason=${streamFinishReason}`, + ); + } + } + // Record stream input prompt if (data.prompt) { const promptStr = String(data.prompt); @@ -3262,9 +3345,12 @@ Current user's request: ${currentInput}`; ); span.durationMs = responseTime; - if (!success && data.error) { - span.statusMessage = - (data.error as Error).message || String(data.error); + if (!success) { + if (data.error) { + span.statusMessage = String(data.error); + } else if (data.result) { + span.statusMessage = extractMcpErrorText(data.result); + } } if (data.result) { @@ -3302,16 +3388,25 @@ Current user's request: ${currentInput}`; name: `gen_ai.${provider}.stream.error`, traceId: traceCtx?.traceId, }); - // Make this the root span + // Link to the OTel parent span; keep unique spanId per W3C spec. if (traceCtx) { - span.spanId = traceCtx.parentSpanId; - span.parentSpanId = undefined; + span.parentSpanId = traceCtx.parentSpanId; } span = SpanSerializer.endSpan(span, SpanStatus.ERROR); span.durationMs = durationMs; span.statusMessage = `${errorName}: ${errorMessage}`; span.attributes["stream.chunk_count"] = chunkCount; + // S7 fix: Distinguish aborts from errors + const isAbort = + errorName === "AbortError" || + errorMessage.toLowerCase().includes("aborted") || + errorMessage.toLowerCase().includes("abort"); + span.attributes["error.type"] = isAbort ? "abort" : errorName; + if (isAbort) { + span.attributes["stream.aborted"] = true; + } + this.metricsAggregator.recordSpan(span); getMetricsAggregator().recordSpan(span); } catch { @@ -3469,6 +3564,20 @@ Current user's request: ${currentInput}`; code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); + + // G7 fix: Distinguish context overflow errors with dedicated attributes + if (error instanceof ContextBudgetExceededError) { + generateSpan.setAttribute("neurolink.error.type", "context_overflow"); + generateSpan.setAttribute( + "neurolink.context.estimated_tokens", + error.estimatedTokens, + ); + generateSpan.setAttribute( + "neurolink.context.available_tokens", + error.availableTokens, + ); + } + this.emitGenerateErrorEvent(optionsOrPrompt, error); throw error; } finally { @@ -3793,6 +3902,10 @@ Current user's request: ${currentInput}`; options.input?.text || (options as Record).prompt, temperature: textOptions.temperature, maxTokens: textOptions.maxTokens, + // A2 fix: Signal that Pipeline A (AI SDK → @langfuse/otel) already + // creates a GENERATION observation for this call. The generation:end + // listener should skip creating a duplicate Pipeline B span. + pipelineAHandled: true, }); this.emitter.emit("response:end", textResult.content || ""); this.emitter.emit( @@ -3862,6 +3975,17 @@ Current user's request: ${currentInput}`; "neurolink.finish_reason", generateResult.finishReason || "unknown", ); + + // G3 fix: Record step count and whether max steps was reached + // Read steps from the raw provider result (textResult), not the flattened DTO + const stepCount = (textResult as { steps?: unknown[] })?.steps?.length ?? 1; + const maxSteps = options.maxSteps ?? 200; // DEFAULT_MAX_STEPS + generateSpan.setAttribute("neurolink.step_count", stepCount); + generateSpan.setAttribute( + "neurolink.max_steps_reached", + stepCount >= maxSteps, + ); + generateSpan.setAttribute( "neurolink.result_provider", generateResult.provider || "unknown", @@ -6387,6 +6511,7 @@ Current user's request: ${currentInput}`; provider: metadata.fallbackProvider ?? providerName, model: metadata.fallbackModel ?? streamModel ?? enhancedOptions.model, + finishReason: streamState.finishReason ?? "stop", prompt: enhancedOptions.input?.text || (enhancedOptions as Record).prompt, @@ -6396,6 +6521,7 @@ Current user's request: ${currentInput}`; durationMs: Date.now() - streamStartTime, sessionId, usage: resolvedUsage, + finishReason: streamState.finishReason ?? "stop", ...(metadata.fallbackAttempted && { primaryProvider: providerName, primaryModel: enhancedOptions.model, @@ -7491,23 +7617,20 @@ Current user's request: ${currentInput}`; error: error instanceof Error ? error.message : String(error), }); - // Record a failed-provider span for the primary provider that threw + // S1 fix: Emit stream:error so the Pipeline B listener creates an error span. + // S8 fix: The old direct SpanSerializer.createGenerationSpan block is removed — + // the stream:error listener now handles span creation, avoiding duplication. try { - const failedProvider = options.provider || "unknown"; - const traceCtx = this._metricsTraceContext; - let failedSpan = SpanSerializer.createGenerationSpan({ - provider: failedProvider, + this.emitter.emit("stream:error", { + content: error instanceof Error ? error.message : String(error), + metadata: { + errorName: error instanceof Error ? error.name : "UnknownError", + durationMs: Date.now() - startTime, + chunkCount: 0, + }, + provider: options.provider || "unknown", model: options.model || "unknown", - name: `gen_ai.${failedProvider}.stream.failed`, - traceId: traceCtx?.traceId, - parentSpanId: traceCtx?.parentSpanId, }); - failedSpan = SpanSerializer.endSpan(failedSpan, SpanStatus.ERROR); - failedSpan.statusMessage = - error instanceof Error ? error.message : String(error); - failedSpan.durationMs = Date.now() - startTime; - this.metricsAggregator.recordSpan(failedSpan); - getMetricsAggregator().recordSpan(failedSpan); } catch { /* non-blocking */ } @@ -7559,6 +7682,25 @@ Current user's request: ${currentInput}`; contentLength: fallbackAccumulatedContent.length, }, ); + + // S6 fix: Emit stream:complete after successful fallback so Pipeline B records it + try { + self.emitter.emit("stream:complete", { + content: fallbackAccumulatedContent, + provider: providerName, + model: options.model || "unknown", + finishReason: "stop", + metadata: { + durationMs: Date.now() - startTime, + chunkCount: 0, + totalLength: fallbackAccumulatedContent.length, + isFallback: true, + finishReason: "stop", + }, + }); + } catch { + /* non-blocking */ + } } // Store memory after fallback stream consumption is complete @@ -8848,6 +8990,7 @@ Current user's request: ${currentInput}`; executionContext: ReturnType, toolSpan: ReturnType, ): Promise { + let toolRetryCount = 0; try { mcpLogger.debug( `[${executionContext.functionTag}] Executing tool: ${toolName}`, @@ -8858,7 +9001,6 @@ Current user's request: ${currentInput}`; circuitBreakerState: prepared.circuitBreaker.getState(), }, ); - const result: T = await prepared.circuitBreaker.execute(async () => { return withRetry( async () => @@ -8876,6 +9018,7 @@ Current user's request: ${currentInput}`; delayMs: prepared.finalOptions.retryDelayMs, isRetriable: isRetriableError, onRetry: (attempt, error) => { + toolRetryCount = attempt; mcpLogger.warn( `[${executionContext.functionTag}] Retrying tool execution (attempt ${attempt})`, { @@ -8888,6 +9031,7 @@ Current user's request: ${currentInput}`; }, ); }); + toolSpan.setAttribute("tool.retry_count", toolRetryCount); return await this.handleSuccessfulToolExecution( toolName, @@ -8897,6 +9041,8 @@ Current user's request: ${currentInput}`; toolSpan, ); } catch (error) { + // Ensure retry count is recorded even on failure + toolSpan.setAttribute("tool.retry_count", toolRetryCount); return this.handleFailedToolExecution( toolName, params, @@ -8956,6 +9102,21 @@ Current user's request: ${currentInput}`; (resultObj && "isError" in resultObj && resultObj.isError === true) || (resultObj && "success" in resultObj && resultObj.success === false); + const contentArr = isToolError + ? (resultObj?.content as + | Array<{ type?: string; text?: string }> + | undefined) + : undefined; + const errorText = isToolError + ? contentArr + ?.filter((content) => content.type === "text" && content.text) + .map((content) => content.text) + .join(" ") || + (typeof resultObj?.error === "string" + ? resultObj.error + : "Unknown error") + : undefined; + if (isToolError) { try { await prepared.circuitBreaker.execute(async () => { @@ -8973,18 +9134,9 @@ Current user's request: ${currentInput}`; }, ); - const contentArr = resultObj?.content as - | Array<{ type?: string; text?: string }> - | undefined; - const errorText = - contentArr - ?.filter((content) => content.type === "text" && content.text) - .map((content) => content.text) - .join(" ") || - (typeof resultObj?.error === "string" - ? resultObj.error - : "Unknown error"); - const errorCategory = classifyMcpErrorMessage(errorText); + const errorCategory = classifyMcpErrorMessage( + errorText ?? "Unknown error", + ); const prefix = `[TOOL_ERROR: ${toolName} failed (${errorCategory})] `; if (resultObj && Array.isArray(contentArr)) { @@ -8998,11 +9150,14 @@ Current user's request: ${currentInput}`; resultObj.content = clonedContent; } - toolSpan.setAttribute("tool.error.message", errorText.substring(0, 500)); + toolSpan.setAttribute( + "tool.error.message", + (errorText ?? "Unknown error").substring(0, 500), + ); toolSpan.setAttribute("tool.error.category", errorCategory); toolSpan.setStatus({ code: SpanStatusCode.ERROR, - message: `MCP tool returned isError: ${errorText.substring(0, 200)}`, + message: `MCP tool returned isError: ${(errorText ?? "Unknown error").substring(0, 200)}`, }); prepared.metrics.failedExecutions++; @@ -9027,6 +9182,7 @@ Current user's request: ${currentInput}`; executionContext.executionStartTime, !isToolError, result, + isToolError && errorText ? new Error(errorText) : undefined, ); toolSpan.setAttribute( "tool.result.status", @@ -9068,6 +9224,9 @@ Current user's request: ${currentInput}`; executionContext.executionStartTime, false, undefined, + new Error( + `Circuit breaker open for ${toolName} (state=${error.breakerState}, failures=${error.failureCount})`, + ), ); toolSpan.setAttribute("tool.result.status", "circuit_breaker_open"); toolSpan.setAttribute("tool.duration_ms", executionTime); diff --git a/src/lib/observability/exporterRegistry.ts b/src/lib/observability/exporterRegistry.ts index 0afe5cea9..8e8bbaa7d 100644 --- a/src/lib/observability/exporterRegistry.ts +++ b/src/lib/observability/exporterRegistry.ts @@ -7,8 +7,10 @@ import { logger } from "../utils/logger.js"; import type { BaseExporter } from "./exporters/baseExporter.js"; import { AlwaysSampler } from "./sampling/samplers.js"; import type { - ExporterHealthStatus, ExportResult, + ExporterHealthStatus, + ObservabilityCircuitBreakerConfig, + ObservabilityCircuitBreakerState, Sampler, SpanData, } from "../types/index.js"; @@ -42,25 +44,6 @@ function withExportTimeout( }); } -/** - * Circuit breaker state for an exporter - */ -type CircuitBreakerState = { - failures: number; - lastFailure: number; - state: "closed" | "open" | "half-open"; -}; - -/** - * Circuit breaker configuration - */ -type CircuitBreakerConfig = { - /** Number of failures before opening the circuit */ - failureThreshold: number; - /** Time in ms to wait before trying half-open state */ - resetTimeout: number; -}; - /** * Registry for managing multiple observability exporters * Includes circuit breaker protection to prevent cascading failures @@ -69,8 +52,9 @@ export class ExporterRegistry { private exporters: Map = new Map(); private defaultExporter: string | null = null; private sampler: Sampler = new AlwaysSampler(); - private circuitBreakers: Map = new Map(); - private readonly circuitBreakerConfig: CircuitBreakerConfig = { + private circuitBreakers: Map = + new Map(); + private readonly circuitBreakerConfig: ObservabilityCircuitBreakerConfig = { failureThreshold: 5, resetTimeout: 30000, // 30 seconds }; @@ -148,7 +132,9 @@ export class ExporterRegistry { * Configure the circuit breaker settings * @param config - Partial circuit breaker configuration */ - configureCircuitBreaker(config: Partial): void { + configureCircuitBreaker( + config: Partial, + ): void { Object.assign(this.circuitBreakerConfig, config); } @@ -228,7 +214,7 @@ export class ExporterRegistry { */ getCircuitBreakerStatus( exporterName: string, - ): CircuitBreakerState | undefined { + ): ObservabilityCircuitBreakerState | undefined { return this.circuitBreakers.get(exporterName); } diff --git a/src/lib/observability/exporters/sentryExporter.ts b/src/lib/observability/exporters/sentryExporter.ts index 3a79e356f..9e82cb3d6 100644 --- a/src/lib/observability/exporters/sentryExporter.ts +++ b/src/lib/observability/exporters/sentryExporter.ts @@ -8,36 +8,14 @@ import type { ExporterHealthStatus, ExportResult, SentryExporterConfig, + SentryModule, + SentryScope, SpanData, } from "../../types/index.js"; import { SpanStatus } from "../../types/index.js"; import { BaseExporter } from "./baseExporter.js"; // Sentry types - optional dependency -type SentryModule = { - init: (options: { - dsn: string; - tracesSampleRate: number; - release?: string; - environment: string; - }) => void; - withScope: (callback: (scope: SentryScope) => void) => void; - captureException: (error: Error) => void; - startInactiveSpan: (options: { - name: string; - op: string; - startTime: number; - attributes?: Record; - }) => { end: (timestamp?: number) => void }; - flush: (timeout: number) => Promise; - close: (timeout: number) => Promise; -}; - -type SentryScope = { - setTags: (tags: Record) => void; - setContext: (name: string, context: Record) => void; - setUser: (user: { id: string }) => void; -}; /** * Sentry exporter for error tracking and performance monitoring diff --git a/src/lib/observability/metricsAggregator.ts b/src/lib/observability/metricsAggregator.ts index 1b3ef1e23..a91d92094 100644 --- a/src/lib/observability/metricsAggregator.ts +++ b/src/lib/observability/metricsAggregator.ts @@ -1,42 +1,15 @@ import { TokenTracker } from "./tokenTracker.js"; import type { - SpanData, - TraceView, - TokenUsageStats, LatencyStats, + MetricsAggregatorConfig, MetricsSummary, ModelCostStats, ProviderCostStats, + SpanData, + TimeWindowStats, + TokenUsageStats, + TraceView, } from "../types/index.js"; -/** - * Time window statistics - */ -type TimeWindowStats = { - windowStart: Date; - windowEnd: Date; - windowDurationMs: number; - requestCount: number; - errorCount: number; - successRate: number; - throughput: number; // requests per second - latency: LatencyStats; - tokens: TokenUsageStats; - costByProvider: Map; - costByModel: Map; -}; -/** - * Configuration for the metrics aggregator - */ -type MetricsAggregatorConfig = { - /** Maximum spans to retain in memory */ - maxSpansRetained?: number; - /** Enable time-window statistics */ - enableTimeWindows?: boolean; - /** Time window size in milliseconds (default: 60000 = 1 minute) */ - timeWindowMs?: number; - /** Maximum time windows to retain */ - maxTimeWindows?: number; -}; /** * Metrics Aggregator for comprehensive telemetry analysis diff --git a/src/lib/observability/tokenTracker.ts b/src/lib/observability/tokenTracker.ts index e8c714e71..8ac5a1b42 100644 --- a/src/lib/observability/tokenTracker.ts +++ b/src/lib/observability/tokenTracker.ts @@ -4,23 +4,16 @@ */ import type { + ObservabilityModelPricing, SpanAttributes, SpanData, TokenUsageStats, } from "../types/index.js"; -/** - * Model pricing information - */ -type ModelPricing = { - inputPricePerMillion: number; - outputPricePerMillion: number; - cachedInputPricePerMillion?: number; -}; /** * Built-in model pricing database (approximate, subject to change) */ -const MODEL_PRICING: Record = { +const MODEL_PRICING: Record = { // OpenAI "gpt-4o": { inputPricePerMillion: 2.5, @@ -99,23 +92,26 @@ export class TokenTracker { bySpanType: new Map(), }; - private customPricing: Map = new Map(); + private customPricing: Map = new Map(); /** * Set custom pricing for a single model * @param modelName - The model name (e.g., "gpt-4o", "claude-3-5-sonnet") * @param pricing - The pricing information */ - setModelPricing(modelName: string, pricing: ModelPricing): void { + setObservabilityModelPricing( + modelName: string, + pricing: ObservabilityModelPricing, + ): void { this.customPricing.set(modelName, pricing); } /** - * Update pricing for an existing model (alias for setModelPricing) + * Update pricing for an existing model (alias for setObservabilityModelPricing) * @param model - The model name * @param pricing - The new pricing information */ - updatePricing(model: string, pricing: ModelPricing): void { + updatePricing(model: string, pricing: ObservabilityModelPricing): void { this.customPricing.set(model, pricing); } @@ -124,7 +120,9 @@ export class TokenTracker { * Useful for loading pricing from environment or config files * @param config - Record of model names to pricing information */ - loadPricingFromConfig(config: Record): void { + loadPricingFromConfig( + config: Record, + ): void { for (const [model, pricing] of Object.entries(config)) { this.customPricing.set(model, pricing); } @@ -135,7 +133,7 @@ export class TokenTracker { * @param model - The model name * @returns The pricing information or undefined if not found */ - getModelPricing(model: string): ModelPricing | undefined { + getModelPricing(model: string): ObservabilityModelPricing | undefined { return this.customPricing.get(model) ?? MODEL_PRICING[model]; } @@ -143,8 +141,10 @@ export class TokenTracker { * Get all available model pricing (custom + built-in) * @returns Record of all model pricing */ - getAllPricing(): Record { - const allPricing: Record = { ...MODEL_PRICING }; + getAllPricing(): Record { + const allPricing: Record = { + ...MODEL_PRICING, + }; // Custom pricing takes precedence const customPricingEntries = Array.from(this.customPricing.entries()); for (const [model, pricing] of customPricingEntries) { @@ -355,7 +355,7 @@ export class TokenTracker { const tracker = new TokenTracker(); // Copy custom pricing so windowed calculations use the same rates for (const [model, pricing] of this.customPricing) { - tracker.setModelPricing(model, pricing); + tracker.setObservabilityModelPricing(model, pricing); } for (const span of spans) { tracker.trackSpan(span); diff --git a/src/lib/observability/utils/spanSerializer.ts b/src/lib/observability/utils/spanSerializer.ts index dd600c4d8..2ea6f5111 100644 --- a/src/lib/observability/utils/spanSerializer.ts +++ b/src/lib/observability/utils/spanSerializer.ts @@ -14,13 +14,18 @@ import { SpanStatus, SpanType, } from "../../types/index.js"; +import { getActiveTraceContext } from "../../telemetry/traceContext.js"; /** * Utility class for span creation and serialization */ export class SpanSerializer { /** - * Create a new span with generated IDs + * Create a new span with generated IDs. + * + * When `traceId` / `parentSpanId` are omitted, the method automatically + * attempts to inherit them from the active OTel context so that Pipeline B + * spans land inside the same Langfuse trace as Pipeline A spans (fix A5). */ static createSpan( type: SpanType, @@ -29,10 +34,21 @@ export class SpanSerializer { parentSpanId?: string, traceId?: string, ): SpanData { + // A5 fix: When no explicit traceId is provided, try to inherit from the + // active OTel context so Pipeline B spans are linked to Pipeline A traces. + let resolvedTraceId = traceId; + let resolvedParentSpanId = parentSpanId; + if (!resolvedTraceId) { + const otelCtx = getActiveTraceContext(); + resolvedTraceId = otelCtx.traceId ?? randomBytes(16).toString("hex"); + if (!resolvedParentSpanId && otelCtx.parentSpanId) { + resolvedParentSpanId = otelCtx.parentSpanId; + } + } return { spanId: randomBytes(8).toString("hex"), - traceId: traceId ?? randomBytes(16).toString("hex"), - parentSpanId, + traceId: resolvedTraceId, + parentSpanId: resolvedParentSpanId, type, name, startTime: new Date().toISOString(), @@ -147,7 +163,12 @@ export class SpanSerializer { // Langfuse tracing; use LangfuseExporterConfig.redactIO=true to suppress them // in compliance-sensitive deployments. metadata: filterSafeMetadata(span.attributes), - level: span.status === SpanStatus.ERROR ? "ERROR" : "DEFAULT", + level: + span.status === SpanStatus.ERROR + ? "ERROR" + : span.status === SpanStatus.WARNING + ? "WARNING" + : "DEFAULT", statusMessage: span.statusMessage, input: span.attributes["input"], output: span.attributes["output"], diff --git a/src/lib/processors/base/BaseFileProcessor.ts b/src/lib/processors/base/BaseFileProcessor.ts index dfa5f068b..9ca392d99 100644 --- a/src/lib/processors/base/BaseFileProcessor.ts +++ b/src/lib/processors/base/BaseFileProcessor.ts @@ -49,6 +49,8 @@ import { gunzip } from "zlib"; import { SIZE_LIMITS } from "../config/index.js"; import { isAbortError } from "../../utils/errorHandling.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { createFileError, extractHttpStatus, @@ -135,90 +137,102 @@ export abstract class BaseFileProcessor { fileInfo: FileInfo, options?: ProcessOptions, ): Promise> { - try { - // Step 1: Validate file type and size - const validationResult = this.validateFileWithResult(fileInfo); - if (!validationResult.success) { - return { - success: false, - error: validationResult.error, - }; - } - - // Step 2: Get file buffer (from direct buffer or download from URL) - let buffer: Buffer; - - if (fileInfo.buffer) { - // Direct buffer provided - skip download - buffer = fileInfo.buffer; - } else if (fileInfo.url) { - // Download from URL - const downloadResult = await this.downloadFileWithRetry( - fileInfo, - options, - ); - if (!downloadResult.success) { - return { - success: false, - error: downloadResult.error, - }; - } - if (!downloadResult.data) { - return { - success: false, - error: this.createError(FileErrorCode.DOWNLOAD_FAILED, { - reason: "Download succeeded but returned no data", - }), - }; - } - buffer = downloadResult.data; - - // Validate actual downloaded size against limit - if (!this.validateFileSize(buffer.length)) { + return withSpan( + { + name: "neurolink.file.process", + tracer: tracers.file, + attributes: { + "file.processor": this.constructor.name, + "file.type": this.config.fileTypeName, + "file.mimetype": fileInfo.mimetype ?? "unknown", + "file.name": fileInfo.name ?? "unknown", + }, + }, + async (_span) => { + try { + // Step 1: Validate file type and size + const validationResult = this.validateFileWithResult(fileInfo); + if (!validationResult.success) { + return { + success: false, + error: validationResult.error, + }; + } + + // Step 2: Get file buffer (from direct buffer or download from URL) + let buffer: Buffer; + + if (fileInfo.buffer) { + // Direct buffer provided - skip download + buffer = fileInfo.buffer; + } else if (fileInfo.url) { + // Download from URL + const downloadResult = await this.downloadFileWithRetry( + fileInfo, + options, + ); + if (!downloadResult.success) { + return { + success: false, + error: downloadResult.error, + }; + } + if (!downloadResult.data) { + return { + success: false, + error: this.createError(FileErrorCode.DOWNLOAD_FAILED, { + reason: "Download succeeded but returned no data", + }), + }; + } + buffer = downloadResult.data; + + // Validate actual downloaded size against limit + if (!this.validateFileSize(buffer.length)) { + return { + success: false, + error: this.createError(FileErrorCode.FILE_TOO_LARGE, { + sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), + maxMB: this.config.maxSizeMB, + type: this.config.fileTypeName, + }), + }; + } + } else { + // No buffer or URL provided + return { + success: false, + error: this.createError(FileErrorCode.DOWNLOAD_FAILED, { + reason: "No buffer or URL provided for file", + }), + }; + } + + // Step 3: Post-download validation (subclasses can override) + const postValidationResult = + await this.validateDownloadedFileWithResult(buffer, fileInfo); + if (!postValidationResult.success) { + return { + success: false, + error: postValidationResult.error, + }; + } + + // Step 4: Build processed result using template method + return await this.buildProcessedResultWithResult(buffer, fileInfo); + } catch (error) { + // Catch any unexpected errors return { success: false, - error: this.createError(FileErrorCode.FILE_TOO_LARGE, { - sizeMB: (buffer.length / (1024 * 1024)).toFixed(2), - maxMB: this.config.maxSizeMB, - type: this.config.fileTypeName, - }), + error: this.createError( + FileErrorCode.UNKNOWN_ERROR, + { error: error instanceof Error ? error.message : String(error) }, + error instanceof Error ? error : undefined, + ), }; } - } else { - // No buffer or URL provided - return { - success: false, - error: this.createError(FileErrorCode.DOWNLOAD_FAILED, { - reason: "No buffer or URL provided for file", - }), - }; - } - - // Step 3: Post-download validation (subclasses can override) - const postValidationResult = await this.validateDownloadedFileWithResult( - buffer, - fileInfo, - ); - if (!postValidationResult.success) { - return { - success: false, - error: postValidationResult.error, - }; - } - - // Step 4: Build processed result using template method - return await this.buildProcessedResultWithResult(buffer, fileInfo); - } catch (error) { - // Catch any unexpected errors - return { - success: false, - error: this.createError( - FileErrorCode.UNKNOWN_ERROR, - { error: error instanceof Error ? error.message : String(error) }, - error instanceof Error ? error : undefined, - ), - }; - } + }, + ); // end withSpan } /** diff --git a/src/lib/processors/document/ExcelProcessor.ts b/src/lib/processors/document/ExcelProcessor.ts index 29f2b713f..a8648707b 100644 --- a/src/lib/processors/document/ExcelProcessor.ts +++ b/src/lib/processors/document/ExcelProcessor.ts @@ -39,15 +39,15 @@ import ExcelJS from "exceljs"; const { Workbook } = ExcelJS; -type CellValue = ExcelJS.CellValue; import { BaseFileProcessor } from "../base/BaseFileProcessor.js"; import type { + CellValue, + ExcelWorksheet, FileInfo, - ProcessorFileProcessingResult, ProcessOptions, - ExcelWorksheet, ProcessedExcel, + ProcessorFileProcessingResult, } from "../../types/index.js"; import { SIZE_LIMITS } from "../config/index.js"; import { FileErrorCode } from "../errors/index.js"; diff --git a/src/lib/processors/errors/errorHelpers.ts b/src/lib/processors/errors/errorHelpers.ts index 70661dd5f..e5abfda88 100644 --- a/src/lib/processors/errors/errorHelpers.ts +++ b/src/lib/processors/errors/errorHelpers.ts @@ -11,41 +11,12 @@ import { isAbortError } from "../../utils/errorHandling.js"; import type { FileProcessingError, + FileProcessingSummary, ProcessorErrorMessageTemplate, } from "../../types/index.js"; import { ERROR_MESSAGES, FileErrorCode } from "./FileErrorCode.js"; -/** - * Summary of file processing operations. - */ -type FileProcessingSummary = { - /** Total number of files attempted */ - totalFiles: number; - /** Successfully processed files */ - processedFiles: Array<{ - filename: string; - size?: number; - type?: string; - }>; - /** Files that failed to process */ - failedFiles: Array<{ - filename: string; - error: FileProcessingError; - }>; - /** Files that were skipped */ - skippedFiles: Array<{ - filename: string; - reason: string; - suggestedAlternative?: string; - }>; - /** Non-fatal warnings */ - warnings: Array<{ - filename: string; - message: string; - }>; -}; - /** * Create a structured file processing error with user-friendly messaging. * diff --git a/src/lib/processors/errors/errorSerializer.ts b/src/lib/processors/errors/errorSerializer.ts index 74b39f8f8..9a46f1b97 100644 --- a/src/lib/processors/errors/errorSerializer.ts +++ b/src/lib/processors/errors/errorSerializer.ts @@ -13,6 +13,7 @@ */ import { createHash } from "crypto"; +import type { SerializeOptions, SerializedError } from "../../types/index.js"; /** * Fields that should be redacted for security/privacy. @@ -59,52 +60,6 @@ const MAX_METADATA_SIZE = 2000; const MAX_STACK_FRAMES = 20; const MAX_DEPTH = 5; -/** - * Serialized error representation with full context. - */ -type SerializedError = { - /** Unique error instance ID */ - errorId: string; - /** Deterministic fingerprint for error aggregation */ - errorFingerprint: string; - /** Error type/class name */ - errorType: string; - /** Error message */ - message: string; - /** Full stack trace */ - stack?: string; - /** Parsed stack frames */ - stackFrames?: string[]; - /** HTTP status code if applicable */ - statusCode?: number; - /** Whether the error is operational (expected) vs programmer error */ - isOperational?: boolean; - /** Whether the error is retryable */ - isRetryable?: boolean; - /** Error code */ - code?: string; - /** Additional metadata */ - metadata?: Record; - /** Serialized cause error (for error chaining) */ - cause?: SerializedError; - /** ISO timestamp of when the error was serialized */ - timestamp: string; -}; - -/** - * Options for error serialization. - */ -type SerializeOptions = { - /** Include stack trace in output (default: true) */ - includeStack?: boolean; - /** Max depth for nested object serialization (default: 5) */ - maxDepth?: number; - /** Filter stack traces to application frames only (default: true in production) */ - filterStacks?: boolean; - /** Additional context to include */ - context?: Record; -}; - /** * Safely serialize an error with full context preservation. * Handles circular references, redacts sensitive data, and preserves error metadata. diff --git a/src/lib/processors/registry/ProcessorRegistry.ts b/src/lib/processors/registry/ProcessorRegistry.ts index 3d7857586..1d87ed7d7 100644 --- a/src/lib/processors/registry/ProcessorRegistry.ts +++ b/src/lib/processors/registry/ProcessorRegistry.ts @@ -55,6 +55,8 @@ import type { RegistryProcessResult, ProcessorRegistration, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; // ============================================================================= // UTILITY FUNCTIONS // ============================================================================= @@ -452,12 +454,25 @@ export class ProcessorRegistry { fileInfo: FileInfo, options?: ProcessOptions, ): Promise | null> { - const match = this.findProcessor(fileInfo.mimetype, fileInfo.name); - if (!match) { - return null; - } - const processor = match.processor as BaseFileProcessor; - return processor.processFile(fileInfo, options); + return withSpan( + { + name: "neurolink.processor.processFile", + tracer: tracers.processor, + attributes: { + "processor.filename": fileInfo.name ?? "unknown", + "processor.mimetype": fileInfo.mimetype ?? "unknown", + }, + }, + async () => { + const match = this.findProcessor(fileInfo.mimetype, fileInfo.name); + if (!match) { + return null; + } + const processor = + match.processor as BaseFileProcessor; + return processor.processFile(fileInfo, options); + }, + ); } /** diff --git a/src/lib/providers/amazonBedrock.ts b/src/lib/providers/amazonBedrock.ts index acbfea760..94709763b 100644 --- a/src/lib/providers/amazonBedrock.ts +++ b/src/lib/providers/amazonBedrock.ts @@ -41,16 +41,22 @@ import type { BedrockContentBlock, BedrockMessage, } from "../types/index.js"; -import { AuthenticationError, ProviderError } from "../types/index.js"; +import { + AuthenticationError, + ProviderError, + RateLimitError, +} from "../types/index.js"; import { isAbortError, withTimeout } from "../utils/errorHandling.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { calculateCost } from "../utils/pricing.js"; import { buildMultimodalMessagesArray } from "../utils/messageBuilder.js"; import { buildMultimodalOptions } from "../utils/multimodalOptionsBuilder.js"; import { convertZodToJsonSchema } from "../utils/schemaConversion.js"; -import { type Span, trace, SpanKind, SpanStatusCode } from "@opentelemetry/api"; +import { type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api"; +import { tracers } from "../telemetry/index.js"; -const bedrockTracer = trace.getTracer("neurolink.bedrock"); +const bedrockTracer = tracers.provider; // Bedrock-specific types now imported from ../types/providerSpecific.js @@ -203,6 +209,7 @@ export class AmazonBedrockProvider extends BaseProvider { "[AmazonBedrockProvider] generate() called with conversation management", ); + const generateStartTime = Date.now(); const options = typeof optionsOrPrompt === "string" ? { prompt: optionsOrPrompt } @@ -274,21 +281,71 @@ export class AmazonBedrockProvider extends BaseProvider { ); // Start conversation loop and return enhanced result - const text = await this.conversationLoop(options); + let text: string; + let usage: { input: number; output: number; total: number }; + let finishReason: string | undefined; + try { + ({ text, usage, finishReason } = await this.conversationLoop(options)); + } catch (error) { + // Emit failure generation:end so Pipeline B records the failed generation + const failEmitter = this.neurolink?.getEventEmitter(); + if (failEmitter) { + failEmitter.emit("generation:end", { + provider: this.providerName, + responseTime: Date.now() - generateStartTime, + timestamp: Date.now(), + result: { + content: "", + usage: { input: 0, output: 0, total: 0 }, + model: this.modelName || this.getDefaultModel(), + provider: this.providerName, + finishReason: "error", + }, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; + } + + // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION observation. + // Bedrock bypasses the Vercel AI SDK so experimental_telemetry is never injected; + // we emit the event manually to fill that gap. + const generateEmitter = this.neurolink?.getEventEmitter(); + if (generateEmitter) { + generateEmitter.emit("generation:end", { + provider: this.providerName, + responseTime: Date.now() - generateStartTime, + timestamp: Date.now(), + result: { + content: text, + usage, + model: this.modelName || this.getDefaultModel(), + provider: this.providerName, + finishReason, + }, + success: true, + }); + } return { content: text, // CLI expects 'content' not 'text' - usage: { total: 0, input: 0, output: 0 }, + usage, model: this.modelName || this.getDefaultModel(), provider: this.getProviderName(), }; } - private async conversationLoop( - options: TextGenerationOptions, - ): Promise { + private async conversationLoop(options: TextGenerationOptions): Promise<{ + text: string; + usage: { input: number; output: number; total: number }; + finishReason?: string; + }> { const maxIterations = 10; // Prevent infinite loops let iteration = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let lastFinishReason: string | undefined; while (iteration < maxIterations) { iteration++; @@ -304,6 +361,14 @@ export class AmazonBedrockProvider extends BaseProvider { JSON.stringify(response, null, 2), ); + // Accumulate real token counts and capture the stop reason so + // Pipeline B (Langfuse) gets correct usage and finishReason. + totalInputTokens += response.usage?.inputTokens ?? 0; + totalOutputTokens += response.usage?.outputTokens ?? 0; + if (response.stopReason) { + lastFinishReason = response.stopReason; + } + const result = await this.handleBedrockResponse(response); logger.debug(`[AmazonBedrockProvider] Handle response result:`, result); @@ -318,7 +383,15 @@ export class AmazonBedrockProvider extends BaseProvider { logger.debug( `[AmazonBedrockProvider] Returning final text: "${result.text}"`, ); - return result.text || ""; + return { + text: result.text || "", + usage: { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }, + finishReason: lastFinishReason, + }; } } catch (error) { logger.error( @@ -1286,6 +1359,13 @@ export class AmazonBedrockProvider extends BaseProvider { const maxIterations = options.maxSteps || DEFAULT_MAX_STEPS; let iteration = 0; + // Shared counters updated by both the first-iteration inline loop and + // the processStreamResponse loop. Read by the final generation:end emit + // so Pipeline B (Langfuse) gets real token counts from Bedrock streams. + let streamTotalInputTokens = 0; + let streamTotalOutputTokens = 0; + let streamLastStopReason: string | undefined; + // The REAL issue: ReadableStream errors don't bubble up to the caller // So we need to make the first streaming call synchronously to test permissions try { @@ -1411,10 +1491,26 @@ export class AmazonBedrockProvider extends BaseProvider { if (chunk.messageStop) { firstStopReason = chunk.messageStop.stopReason || "end_turn"; + // Don't break — metadata chunk with usage comes after messageStop + continue; + } + + // Accumulate usage from Bedrock metadata chunk for Pipeline B. + // The metadata chunk is emitted after messageStop with aggregate usage. + if (chunk.metadata?.usage) { + streamTotalInputTokens += + chunk.metadata.usage.inputTokens ?? 0; + streamTotalOutputTokens += + chunk.metadata.usage.outputTokens ?? 0; + // Stream is effectively complete after metadata chunk break; } } + if (firstStopReason) { + streamLastStopReason = firstStopReason; + } + // Add first assistant message to conversation history const firstAssistantMessage: BedrockMessage = { role: "assistant", @@ -1461,9 +1557,18 @@ export class AmazonBedrockProvider extends BaseProvider { ); const commandInput = await this.prepareStreamCommand(options); - const { stopReason, assistantMessage } = + const { stopReason, assistantMessage, usage } = await this.processStreamResponse(commandInput, controller); + // Accumulate real usage from Bedrock metadata chunks. + if (usage) { + streamTotalInputTokens += usage.input; + streamTotalOutputTokens += usage.output; + } + if (stopReason) { + streamLastStopReason = stopReason; + } + streamSpan.addEvent("stream.turn_complete", { iteration, stop_reason: stopReason, @@ -1512,23 +1617,75 @@ export class AmazonBedrockProvider extends BaseProvider { }, }); - // Create analytics promise (without token tracking for now due to AWS SDK limitations) - const analyticsPromise = Promise.resolve( - createAnalytics( - this.providerName, - this.modelName || this.getDefaultModel(), - { usage: { input: 0, output: 0, total: 0 } }, - Date.now() - startTime, - { - requestId: `bedrock-stream-${Date.now()}`, - streamingMode: true, - note: "Token usage not available from AWS SDK streaming responses", - }, - ), + // Emit generation:end after the stream completes so Pipeline B (Langfuse) + // creates a GENERATION observation. Bedrock bypasses the Vercel AI SDK so + // experimental_telemetry is never injected; we emit the event manually. + const streamEmitter = this.neurolink?.getEventEmitter(); + const streamAsyncIterable = this.convertToAsyncIterable(stream); + const self = this; + + // Defer analytics resolution until the stream completes so we have + // real token counts aggregated from Bedrock metadata chunks. + let resolveAnalytics!: ( + value: ReturnType, + ) => void; + const analyticsPromise = new Promise>( + (resolve) => { + resolveAnalytics = resolve; + }, ); + const wrappedStreamIterable: AsyncIterable<{ content: string }> = { + async *[Symbol.asyncIterator]() { + let streamErrored = false; + try { + yield* streamAsyncIterable; + } catch (error) { + streamErrored = true; + throw error; + } finally { + const aggregatedUsage = { + input: streamTotalInputTokens, + output: streamTotalOutputTokens, + total: streamTotalInputTokens + streamTotalOutputTokens, + }; + + // Resolve analytics with accumulated token counts from Bedrock + // metadata chunks so Pipeline A also reports real usage. + resolveAnalytics( + createAnalytics( + self.providerName, + self.modelName || self.getDefaultModel(), + { usage: aggregatedUsage }, + Date.now() - startTime, + { + requestId: `bedrock-stream-${Date.now()}`, + streamingMode: true, + }, + ), + ); + + if (streamEmitter) { + streamEmitter.emit("generation:end", { + provider: self.providerName, + responseTime: Date.now() - startTime, + timestamp: Date.now(), + result: { + content: "", + usage: aggregatedUsage, + model: self.modelName || self.getDefaultModel(), + provider: self.providerName, + finishReason: streamErrored ? "error" : streamLastStopReason, + }, + success: !streamErrored, + }); + } + } + }, + }; + return { - stream: this.convertToAsyncIterable(stream), + stream: wrappedStreamIterable, usage: { total: 0, input: 0, output: 0 }, model: this.modelName || this.getDefaultModel(), provider: this.getProviderName(), @@ -1646,7 +1803,11 @@ export class AmazonBedrockProvider extends BaseProvider { private async processStreamResponse( commandInput: ConverseStreamCommandInput, controller: ReadableStreamDefaultController, - ): Promise<{ stopReason: string; assistantMessage: BedrockMessage }> { + ): Promise<{ + stopReason: string; + assistantMessage: BedrockMessage; + usage?: { input: number; output: number; total: number }; + }> { const command = new ConverseStreamCommand(commandInput); logger.debug( @@ -1681,6 +1842,9 @@ export class AmazonBedrockProvider extends BaseProvider { })[] = []; let stopReason = ""; let currentText = ""; + let streamUsage: + | { input: number; output: number; total: number } + | undefined; // Process streaming chunks for await (const chunk of response.stream) { @@ -1770,6 +1934,21 @@ export class AmazonBedrockProvider extends BaseProvider { if (chunk.messageStop) { stopReason = chunk.messageStop.stopReason || "end_turn"; + // Don't break — metadata chunk with usage arrives after messageStop + continue; + } + + // Bedrock ConverseStream emits a metadata chunk at the end with + // aggregate usage. Capture it for Pipeline B telemetry. + if (chunk.metadata?.usage) { + const input = chunk.metadata.usage.inputTokens ?? 0; + const output = chunk.metadata.usage.outputTokens ?? 0; + streamUsage = { + input, + output, + total: chunk.metadata.usage.totalTokens ?? input + output, + }; + // Stream is effectively complete after metadata chunk break; } } @@ -1781,7 +1960,7 @@ export class AmazonBedrockProvider extends BaseProvider { }; this.conversationHistory.push(assistantMessage); - return { stopReason, assistantMessage }; + return { stopReason, assistantMessage, usage: streamUsage }; } private async handleStreamStopReason( @@ -1945,6 +2124,23 @@ export class AmazonBedrockProvider extends BaseProvider { `📤 [AmazonBedrockProvider] Added ${toolResults.length} tool results to conversation (1:1 mapping validated)`, ); + // Emit tool:end for each completed tool result so Pipeline B + // captures telemetry for Bedrock-driven tool calls (gap S2). + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResultsForStorage.map((tr) => { + const hasError = + tr.result && typeof tr.result === "object" && "error" in tr.result; + return { + toolName: tr.toolName, + result: tr.result, + error: hasError + ? String((tr.result as Record).error) + : undefined, + }; + }), + ); + // Store tool execution for analytics and debugging (similar to Vertex onStepFinish) this.handleToolExecutionStorage( toolCalls, @@ -2057,6 +2253,19 @@ export class AmazonBedrockProvider extends BaseProvider { ); } + // Check for AWS-specific throttling BEFORE generic mapping + const errName = (error as { name?: string })?.name ?? ""; + const errCode = (error as { code?: string })?.code ?? ""; + if ( + errName === "ThrottlingException" || + errCode === "ThrottlingException" + ) { + return new RateLimitError( + `Bedrock rate limit (throttled): ${error instanceof Error ? error.message : String(error)}`, + "bedrock", + ); + } + return new ProviderError( `AWS Bedrock error: ${message}`, this.providerName, diff --git a/src/lib/providers/amazonSagemaker.ts b/src/lib/providers/amazonSagemaker.ts index a5b43301b..9b5f712be 100644 --- a/src/lib/providers/amazonSagemaker.ts +++ b/src/lib/providers/amazonSagemaker.ts @@ -19,6 +19,8 @@ import type { SageMakerAsLanguageModel, } from "../types/index.js"; import { logger } from "../utils/logger.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; // SageMaker-specific imports import { getDefaultSageMakerEndpoint, @@ -121,19 +123,34 @@ export class AmazonSageMakerProvider extends BaseProvider { _options: StreamOptions, _analysisSchema?: ZodType | Schema, ): Promise { - try { - // For now, throw an error indicating this is not yet implemented - throw new SageMakerError( - "SageMaker streaming not yet fully implemented. Coming in next phase.", - { - code: "MODEL_ERROR", - statusCode: 501, - endpoint: this.modelConfig.endpointName, + return withSpan( + { + name: "neurolink.provider.sagemaker.stream", + tracer: tracers.stream, + attributes: { + "provider.name": "sagemaker", + "model.name": this.modelName, + "sagemaker.endpoint": this.modelConfig.endpointName, + "sagemaker.region": this.sagemakerConfig.region, + "sagemaker.not_implemented": true, }, - ); - } catch (error) { - throw this.handleProviderError(error); - } + }, + async () => { + try { + // For now, throw an error indicating this is not yet implemented + throw new SageMakerError( + "SageMaker streaming not yet fully implemented. Coming in next phase.", + { + code: "MODEL_ERROR", + statusCode: 501, + endpoint: this.modelConfig.endpointName, + }, + ); + } catch (error) { + throw this.handleProviderError(error); + } + }, + ); } protected formatProviderError(error: unknown): Error { diff --git a/src/lib/providers/anthropic.ts b/src/lib/providers/anthropic.ts index e4a766abf..720cb7576 100644 --- a/src/lib/providers/anthropic.ts +++ b/src/lib/providers/anthropic.ts @@ -66,6 +66,7 @@ import { TimeoutError, } from "../utils/timeout.js"; import { resolveToolChoice } from "../utils/toolChoice.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { getModelId } from "./providerTypeUtils.js"; /** @@ -1063,6 +1064,18 @@ export class AnthropicProvider extends BaseProvider { experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options), onStepFinish: ({ toolCalls, toolResults }) => { + // Emit tool:end for each completed tool result so Pipeline B + // captures telemetry for AI-SDK-driven tool calls (gap S2). + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); + this.handleToolExecutionStorage( toolCalls, toolResults, @@ -1080,6 +1093,16 @@ export class AnthropicProvider extends BaseProvider { }, }); } catch (streamError) { + streamSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: + streamError instanceof Error + ? streamError.message + : String(streamError), + }); + if (streamError instanceof Error) { + streamSpan.recordException(streamError); + } streamSpan.end(); throw streamError; } diff --git a/src/lib/providers/azureOpenai.ts b/src/lib/providers/azureOpenai.ts index ddf9fa822..ee52a73db 100644 --- a/src/lib/providers/azureOpenai.ts +++ b/src/lib/providers/azureOpenai.ts @@ -5,6 +5,7 @@ import { BaseProvider } from "../core/baseProvider.js"; import { DEFAULT_MAX_STEPS } from "../core/constants.js"; import type { NeuroLink } from "../neurolink.js"; import { createProxyFetch } from "../proxy/proxyFetch.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import type { UnknownRecord, StepFinishEvent, @@ -178,6 +179,15 @@ export class AzureOpenAIProvider extends BaseProvider { this.telemetryHandler.getTelemetryConfig(options), experimental_repairToolCall: this.getToolCallRepairFn(options), onStepFinish: (event: StepFinishEvent) => { + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + event.toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); this.handleToolExecutionStorage( [...event.toolCalls], [...event.toolResults], diff --git a/src/lib/providers/googleAiStudio.ts b/src/lib/providers/googleAiStudio.ts index 98c177bd2..7df84a7fb 100644 --- a/src/lib/providers/googleAiStudio.ts +++ b/src/lib/providers/googleAiStudio.ts @@ -18,6 +18,7 @@ import { BaseProvider } from "../core/baseProvider.js"; import { DEFAULT_MAX_STEPS } from "../core/constants.js"; import { streamAnalyticsCollector } from "../core/streamAnalytics.js"; import type { NeuroLink } from "../neurolink.js"; +import { SpanStatusCode } from "@opentelemetry/api"; import { ATTR, tracers, withClientSpan } from "../telemetry/index.js"; import type { AnalyticsData, @@ -29,6 +30,7 @@ import type { GoogleGenAIClass, LiveServerMessage, AudioChunk, + GoogleLiveAudioQueueItem, NativeToolsConfig, StreamOptions, StreamResult, @@ -52,6 +54,7 @@ import { } from "../utils/timeout.js"; import { estimateTokens } from "../utils/tokenEstimation.js"; import { resolveToolChoice } from "../utils/toolChoice.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { buildNativeConfig, buildNativeToolDeclarations, @@ -708,6 +711,18 @@ export class GoogleAIStudioProvider extends BaseProvider { }); } + // Emit tool:end for each completed tool result so Pipeline B + // captures telemetry for AI-SDK-driven tool calls (gap S2). + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); + this.handleToolExecutionStorage( toolCalls, toolResults, @@ -1023,8 +1038,64 @@ export class GoogleAIStudioProvider extends BaseProvider { timestamp: new Date().toISOString(), }); + // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION + // observation. The native @google/genai stream path bypasses the Vercel + // AI SDK so experimental_telemetry is never injected; we emit manually. + const nativeStreamEmitter = this.neurolink?.getEventEmitter(); + if (nativeStreamEmitter) { + nativeStreamEmitter.emit("generation:end", { + provider: this.providerName, + responseTime, + timestamp: Date.now(), + result: { + content: "", + usage: { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }, + model: modelName, + provider: this.providerName, + finishReason: hitStepLimitWithoutFinalAnswer + ? "max_steps" + : "stop", + }, + success: true, + }); + } + channel.close(); } catch (err) { + // Propagate error to OTel span so traces show ERROR status + span.recordException( + err instanceof Error ? err : new Error(String(err)), + ); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: err instanceof Error ? err.message : String(err), + }); + // Emit failure generation:end so Pipeline B records the failed stream + const errorEmitter = this.neurolink?.getEventEmitter(); + if (errorEmitter) { + errorEmitter.emit("generation:end", { + provider: this.providerName, + responseTime: Date.now() - startTime, + timestamp: Date.now(), + result: { + content: "", + usage: { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }, + model: modelName, + provider: this.providerName, + finishReason: "error", + }, + success: false, + error: err instanceof Error ? err.message : String(err), + }); + } channel.error(err); analyticsReject(err); } finally { @@ -1254,6 +1325,30 @@ export class GoogleAIStudioProvider extends BaseProvider { step >= maxSteps ? "max_steps" : "stop", ); + // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION + // observation. The native @google/genai path bypasses the Vercel AI SDK + // so experimental_telemetry is never injected; we emit the event manually. + const nativeGenerateEmitter = this.neurolink?.getEventEmitter(); + if (nativeGenerateEmitter) { + nativeGenerateEmitter.emit("generation:end", { + provider: this.providerName, + responseTime, + timestamp: Date.now(), + result: { + content: finalText, + usage: { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }, + model: modelName, + provider: this.providerName, + finishReason: step >= maxSteps ? "max_steps" : "stop", + }, + success: true, + }); + } + // Build EnhancedGenerateResult return { content: finalText, @@ -1373,17 +1468,13 @@ export class GoogleAIStudioProvider extends BaseProvider { "gemini-2.5-flash-preview-native-audio-dialog"; // Simple async queue for yielding audio events to the outer AsyncIterable - type QueueItem = - | { type: "audio"; audio: AudioChunk } - | { type: "end" } - | { type: "error"; error: unknown }; - const queue: QueueItem[] = []; + const queue: GoogleLiveAudioQueueItem[] = []; let resolveNext: | ((value: IteratorResult<{ type: "audio"; audio: AudioChunk }>) => void) | null = null; let done = false; - const push = (item: QueueItem) => { + const push = (item: GoogleLiveAudioQueueItem) => { if (done) { return; } diff --git a/src/lib/providers/googleVertex.ts b/src/lib/providers/googleVertex.ts index abf0ffdef..7e4a343f9 100644 --- a/src/lib/providers/googleVertex.ts +++ b/src/lib/providers/googleVertex.ts @@ -50,6 +50,7 @@ import type { StreamResult, StreamToolCall, StreamToolResult, + VertexNativePart, } from "../types/index.js"; import { @@ -80,6 +81,7 @@ import { } from "../utils/timeout.js"; import { estimateTokens } from "../utils/tokenEstimation.js"; import { resolveToolChoice } from "../utils/toolChoice.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { buildNativeConfig, buildNativeToolDeclarations, @@ -1524,6 +1526,10 @@ export class GoogleVertexProvider extends BaseProvider { }); } + // Emit tool:end for each completed tool result so Pipeline B + // captures telemetry for AI-SDK-driven tool calls (gap S2). + emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults); + this.handleToolExecutionStorage( toolCalls, toolResults, @@ -1760,11 +1766,7 @@ export class GoogleVertexProvider extends BaseProvider { { text: string } | { inlineData: { mimeType: string; data: string } } >; }> { - type NativePart = - | { text: string } - | { inlineData: { mimeType: string; data: string } }; - - const userParts: NativePart[] = [{ text: inputText }]; + const userParts: VertexNativePart[] = [{ text: inputText }]; // Add PDF files as inlineData parts if present if (multimodalInput?.pdfFiles && multimodalInput.pdfFiles.length > 0) { @@ -2192,6 +2194,33 @@ export class GoogleVertexProvider extends BaseProvider { timestamp: new Date().toISOString(), }); + // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION + // observation. The native @google/genai stream path on Vertex bypasses the + // Vercel AI SDK so experimental_telemetry is never injected; we emit manually. + const vertexStreamEmitter = this.neurolink?.getEventEmitter(); + if (vertexStreamEmitter) { + vertexStreamEmitter.emit("generation:end", { + provider: this.providerName, + responseTime, + timestamp: Date.now(), + result: { + content: "", + usage: { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }, + model: params.modelName, + provider: this.providerName, + finishReason: + step >= params.maxSteps && !completedWithFinalAnswer + ? "max_steps" + : "stop", + }, + success: true, + }); + } + params.channel.close(); } catch (error) { params.channel.error(error); @@ -2443,6 +2472,30 @@ export class GoogleVertexProvider extends BaseProvider { step >= maxSteps ? "max_steps" : "stop", ); + // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION + // observation. The native @google/genai path on Vertex bypasses the Vercel + // AI SDK so experimental_telemetry is never injected; we emit manually. + const vertexGenerateEmitter = this.neurolink?.getEventEmitter(); + if (vertexGenerateEmitter) { + vertexGenerateEmitter.emit("generation:end", { + provider: this.providerName, + responseTime, + timestamp: Date.now(), + result: { + content: finalText, + usage: { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }, + model: modelName, + provider: this.providerName, + finishReason: step >= maxSteps ? "max_steps" : "stop", + }, + success: true, + }); + } + // Build EnhancedGenerateResult return { content: finalText, diff --git a/src/lib/providers/huggingFace.ts b/src/lib/providers/huggingFace.ts index 3575b99fe..137c0d5d4 100644 --- a/src/lib/providers/huggingFace.ts +++ b/src/lib/providers/huggingFace.ts @@ -21,6 +21,7 @@ import type { StreamResult, } from "../types/index.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { createHuggingFaceConfig, @@ -219,6 +220,15 @@ export class HuggingFaceProvider extends BaseProvider { this.telemetryHandler.getTelemetryConfig(options), experimental_repairToolCall: this.getToolCallRepairFn(options), onStepFinish: ({ toolCalls, toolResults }) => { + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); this.handleToolExecutionStorage( toolCalls, toolResults, diff --git a/src/lib/providers/litellm.ts b/src/lib/providers/litellm.ts index 4b5cade91..4bc68b8d1 100644 --- a/src/lib/providers/litellm.ts +++ b/src/lib/providers/litellm.ts @@ -32,6 +32,7 @@ import { RateLimitError, } from "../types/index.js"; import { isAbortError } from "../utils/errorHandling.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { calculateCost } from "../utils/pricing.js"; import { getProviderModel } from "../utils/providerConfig.js"; @@ -307,6 +308,15 @@ export class LiteLLMProvider extends BaseProvider { }, onStepFinish: ({ toolCalls, toolResults }) => { + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); logger.info("Tool execution completed", { toolResults, toolCalls }); for (const toolCall of toolCalls) { diff --git a/src/lib/providers/mistral.ts b/src/lib/providers/mistral.ts index 929b838a3..ddd1d5f46 100644 --- a/src/lib/providers/mistral.ts +++ b/src/lib/providers/mistral.ts @@ -14,6 +14,7 @@ import type { ValidationSchema, } from "../types/index.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { createMistralConfig, @@ -121,6 +122,15 @@ export class MistralProvider extends BaseProvider { this.telemetryHandler.getTelemetryConfig(options), experimental_repairToolCall: this.getToolCallRepairFn(options), onStepFinish: ({ toolCalls, toolResults }) => { + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); this.handleToolExecutionStorage( toolCalls, toolResults, diff --git a/src/lib/providers/ollama.ts b/src/lib/providers/ollama.ts index 088d0ca97..8b331d0c2 100644 --- a/src/lib/providers/ollama.ts +++ b/src/lib/providers/ollama.ts @@ -7,16 +7,17 @@ import { modelConfig } from "../core/modelConfiguration.js"; import { createProxyFetch } from "../proxy/proxyFetch.js"; import type { JsonValue, - StreamOptions, - StreamResult, - ToolArgs, - ZodUnknownSchema, MessageContent, MultimodalChatMessage, OllamaAsLanguageModel, + OllamaHttpError, OllamaMessage, OllamaToolCall, OllamaToolResult, + StreamOptions, + StreamResult, + ToolArgs, + ZodUnknownSchema, } from "../types/index.js"; import { logger } from "../utils/logger.js"; import { buildMultimodalMessagesArray } from "../utils/messageBuilder.js"; @@ -28,6 +29,7 @@ import { ProviderError, } from "../types/index.js"; import { tracers, ATTR, withClientSpan } from "../telemetry/index.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { TimeoutError } from "../utils/timeout.js"; // Model version constants (configurable via environment) @@ -68,12 +70,6 @@ const getOllamaTimeout = (): number => { return parseInt(process.env.OLLAMA_TIMEOUT || "240000", 10); }; -type OllamaHttpError = ProviderError & { - statusCode: number; - statusText: string; - responseBody: string; -}; - function isOllamaHttpError(error: unknown): error is OllamaHttpError { return ( error instanceof ProviderError && @@ -955,7 +951,16 @@ export class OllamaProvider extends BaseProvider { }); } + // Capture instance references before the stream for use in the finally block. + const ollamaNeurolink = this.neurolink; + const ollamaProviderName = this.providerName; + const ollamaModelName = this.modelName || FALLBACK_OLLAMA_MODEL; + // Conversation loop for multi-step tool execution + let totalInputTokens = 0; + let totalOutputTokens = 0; + let lastFinishReason: string | undefined; + let ollamaStreamErrored = false; const stream = new ReadableStream({ start: async (controller) => { try { @@ -964,7 +969,8 @@ export class OllamaProvider extends BaseProvider { `[OllamaProvider] Conversation iteration ${iteration + 1}/${maxIterations}`, ); - // Make API request + // Make API request — request usage in stream_options so + // Pipeline B gets real token counts for Langfuse cost dashboards. const response = await proxyFetch( `${this.baseUrl}/v1/chat/completions`, { @@ -976,6 +982,7 @@ export class OllamaProvider extends BaseProvider { tools: ollamaTools, tool_choice: "auto", stream: true, + stream_options: { include_usage: true }, temperature: options.temperature, max_tokens: options.maxTokens, }), @@ -990,9 +997,18 @@ export class OllamaProvider extends BaseProvider { } // Process response stream - const { content, toolCalls, finishReason } = + const { content, toolCalls, finishReason, usage } = await this.processOllamaResponse(response, controller); + // Accumulate usage across iterations for Pipeline B + if (usage) { + totalInputTokens += usage.input; + totalOutputTokens += usage.output; + } + if (finishReason) { + lastFinishReason = finishReason; + } + // Add assistant message to history const assistantMessage: OllamaMessage = { role: "assistant", @@ -1063,6 +1079,7 @@ export class OllamaProvider extends BaseProvider { } if (iteration >= maxIterations) { + ollamaStreamErrored = true; controller.error( new Error( `Ollama conversation exceeded maximum iterations (${maxIterations})`, @@ -1070,23 +1087,55 @@ export class OllamaProvider extends BaseProvider { ); } } catch (error) { + ollamaStreamErrored = true; controller.error(error); } finally { - // Resolve analytics with final values now that the loop has completed. + // Resolve analytics with accumulated token counts so Pipeline A + // and Pipeline B both get real usage data from Ollama. + const aggregatedUsage = { + input: totalInputTokens, + output: totalOutputTokens, + total: totalInputTokens + totalOutputTokens, + }; resolveAnalytics( createAnalytics( this.providerName, this.modelName || FALLBACK_OLLAMA_MODEL, - { usage: { input: 0, output: 0, total: 0 } }, + { usage: aggregatedUsage }, Date.now() - startTime, { requestId: `ollama-stream-${Date.now()}`, streamingMode: true, iterations: iteration, - note: "Token usage not available from Ollama streaming responses", }, ), ); + // Emit generation:end so Pipeline B (Langfuse) creates a GENERATION + // observation. Ollama bypasses the Vercel AI SDK so + // experimental_telemetry is never injected; we emit manually. + const ollamaEmitter = ollamaNeurolink?.getEventEmitter(); + if (ollamaEmitter) { + // Collect accumulated text from conversation history + const accumulatedContent = conversationHistory + .filter((m) => m.role === "assistant") + .map((m) => m.content) + .join(""); + ollamaEmitter.emit("generation:end", { + provider: ollamaProviderName, + responseTime: Date.now() - startTime, + timestamp: Date.now(), + result: { + content: accumulatedContent, + usage: aggregatedUsage, + model: ollamaModelName, + provider: ollamaProviderName, + finishReason: ollamaStreamErrored + ? "error" + : (lastFinishReason ?? "stop"), + }, + success: !ollamaStreamErrored, + }); + } } }, }); @@ -1422,6 +1471,7 @@ export class OllamaProvider extends BaseProvider { content?: string; toolCalls?: OllamaToolCall[]; finishReason?: string; + usage?: { input: number; output: number; total: number }; }> { const reader = response.body?.getReader(); if (!reader) { @@ -1433,6 +1483,9 @@ export class OllamaProvider extends BaseProvider { let aggregatedContent = ""; let aggregatedToolCalls: OllamaToolCall[] = []; let finalFinishReason: string | undefined; + let finalUsage: + | { input: number; output: number; total: number } + | undefined; try { while (true) { @@ -1454,6 +1507,27 @@ export class OllamaProvider extends BaseProvider { try { const parsed = JSON.parse(dataLine); + // OpenAI-compatible usage chunk (Ollama may include usage + // in the final chunk when stream_options.include_usage is set, + // or as a standalone chunk with empty choices). + const parsedUsage = ( + parsed as { + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + } + ).usage; + if (parsedUsage) { + const input = parsedUsage.prompt_tokens ?? 0; + const output = parsedUsage.completion_tokens ?? 0; + finalUsage = { + input, + output, + total: parsedUsage.total_tokens ?? input + output, + }; + } const processed = this.processOllamaStreamData(parsed); if (!processed) { @@ -1498,6 +1572,7 @@ export class OllamaProvider extends BaseProvider { toolCalls: aggregatedToolCalls.length > 0 ? aggregatedToolCalls : undefined, finishReason: finalFinishReason, + usage: finalUsage, }; } @@ -1887,6 +1962,23 @@ export class OllamaProvider extends BaseProvider { } } + // Emit tool:end for each completed tool result so Pipeline B + // captures telemetry for Ollama-driven tool calls (gap S2). + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResultsForStorage.map((tr) => { + const hasError = + tr.result && typeof tr.result === "object" && "error" in tr.result; + return { + toolName: tr.toolName, + result: tr.result, + error: hasError + ? String((tr.result as Record).error) + : undefined, + }; + }), + ); + // Store tool executions (similar to Bedrock) this.handleToolExecutionStorage( toolCallsForStorage, diff --git a/src/lib/providers/openAI.ts b/src/lib/providers/openAI.ts index e32453034..2e21466bc 100644 --- a/src/lib/providers/openAI.ts +++ b/src/lib/providers/openAI.ts @@ -1,5 +1,5 @@ import { createOpenAI } from "@ai-sdk/openai"; -import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; +import { type Span, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api"; import { embed, embedMany, @@ -44,6 +44,7 @@ import { TimeoutError, } from "../utils/timeout.js"; import { resolveToolChoice } from "../utils/toolChoice.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { getModelId } from "./providerTypeUtils.js"; /** @@ -224,6 +225,18 @@ export class OpenAIProvider extends BaseProvider { * Validate tool structure for OpenAI compatibility * More lenient validation to avoid filtering out valid tools */ + /** Shared helper: mark a stream span as ERROR, record the exception, and end it. */ + private endStreamSpanWithError(span: Span, error: unknown): void { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + if (error instanceof Error) { + span.recordException(error); + } + span.end(); + } + private isValidToolStructure(tool: unknown): boolean { if (!tool || typeof tool !== "object") { return false; @@ -483,6 +496,18 @@ export class OpenAIProvider extends BaseProvider { toolCalls, }); + // Emit tool:end for each completed tool result so Pipeline B + // captures telemetry for AI-SDK-driven tool calls (gap S2). + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); + // Handle tool execution storage this.handleToolExecutionStorage( toolCalls, @@ -498,7 +523,7 @@ export class OpenAIProvider extends BaseProvider { }, }); } catch (streamError) { - streamSpan.end(); + this.endStreamSpanWithError(streamSpan, streamError); throw streamError; } @@ -541,11 +566,7 @@ export class OpenAIProvider extends BaseProvider { streamSpan.end(); }) .catch((err: unknown) => { - streamSpan.setStatus({ - code: SpanStatusCode.ERROR, - message: err instanceof Error ? err.message : String(err), - }); - streamSpan.end(); + this.endStreamSpanWithError(streamSpan, err); }); timeoutController?.cleanup(); diff --git a/src/lib/providers/openRouter.ts b/src/lib/providers/openRouter.ts index 35f42efff..60347670b 100644 --- a/src/lib/providers/openRouter.ts +++ b/src/lib/providers/openRouter.ts @@ -25,6 +25,7 @@ import type { StreamTextResult, } from "../types/index.js"; import { isAbortError } from "../utils/errorHandling.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { getProviderModel } from "../utils/providerConfig.js"; import { @@ -394,6 +395,15 @@ export class OpenRouterProvider extends BaseProvider { }, onStepFinish: ({ toolCalls, toolResults }) => { + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); logger.info("Tool execution completed", { toolCallCount: toolCalls?.length || 0, toolResultCount: toolResults?.length || 0, diff --git a/src/lib/providers/openaiCompatible.ts b/src/lib/providers/openaiCompatible.ts index 274f75fa8..0d9a898cc 100644 --- a/src/lib/providers/openaiCompatible.ts +++ b/src/lib/providers/openaiCompatible.ts @@ -22,6 +22,7 @@ import type { ZodUnknownSchema, } from "../types/index.js"; +import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js"; import { logger } from "../utils/logger.js"; import { composeAbortSignals, @@ -293,6 +294,15 @@ export class OpenAICompatibleProvider extends BaseProvider { this.telemetryHandler.getTelemetryConfig(options), experimental_repairToolCall: this.getToolCallRepairFn(options), onStepFinish: (event: StepFinishEvent) => { + emitToolEndFromStepFinish( + this.neurolink?.getEventEmitter(), + event.toolResults as Array<{ + toolName: string; + output?: unknown; + result?: unknown; + error?: string; + }>, + ); this.handleToolExecutionStorage( [...event.toolCalls], [...event.toolResults], diff --git a/src/lib/providers/sagemaker/detection.ts b/src/lib/providers/sagemaker/detection.ts index 8fde91d7c..c8b999b03 100644 --- a/src/lib/providers/sagemaker/detection.ts +++ b/src/lib/providers/sagemaker/detection.ts @@ -6,78 +6,23 @@ */ import type { + DetectionTestConfig, + EndpointHealth, + InvokeEndpointResponse, + ModelDetectionResult, + ParallelDetectionConfig, SageMakerConfig, SageMakerModelConfig, - InvokeEndpointResponse, StreamingCapability, } from "../../types/index.js"; import { SageMakerRuntimeClient } from "./client.js"; import { logger } from "../../utils/logger.js"; + /** * Configurable constants for detection timing and performance */ const DETECTION_STAGGER_DELAY_MS = 25; // Delay between staggered test starts (ms) const DETECTION_RATE_LIMIT_BACKOFF_MS = 200; // Initial backoff on rate limit detection (ms) -/** - * Model type detection result - */ -type ModelDetectionResult = { - /** Primary model type */ - type: StreamingCapability["modelType"]; - /** Detection confidence (0-1) */ - confidence: number; - /** Evidence used for detection */ - evidence: string[]; - /** Suggested configuration */ - suggestedConfig?: Partial; -}; - -/** - * Endpoint health and metadata information - */ -type EndpointHealth = { - /** Health status */ - status: "healthy" | "unhealthy" | "unknown"; - /** Response time in milliseconds */ - responseTime: number; - /** Endpoint metadata if available */ - metadata?: Record; - /** Model information if discoverable */ - modelInfo?: { - name?: string; - version?: string; - framework?: string; - architecture?: string; - }; -}; - -/** - * Configuration object for detection test wrapper - * Replaces multiple callback parameters for better maintainability - */ -type DetectionTestConfig = { - test: () => Promise; - index: number; - testName: string; - endpointName: string; - semaphore: { - acquire(): Promise; - release(): void; - }; - incrementRateLimit: () => void; - maxRateLimitRetries: number; - rateLimitState: { count: number }; // Use mutable object to prevent closure issues -}; - -/** - * Configuration object for parallel detection test execution - * Centralizes rate limiting and execution parameters - */ -type ParallelDetectionConfig = { - maxConcurrentTests: number; - maxRateLimitRetries: number; - initialRateLimitCount: number; -}; /** * SageMaker Model Detection and Capability Discovery Service diff --git a/src/lib/providers/sagemaker/diagnostics.ts b/src/lib/providers/sagemaker/diagnostics.ts index 53854f998..243728fb1 100644 --- a/src/lib/providers/sagemaker/diagnostics.ts +++ b/src/lib/providers/sagemaker/diagnostics.ts @@ -5,35 +5,10 @@ */ import chalk from "chalk"; +import type { DiagnosticReport, DiagnosticResult } from "../../types/index.js"; import { checkSageMakerConfiguration } from "./config.js"; import { createSageMakerProvider } from "./index.js"; -/** - * Simple diagnostic result interface - */ -type DiagnosticResult = { - name: string; - category: "configuration" | "connectivity" | "streaming"; - status: "pass" | "fail" | "warning"; - message: string; - details?: string; - recommendation?: string; -}; - -/** - * Diagnostic report interface - */ -type DiagnosticReport = { - overallStatus: "healthy" | "issues" | "critical"; - results: DiagnosticResult[]; - summary: { - total: number; - passed: number; - failed: number; - warnings: number; - }; -}; - /** * Run quick diagnostics for SageMaker configuration */ diff --git a/src/lib/providers/sagemaker/language-model.ts b/src/lib/providers/sagemaker/language-model.ts index 28902123e..43f99f93e 100644 --- a/src/lib/providers/sagemaker/language-model.ts +++ b/src/lib/providers/sagemaker/language-model.ts @@ -11,27 +11,16 @@ import { SageMakerRuntimeClient } from "./client.js"; import { handleSageMakerError } from "./errors.js"; import { estimateTokenUsage, createSageMakerStream } from "./streaming.js"; import type { + ConnectivityResult, SageMakerAsLanguageModel, SageMakerConfig, SageMakerModelConfig, - ConnectivityResult, + SageMakerOpenAIToolCall, UnknownRecord, } from "../../types/index.js"; import { createAdaptiveSemaphore } from "./adaptive-semaphore.js"; import { logger } from "../../utils/logger.js"; -/** - * Interface for SageMaker tool call results - */ -type SageMakerToolCall = { - type: "function"; - id: string; - function: { - name: string; - arguments: string; - }; -}; - /** * Base synthetic streaming delay in milliseconds for simulating real-time response * Can be configured via SAGEMAKER_BASE_STREAMING_DELAY_MS environment variable @@ -295,7 +284,7 @@ export class SageMakerLanguageModel implements SageMakerAsLanguageModel { rawCall: { rawPrompt: unknown; rawSettings: Record }; rawResponse?: { headers?: Record }; request?: { body?: string }; - toolCalls?: SageMakerToolCall[]; + toolCalls?: SageMakerOpenAIToolCall[]; object?: unknown; } = { text: generatedText, @@ -756,13 +745,13 @@ export class SageMakerLanguageModel implements SageMakerAsLanguageModel { */ private extractToolCallsFromResponse( responseBody: UnknownRecord, - ): SageMakerToolCall[] | undefined { + ): SageMakerOpenAIToolCall[] | undefined { // Handle OpenAI-compatible format (common for many SageMaker models) if (responseBody.choices && Array.isArray(responseBody.choices)) { const choice = responseBody.choices[0]; if (choice?.message?.tool_calls) { return choice.message.tool_calls.map( - (toolCall: UnknownRecord): SageMakerToolCall => ({ + (toolCall: UnknownRecord): SageMakerOpenAIToolCall => ({ type: "function", id: String(toolCall.id || `call_${randomUUID()}`), function: { @@ -776,7 +765,7 @@ export class SageMakerLanguageModel implements SageMakerAsLanguageModel { // Handle custom SageMaker tool call format if (responseBody.tool_calls && Array.isArray(responseBody.tool_calls)) { - return responseBody.tool_calls as SageMakerToolCall[]; + return responseBody.tool_calls as SageMakerOpenAIToolCall[]; } // Handle Anthropic-style tool use @@ -786,7 +775,7 @@ export class SageMakerLanguageModel implements SageMakerAsLanguageModel { ); if (toolUses.length > 0) { return toolUses.map( - (toolUse: UnknownRecord): SageMakerToolCall => ({ + (toolUse: UnknownRecord): SageMakerOpenAIToolCall => ({ type: "function", id: String(toolUse.id || `call_${randomUUID()}`), function: { diff --git a/src/lib/proxy/proxyConfig.ts b/src/lib/proxy/proxyConfig.ts index 076677c25..2f775aa1e 100644 --- a/src/lib/proxy/proxyConfig.ts +++ b/src/lib/proxy/proxyConfig.ts @@ -22,6 +22,7 @@ import type { ProxyAccountConfig, ProxyConfigFile, ProxyRoutingConfig, + YamlModule, } from "../types/index.js"; // --------------------------------------------------------------------------- @@ -177,10 +178,6 @@ function failOnUnresolvedAccountCredentials(obj: unknown): void { // --------------------------------------------------------------------------- /** Shape of the dynamically-imported `js-yaml` module. */ -type YamlModule = { - load(content: string): unknown; - default?: { load(content: string): unknown }; -}; /** * Parse YAML content into a JS object. diff --git a/src/lib/proxy/proxyEnv.ts b/src/lib/proxy/proxyEnv.ts index 0ed2d8e37..4af065c73 100644 --- a/src/lib/proxy/proxyEnv.ts +++ b/src/lib/proxy/proxyEnv.ts @@ -1,26 +1,11 @@ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; - -type ProxyEnvSource = "cli" | "environment" | "default" | "none"; - -type ProxyEnvResolution = { - path?: string; - source: ProxyEnvSource; - required: boolean; -}; - -type ProxyEnvLoadResult = { - loaded: boolean; - path?: string; - source: ProxyEnvSource; -}; - -type ProxyEnvOptions = { - explicitEnvFile?: string; - env?: NodeJS.ProcessEnv; - homeDir?: string; -}; +import type { + ProxyEnvLoadResult, + ProxyEnvOptions, + ProxyEnvResolution, +} from "../types/index.js"; export function resolveProxyEnvFile( options: ProxyEnvOptions = {}, diff --git a/src/lib/proxy/proxyFetch.ts b/src/lib/proxy/proxyFetch.ts index 84e8f39f8..bbe1d995f 100644 --- a/src/lib/proxy/proxyFetch.ts +++ b/src/lib/proxy/proxyFetch.ts @@ -9,15 +9,13 @@ import { SpanStatusCode, propagation, context } from "@opentelemetry/api"; import { tracers } from "../telemetry/tracers.js"; import type { ProxyAgent } from "undici"; import { shouldBypassProxy } from "./utils/noProxyUtils.js"; -import type { ParsedProxyConfig } from "../types/index.js"; +import type { + LangfuseContext, + ParsedProxyConfig, + ProxyEnvironmentSnapshot, +} from "../types/index.js"; import { createHash } from "node:crypto"; -type LangfuseContext = { - sessionId?: string | null; - userId?: string | null; - conversationId?: string | null; -}; - async function getLangfuseContext(): Promise { try { // Dynamic import to avoid hard dependency — getLangfuseContext is only @@ -409,14 +407,6 @@ async function createProxyAgent(proxyUrl: string): Promise { } } -type ProxyEnvironmentSnapshot = { - httpsProxy?: string; - httpProxy?: string; - allProxy?: string; - socksProxy?: string; - noProxy?: string; -}; - function sanitizeProxyUrl(url: string | undefined): string { return maskProxyUrl(url) ?? "NOT_SET"; } diff --git a/src/lib/proxy/proxyTracer.ts b/src/lib/proxy/proxyTracer.ts index 3038ebd24..8b6e89b4d 100644 --- a/src/lib/proxy/proxyTracer.ts +++ b/src/lib/proxy/proxyTracer.ts @@ -15,8 +15,6 @@ */ import { - type Counter, - type Histogram, type Meter, type Span, SpanStatusCode, @@ -32,6 +30,13 @@ import { OtelBridge } from "../observability/otelBridge.js"; import { calculateCost } from "../utils/pricing.js"; import { TelemetryService } from "../telemetry/telemetryService.js"; import { logger } from "../utils/logger.js"; +import type { + AccountSelectionContext, + ProxyMetrics, + ProxyRequestContext, + UpstreamAttemptContext, + UsageContext, +} from "../types/index.js"; const LOG_PREFIX = "[ProxyTracer]"; @@ -45,25 +50,6 @@ const LOG_PREFIX = "[ProxyTracer]"; // point the MeterProvider is guaranteed to be registered. // --------------------------------------------------------------------------- -type ProxyMetrics = { - requestsTotal: Counter; - requestDuration: Histogram; - tokensInput: Counter; - tokensOutput: Counter; - tokensCacheRead: Counter; - tokensCacheCreation: Counter; - tokensReasoning: Counter; - costTotal: Counter; - errorsTotal: Counter; - retriesTotal: Counter; - modelSubstitutionTotal: Counter; - requestBodySize: Histogram; - responseBodySize: Histogram; - fallbackAttemptsTotal: Counter; - fallbackSuccessTotal: Counter; - fallbackFailureTotal: Counter; -}; - let _metrics: ProxyMetrics | null = null; function getProxyMetrics(): ProxyMetrics { @@ -151,50 +137,6 @@ function getProxyMetrics(): ProxyMetrics { return createdMetrics; } -// --------------------------------------------------------------------------- -// Context types -// --------------------------------------------------------------------------- - -type ProxyRequestContext = { - requestId: string; - method: string; - path: string; - model: string; - stream: boolean; - toolCount: number; - sessionId?: string; - userAgent?: string; - clientApp?: string; -}; - -type AccountSelectionContext = { - strategy: string; - accountsTotal: number; - accountsHealthy: number; - selectedAccount: string; - accountType: string; - rateLimitBefore5h?: number; - rateLimitBefore7d?: number; -}; - -type UpstreamAttemptContext = { - attempt: number; - account: string; - polyfillHeaders: boolean; - polyfillBody: boolean; - upstreamUrl: string; -}; - -type UsageContext = { - inputTokens: number; - outputTokens: number; - cacheCreationTokens: number; - cacheReadTokens: number; - reasoningTokens?: number; - rateLimitAfter5h?: number; - rateLimitAfter7d?: number; -}; - // --------------------------------------------------------------------------- // Header redaction (mirrors requestLogger.ts patterns) // --------------------------------------------------------------------------- diff --git a/src/lib/proxy/quietDetector.ts b/src/lib/proxy/quietDetector.ts index 2854025f7..8b9fe8c02 100644 --- a/src/lib/proxy/quietDetector.ts +++ b/src/lib/proxy/quietDetector.ts @@ -8,13 +8,7 @@ import { openSync, readSync, closeSync, fstatSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; - -/** Result of a traffic-quiet check. */ -type QuietStatus = { - isQuiet: boolean; - lastActivityAt: Date | null; - silenceDurationMs: number; -}; +import type { QuietStatus } from "../types/index.js"; /** Default quiet threshold: 2 minutes of no traffic. */ const DEFAULT_QUIET_THRESHOLD_MS = 120_000; diff --git a/src/lib/proxy/rawStreamCapture.ts b/src/lib/proxy/rawStreamCapture.ts index eb90ac92e..697a61e46 100644 --- a/src/lib/proxy/rawStreamCapture.ts +++ b/src/lib/proxy/rawStreamCapture.ts @@ -1,13 +1,7 @@ -type RawStreamCapture = { - totalBytes: number; - text: string; - truncated: boolean; -}; - -type RawStreamCaptureResult = { - stream: TransformStream; - capture: Promise; -}; +import type { + RawStreamCapture, + RawStreamCaptureResult, +} from "../types/index.js"; /** Maximum bytes to capture before stopping accumulation (1 MB). */ const MAX_CAPTURE_BYTES = 1024 * 1024; diff --git a/src/lib/proxy/requestLogger.ts b/src/lib/proxy/requestLogger.ts index c58182803..fdfe3b596 100644 --- a/src/lib/proxy/requestLogger.ts +++ b/src/lib/proxy/requestLogger.ts @@ -23,8 +23,11 @@ import { createHash } from "crypto"; import { promisify } from "util"; import { gzip as gzipCallback } from "zlib"; import type { + ManagedLogFile, + ProxyBodyCaptureEntry, RequestAttemptLogEntry, RequestLogEntry, + StoredBodyArtifact, } from "../types/index.js"; import { OtelBridge } from "../observability/otelBridge.js"; import { SeverityNumber } from "@opentelemetry/api-logs"; @@ -303,35 +306,6 @@ function redactHeaders( return redacted; } -type ProxyBodyCaptureEntry = { - timestamp: string; - requestId: string; - phase: string; - model: string; - stream: boolean; - headers?: Record; - body?: unknown; - bodySize?: number; - contentType?: string; - responseStatus?: number; - durationMs?: number; - account?: string; - accountType?: string; - attempt?: number; - traceId?: string; - spanId?: string; - metadata?: Record; -}; - -type StoredBodyArtifact = { - bodyPath?: string; - bodySha256?: string; - redactedBodyBytes?: number; - storedFileBytes?: number; - redactedBody?: string; - bodyTruncated?: boolean; -}; - function serializeBody(body: unknown): string | undefined { if (body === undefined || body === null) { return undefined; @@ -438,12 +412,6 @@ function prepareRedactedBody(body: unknown): { return truncateUtf8String(redacted, MAX_CAPTURED_BODY_BYTES); } -type ManagedLogFile = { - path: string; - mtime: number; - size: number; -}; - function collectManagedLogFiles(rootDir: string): ManagedLogFile[] { const managedFiles: ManagedLogFile[] = []; diff --git a/src/lib/proxy/sseInterceptor.ts b/src/lib/proxy/sseInterceptor.ts index 585ad4ee7..877d3e409 100644 --- a/src/lib/proxy/sseInterceptor.ts +++ b/src/lib/proxy/sseInterceptor.ts @@ -16,64 +16,13 @@ * const data = await telemetry; // resolves on stream end */ -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type SSEContentBlock = { - index: number; - type: "text" | "thinking" | "tool_use" | "tool_result"; - /** Accumulated text for text blocks. Capped at MAX_BLOCK_CONTENT_BYTES. */ - text?: string; - /** Accumulated thinking content. Capped at MAX_BLOCK_CONTENT_BYTES. */ - thinking?: string; - /** Tool name for tool_use blocks. */ - toolName?: string; - /** Tool call id for tool_use blocks. */ - toolId?: string; - /** Accumulated partial JSON input for tool_use blocks. Capped at MAX_BLOCK_CONTENT_BYTES. */ - toolInput?: string; -}; - -type SSETelemetry = { - /** Message id from message_start. */ - messageId: string; - /** Model string from message_start. */ - model: string; - - /** Token usage aggregated from message_start + message_delta. */ - usage: { - inputTokens: number; - outputTokens: number; - cacheCreationInputTokens: number; - cacheReadInputTokens: number; - totalTokens: number; - }; - - /** All content blocks accumulated during the stream. */ - contentBlocks: SSEContentBlock[]; - - /** Stop reason from message_delta, e.g. "end_turn". */ - stopReason: string | null; - /** Stop sequence from message_delta, if any. */ - stopSequence: string | null; - /** Total number of SSE events observed. */ - eventCount: number; - /** Wall-clock duration from first byte to stream end (ms). */ - streamDurationMs: number; - /** Total bytes received from upstream (raw SSE stream size). */ - totalBytesReceived: number; - - /** - * Raw SSE event log. For content_block_delta events only the type is - * stored (not the full data payload) to avoid excessive memory use. - * All other events store the full data string. - */ - events: Array<{ type: string; timestamp: number; data: string }>; - - /** Full raw SSE transcript, when captureRawText is enabled. */ - rawText?: string; -}; +import type { + SSEContentBlock, + SSEInterceptorOptions, + SSEInterceptorResult, + SSETelemetry, + TelemetryAccumulator, +} from "../types/index.js"; // --------------------------------------------------------------------------- // Constants @@ -147,28 +96,6 @@ function extractSSEEvents(buffer: string): { // Telemetry accumulator // --------------------------------------------------------------------------- -type TelemetryAccumulator = { - messageId: string; - model: string; - inputTokens: number; - outputTokens: number; - cacheCreationInputTokens: number; - cacheReadInputTokens: number; - contentBlocks: SSEContentBlock[]; - /** Tracks accumulated byte length per block index to enforce the cap. */ - blockByteCounts: Map; - stopReason: string | null; - stopSequence: string | null; - eventCount: number; - startTime: number; - totalBytesReceived: number; - events: Array<{ type: string; timestamp: number; data: string }>; - rawTextChunks?: string[]; - rawTextBytes: number; - rawTextTruncated: boolean; - eventLogTruncated: boolean; -}; - function createAccumulator(captureRawText: boolean): TelemetryAccumulator { return { messageId: "", @@ -505,21 +432,6 @@ function processEvent( // Public API // --------------------------------------------------------------------------- -type SSEInterceptorResult = { - /** Pipe the upstream response through this stream. */ - stream: TransformStream; - /** - * Resolves with the accumulated telemetry when the stream finishes. - * If the stream errors, the promise resolves with whatever telemetry - * was gathered up to that point (never rejects). - */ - telemetry: Promise; -}; - -type SSEInterceptorOptions = { - captureRawText?: boolean; -}; - /** * Create an SSE interceptor that extracts telemetry from an Anthropic * streaming response while passing all bytes through unmodified. diff --git a/src/lib/proxy/updateChecker.ts b/src/lib/proxy/updateChecker.ts index 9e64f7ea3..47b5f12ac 100644 --- a/src/lib/proxy/updateChecker.ts +++ b/src/lib/proxy/updateChecker.ts @@ -10,32 +10,13 @@ import { execFile as execFileCb } from "node:child_process"; import { promisify } from "node:util"; import { logger } from "../utils/logger.js"; +import type { SemVer, UpdateCheckResult } from "../types/index.js"; const execFile = promisify(execFileCb); /** Timeout (ms) for the `npm view` child process. */ const NPM_VIEW_TIMEOUT_MS = 10_000; -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - -type UpdateCheckResult = { - currentVersion: string; - latestVersion: string; - updateAvailable: boolean; -}; - -// --------------------------------------------------------------------------- -// Semver helpers (no external dependency) -// --------------------------------------------------------------------------- - -type SemVer = { - major: number; - minor: number; - patch: number; -}; - /** * Parse a version string of the form `major.minor.patch` into numeric * components. Returns `null` when the string does not match. diff --git a/src/lib/proxy/updateState.ts b/src/lib/proxy/updateState.ts index dd199d743..bbe48a135 100644 --- a/src/lib/proxy/updateState.ts +++ b/src/lib/proxy/updateState.ts @@ -10,23 +10,7 @@ import fs from "fs"; import os from "os"; import path from "path"; - -// ============================================ -// Types -// ============================================ - -type SuppressedVersion = { - suppressedAt: string; // ISO timestamp - reason: string; // e.g., "unhealthy_after_restart" -}; - -type UpdateState = { - lastCheckAt: string; // ISO timestamp - lastCheckVersion: string; // Latest version found - suppressedVersions: Record; - lastUpdateAt: string | null; - lastUpdateVersion: string | null; -}; +import type { UpdateState } from "../types/index.js"; // ============================================ // Constants diff --git a/src/lib/rag/chunkers/BaseChunker.ts b/src/lib/rag/chunkers/BaseChunker.ts index 0feb95dee..f54e83252 100644 --- a/src/lib/rag/chunkers/BaseChunker.ts +++ b/src/lib/rag/chunkers/BaseChunker.ts @@ -14,6 +14,8 @@ import type { ChunkingStrategy, ChunkMetadata, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; /** * Default chunker configuration @@ -83,33 +85,48 @@ export abstract class BaseChunker implements Chunker { * Chunk content into smaller pieces */ async chunk(content: string, config?: ChunkerConfig): Promise { - const effectiveConfig = { ...this.config, ...config }; - - if (!content || content.trim().length === 0) { - throw new ChunkingError("Content is empty", { - code: RAGErrorCodes.CHUNKING_EMPTY_CONTENT, - strategy: this.strategy, - contentLength: 0, - }); - } - - try { - const chunks = await this.doChunk(content, effectiveConfig); - return this.filterChunks(chunks, effectiveConfig); - } catch (error) { - if (error instanceof ChunkingError) { - throw error; - } - throw new ChunkingError( - `Chunking failed: ${error instanceof Error ? error.message : String(error)}`, - { - code: RAGErrorCodes.CHUNKING_ERROR, - cause: error instanceof Error ? error : undefined, - strategy: this.strategy, - contentLength: content.length, + return withSpan( + { + name: "neurolink.rag.chunk", + tracer: tracers.rag, + attributes: { + "rag.chunker.strategy": this.strategy, + "rag.chunker.content_chars": content.length, + "rag.chunker.content_bytes": Buffer.byteLength(content, "utf8"), }, - ); - } + }, + async (span) => { + const effectiveConfig = { ...this.config, ...config }; + + if (!content || content.trim().length === 0) { + throw new ChunkingError("Content is empty", { + code: RAGErrorCodes.CHUNKING_EMPTY_CONTENT, + strategy: this.strategy, + contentLength: 0, + }); + } + + try { + const chunks = await this.doChunk(content, effectiveConfig); + const result = this.filterChunks(chunks, effectiveConfig); + span.setAttribute("rag.chunker.chunk_count", result.length); + return result; + } catch (error) { + if (error instanceof ChunkingError) { + throw error; + } + throw new ChunkingError( + `Chunking failed: ${error instanceof Error ? error.message : String(error)}`, + { + code: RAGErrorCodes.CHUNKING_ERROR, + cause: error instanceof Error ? error : undefined, + strategy: this.strategy, + contentLength: content.length, + }, + ); + } + }, + ); // end withSpan } /** diff --git a/src/lib/rag/chunking/jsonChunker.ts b/src/lib/rag/chunking/jsonChunker.ts index a2ccc6f67..3b0bf36b6 100644 --- a/src/lib/rag/chunking/jsonChunker.ts +++ b/src/lib/rag/chunking/jsonChunker.ts @@ -7,27 +7,14 @@ import { randomUUID } from "crypto"; import type { - Chunker, + BaseChunkerConfig, Chunk, + Chunker, ChunkerValidationResult, + ExtractChunksOptions, JSONChunkerConfig, - BaseChunkerConfig, } from "../../types/index.js"; -/** - * Options for extractChunks method - */ -type ExtractChunksOptions = { - data: unknown; - path: string; - depth: number; - maxDepth: number; - maxSize: number; - splitKeys: string[]; - preserveKeys: string[]; - includeJsonPath: boolean; -}; - /** * JSON-aware chunker implementation * Splits based on JSON structure diff --git a/src/lib/rag/document/MDocument.ts b/src/lib/rag/document/MDocument.ts index 10046114b..2562ae3d7 100644 --- a/src/lib/rag/document/MDocument.ts +++ b/src/lib/rag/document/MDocument.ts @@ -26,31 +26,14 @@ import { LLMMetadataExtractor } from "../metadata/metadataExtractor.js"; import type { BaseChunkerConfig, Chunk, - ChunkingStrategy, ChunkParams, + ChunkingStrategy, + DocumentState, DocumentType, ExtractParams, MDocumentConfig, } from "../../types/index.js"; -/** - * Document processing state - */ -type DocumentState = { - /** Raw document content */ - content: string; - /** Document type */ - type: DocumentType; - /** Document metadata */ - metadata: Record; - /** Generated chunks (after chunking) */ - chunks: Chunk[]; - /** Document embeddings (after embedding) */ - embeddings: number[][]; - /** Processing history */ - history: string[]; -}; - /** * MDocument class for comprehensive document processing * diff --git a/src/lib/rag/errors/RAGError.ts b/src/lib/rag/errors/RAGError.ts index 06e7174ff..1440ebe39 100644 --- a/src/lib/rag/errors/RAGError.ts +++ b/src/lib/rag/errors/RAGError.ts @@ -10,6 +10,7 @@ import { NeuroLinkFeatureError, createErrorFactory, } from "../../core/infrastructure/index.js"; +import type { RAGErrorCode } from "../../types/index.js"; /** * RAG error codes for all RAG-related operations @@ -66,8 +67,6 @@ export const RAGErrorCodes = { INVALID_CONFIGURATION: "RAG_INVALID_CONFIGURATION", } as const; -type RAGErrorCode = (typeof RAGErrorCodes)[keyof typeof RAGErrorCodes]; - /** * RAG error factory using the infrastructure pattern */ diff --git a/src/lib/rag/ragIntegration.ts b/src/lib/rag/ragIntegration.ts index 2c753ec1b..9ad1ebe7e 100644 --- a/src/lib/rag/ragIntegration.ts +++ b/src/lib/rag/ragIntegration.ts @@ -29,6 +29,8 @@ import type { VectorQueryResult, RAGPreparedTool, } from "../types/index.js"; +import { withSpan } from "../telemetry/withSpan.js"; +import { tracers } from "../telemetry/tracers.js"; /** * Maps file extensions to recommended chunking strategies */ @@ -358,52 +360,69 @@ async function _prepareRAGToolInner( .describe("The search query to find relevant information"), }), execute: async ({ query }: { query: string }) => { - // For the in-memory store with simple embeddings, - // generate a query embedding using the same method - const queryEmbedding = generateSimpleEmbedding( - query, - EMBEDDING_DIMENSION, + return withSpan( + { + name: "neurolink.rag.search", + tracer: tracers.rag, + attributes: { + "rag.query_length": query ? String(query).length : 0, + "rag.top_k": topK ?? 5, + }, + }, + async (span) => { + // For the in-memory store with simple embeddings, + // generate a query embedding using the same method + const queryEmbedding = generateSimpleEmbedding( + query, + EMBEDDING_DIMENSION, + ); + + // Fetch more candidates than needed so diversity can select across files + const fetchK = fileContents.length > 1 ? topK * 3 : topK; + const rawResults = await vectorStore.query({ + indexName, + queryVector: queryEmbedding, + topK: fetchK, + }); + + // Apply source-file diversity for multi-file RAG + const results = + fileContents.length > 1 + ? diversifyResults(rawResults, topK) + : rawResults.slice(0, topK); + + if (results.length === 0) { + span.setAttribute("rag.results_count", 0); + return { + relevantContext: "No relevant documents found for the query.", + sources: [], + totalResults: 0, + }; + } + + const relevantContext = results + .map( + (r, i) => + `[${i + 1}] ${(r.metadata?.text as string) || r.text || ""}`, + ) + .join("\n\n"); + + span.setAttribute("rag.results_count", results.length); + return { + relevantContext, + sources: results.map((r) => ({ + id: r.id, + score: r.score, + source: r.metadata?.source, + text: ((r.metadata?.text as string) || r.text || "").slice( + 0, + 200, + ), + })), + totalResults: results.length, + }; + }, ); - - // Fetch more candidates than needed so diversity can select across files - const fetchK = fileContents.length > 1 ? topK * 3 : topK; - const rawResults = await vectorStore.query({ - indexName, - queryVector: queryEmbedding, - topK: fetchK, - }); - - // Apply source-file diversity for multi-file RAG - const results = - fileContents.length > 1 - ? diversifyResults(rawResults, topK) - : rawResults.slice(0, topK); - - if (results.length === 0) { - return { - relevantContext: "No relevant documents found for the query.", - sources: [], - totalResults: 0, - }; - } - - const relevantContext = results - .map( - (r, i) => - `[${i + 1}] ${(r.metadata?.text as string) || r.text || ""}`, - ) - .join("\n\n"); - - return { - relevantContext, - sources: results.map((r) => ({ - id: r.id, - score: r.score, - source: r.metadata?.source, - text: ((r.metadata?.text as string) || r.text || "").slice(0, 200), - })), - totalResults: results.length, - }; }, }; diff --git a/src/lib/rag/reranker/reranker.ts b/src/lib/rag/reranker/reranker.ts index 0be1f7283..0553aea9f 100644 --- a/src/lib/rag/reranker/reranker.ts +++ b/src/lib/rag/reranker/reranker.ts @@ -11,6 +11,8 @@ import type { RerankResult, AIProvider, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { logger } from "../../utils/logger.js"; /** @@ -42,84 +44,100 @@ export async function rerank( model: AIProvider, options?: RerankerOptions, ): Promise { - const { - queryEmbedding: _queryEmbedding, - topK = 3, - weights = DEFAULT_WEIGHTS, - } = options || {}; - - if (results.length === 0) { - return []; - } - - // Validate weights sum to 1.0 - const totalWeight = - (weights.semantic || DEFAULT_WEIGHTS.semantic) + - (weights.vector || DEFAULT_WEIGHTS.vector) + - (weights.position || DEFAULT_WEIGHTS.position); + return withSpan( + { + name: "neurolink.rag.rerank", + tracer: tracers.rag, + attributes: { + "rag.reranker.input_count": results.length, + "rag.reranker.top_k": options?.topK ?? 3, + "rag.reranker.query_length": query.length, + }, + }, + async (span) => { + const { + queryEmbedding: _queryEmbedding, + topK = 3, + weights = DEFAULT_WEIGHTS, + } = options || {}; + + if (results.length === 0) { + span.setAttribute("rag.reranker.output_count", 0); + return []; + } - if (Math.abs(totalWeight - 1.0) > 0.01) { - logger.warn("[Reranker] Weights do not sum to 1.0, normalizing", { - original: weights, - total: totalWeight, - }); - } + // Validate weights sum to 1.0 + const totalWeight = + (weights.semantic || DEFAULT_WEIGHTS.semantic) + + (weights.vector || DEFAULT_WEIGHTS.vector) + + (weights.position || DEFAULT_WEIGHTS.position); + + if (Math.abs(totalWeight - 1.0) > 0.01) { + logger.warn("[Reranker] Weights do not sum to 1.0, normalizing", { + original: weights, + total: totalWeight, + }); + } - const normalizedWeights = { - semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight, - vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight, - position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight, - }; - - const rerankedResults: RerankResult[] = []; - - // Process results in parallel batches for efficiency - const batchSize = 5; - for (let i = 0; i < results.length; i += batchSize) { - const batch = results.slice(i, i + batchSize); - const batchPromises = batch.map(async (result, batchIndex) => { - const globalIndex = i + batchIndex; - - // Calculate vector score (use existing score or 0) - const vectorScore = result.score ?? 0; - - // Calculate position score (inverse of position) - const positionScore = 1 - globalIndex / results.length; - - // Calculate semantic score using LLM - const semanticResult = await calculateSemanticScore( - query, - result.text || (result.metadata?.text as string) || "", - model, - ); - - // Combine scores - const combinedScore = - normalizedWeights.semantic * semanticResult.score + - normalizedWeights.vector * vectorScore + - normalizedWeights.position * positionScore; - - return { - result, - score: combinedScore, - details: { - semantic: semanticResult.score, - vector: vectorScore, - position: positionScore, - queryAnalysis: semanticResult.analysis, - }, + const normalizedWeights = { + semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight, + vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight, + position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight, }; - }); - const batchResults = await Promise.all(batchPromises); - rerankedResults.push(...batchResults); - } + const rerankedResults: RerankResult[] = []; + + // Process results in parallel batches for efficiency + const batchSize = 5; + for (let i = 0; i < results.length; i += batchSize) { + const batch = results.slice(i, i + batchSize); + const batchPromises = batch.map(async (result, batchIndex) => { + const globalIndex = i + batchIndex; + + // Calculate vector score (use existing score or 0) + const vectorScore = result.score ?? 0; + + // Calculate position score (inverse of position) + const positionScore = 1 - globalIndex / results.length; + + // Calculate semantic score using LLM + const semanticResult = await calculateSemanticScore( + query, + result.text || (result.metadata?.text as string) || "", + model, + ); + + // Combine scores + const combinedScore = + normalizedWeights.semantic * semanticResult.score + + normalizedWeights.vector * vectorScore + + normalizedWeights.position * positionScore; + + return { + result, + score: combinedScore, + details: { + semantic: semanticResult.score, + vector: vectorScore, + position: positionScore, + queryAnalysis: semanticResult.analysis, + }, + }; + }); + + const batchResults = await Promise.all(batchPromises); + rerankedResults.push(...batchResults); + } - // Sort by combined score descending - rerankedResults.sort((a, b) => b.score - a.score); + // Sort by combined score descending + rerankedResults.sort((a, b) => b.score - a.score); - // Return top K results - return rerankedResults.slice(0, topK); + // Return top K results + const output = rerankedResults.slice(0, topK); + span.setAttribute("rag.reranker.output_count", output.length); + return output; + }, + ); // end withSpan } /** @@ -187,33 +205,46 @@ export async function batchRerank( model: AIProvider, options?: RerankerOptions, ): Promise { - const { topK = 3, weights = DEFAULT_WEIGHTS } = options || {}; + return withSpan( + { + name: "neurolink.rag.batchRerank", + tracer: tracers.rag, + attributes: { + "rag.reranker.input_count": results.length, + "rag.reranker.top_k": options?.topK ?? 3, + "rag.reranker.query_length": query.length, + "rag.reranker.batch": true, + }, + }, + async (span) => { + const { topK = 3, weights = DEFAULT_WEIGHTS } = options || {}; - if (results.length === 0) { - return []; - } + if (results.length === 0) { + span.setAttribute("rag.reranker.output_count", 0); + return []; + } - // Normalize weights - const totalWeight = - (weights.semantic || DEFAULT_WEIGHTS.semantic) + - (weights.vector || DEFAULT_WEIGHTS.vector) + - (weights.position || DEFAULT_WEIGHTS.position); + // Normalize weights + const totalWeight = + (weights.semantic || DEFAULT_WEIGHTS.semantic) + + (weights.vector || DEFAULT_WEIGHTS.vector) + + (weights.position || DEFAULT_WEIGHTS.position); - const normalizedWeights = { - semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight, - vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight, - position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight, - }; + const normalizedWeights = { + semantic: (weights.semantic || DEFAULT_WEIGHTS.semantic) / totalWeight, + vector: (weights.vector || DEFAULT_WEIGHTS.vector) / totalWeight, + position: (weights.position || DEFAULT_WEIGHTS.position) / totalWeight, + }; - // Build batch scoring prompt - const documentsText = results - .map( - (r, i) => - `[${i + 1}] ${(r.text || (r.metadata?.text as string) || "").slice(0, 300)}`, - ) - .join("\n\n"); + // Build batch scoring prompt + const documentsText = results + .map( + (r, i) => + `[${i + 1}] ${(r.text || (r.metadata?.text as string) || "").slice(0, 300)}`, + ) + .join("\n\n"); - const prompt = `Rate the relevance of each document to the query on a scale of 0 to 1. + const prompt = `Rate the relevance of each document to the query on a scale of 0 to 1. Query: ${query} @@ -223,65 +254,72 @@ ${documentsText} For each document, provide a score between 0 and 1. Respond with only the scores, one per line, in order:`; - try { - const result = await model.generate({ - prompt, - maxTokens: 50, - temperature: 0, - }); - - // Parse scores from response - const scoreLines = (result?.content || "") - .trim() - .split("\n") - .map((line: string) => line.trim()) - .filter((line: string) => line.length > 0); - - const semanticScores: number[] = []; - for (let i = 0; i < results.length; i++) { - const scoreLine = scoreLines[i]; - if (scoreLine) { - const score = parseFloat(scoreLine.match(/[\d.]+/)?.[0] || "0.5"); - semanticScores.push( - isNaN(score) || score < 0 || score > 1 ? 0.5 : score, + try { + const result = await model.generate({ + prompt, + maxTokens: 50, + temperature: 0, + }); + + // Parse scores from response + const scoreLines = (result?.content || "") + .trim() + .split("\n") + .map((line: string) => line.trim()) + .filter((line: string) => line.length > 0); + + const semanticScores: number[] = []; + for (let i = 0; i < results.length; i++) { + const scoreLine = scoreLines[i]; + if (scoreLine) { + const score = parseFloat(scoreLine.match(/[\d.]+/)?.[0] || "0.5"); + semanticScores.push( + isNaN(score) || score < 0 || score > 1 ? 0.5 : score, + ); + } else { + semanticScores.push(0.5); + } + } + + // Calculate combined scores + const rerankedResults: RerankResult[] = results.map((result, i) => { + const vectorScore = result.score ?? 0; + const positionScore = 1 - i / results.length; + const semanticScore = semanticScores[i] ?? 0.5; + + const combinedScore = + normalizedWeights.semantic * semanticScore + + normalizedWeights.vector * vectorScore + + normalizedWeights.position * positionScore; + + return { + result, + score: combinedScore, + details: { + semantic: semanticScore, + vector: vectorScore, + position: positionScore, + }, + }; + }); + + // Sort and return top K + rerankedResults.sort((a, b) => b.score - a.score); + const output = rerankedResults.slice(0, topK); + span.setAttribute("rag.reranker.output_count", output.length); + return output; + } catch (error) { + logger.warn( + "[Reranker] Batch scoring failed, using individual scoring", + { + error: error instanceof Error ? error.message : String(error), + }, ); - } else { - semanticScores.push(0.5); + // Fall back to individual scoring + return rerank(results, query, model, options); } - } - - // Calculate combined scores - const rerankedResults: RerankResult[] = results.map((result, i) => { - const vectorScore = result.score ?? 0; - const positionScore = 1 - i / results.length; - const semanticScore = semanticScores[i] ?? 0.5; - - const combinedScore = - normalizedWeights.semantic * semanticScore + - normalizedWeights.vector * vectorScore + - normalizedWeights.position * positionScore; - - return { - result, - score: combinedScore, - details: { - semantic: semanticScore, - vector: vectorScore, - position: positionScore, - }, - }; - }); - - // Sort and return top K - rerankedResults.sort((a, b) => b.score - a.score); - return rerankedResults.slice(0, topK); - } catch (error) { - logger.warn("[Reranker] Batch scoring failed, using individual scoring", { - error: error instanceof Error ? error.message : String(error), - }); - // Fall back to individual scoring - return rerank(results, query, model, options); - } + }, + ); // end withSpan } /** diff --git a/src/lib/rag/resilience/CircuitBreaker.ts b/src/lib/rag/resilience/CircuitBreaker.ts index a2907ea67..1aeaa825f 100644 --- a/src/lib/rag/resilience/CircuitBreaker.ts +++ b/src/lib/rag/resilience/CircuitBreaker.ts @@ -10,22 +10,13 @@ import { TypedEventEmitter } from "../../core/infrastructure/index.js"; import { logger } from "../../utils/logger.js"; import { RAGCircuitBreakerError, RAGErrorCodes } from "../errors/RAGError.js"; import type { + CallRecord, CircuitState, RAGCircuitBreakerConfig, RAGCircuitBreakerEvents, RAGCircuitBreakerStats, } from "../../types/index.js"; -/** - * Call record for statistics - */ -type CallRecord = { - timestamp: number; - success: boolean; - duration: number; - operationType?: string; -}; - /** * Default configuration */ diff --git a/src/lib/rag/resilience/RetryHandler.ts b/src/lib/rag/resilience/RetryHandler.ts index b5c5eec28..e4f61cdf9 100644 --- a/src/lib/rag/resilience/RetryHandler.ts +++ b/src/lib/rag/resilience/RetryHandler.ts @@ -7,10 +7,7 @@ */ import { withRetry } from "../../core/infrastructure/index.js"; -import type { - AsyncRetryOptions as _RetryOptions, - RAGRetryConfig, -} from "../../types/index.js"; +import type { RAGRetryConfig } from "../../types/index.js"; import { isAbortError } from "../../utils/errorHandling.js"; import { logger } from "../../utils/logger.js"; import { diff --git a/src/lib/rag/retrieval/vectorQueryTool.ts b/src/lib/rag/retrieval/vectorQueryTool.ts index 0290c12fd..d8100dc60 100644 --- a/src/lib/rag/retrieval/vectorQueryTool.ts +++ b/src/lib/rag/retrieval/vectorQueryTool.ts @@ -8,6 +8,8 @@ import { randomUUID } from "crypto"; import { z } from "zod"; import { ProviderFactory } from "../../factories/providerFactory.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { logger } from "../../utils/logger.js"; import { rerank } from "../reranker/reranker.js"; import type { @@ -76,107 +78,123 @@ export function createVectorQueryTool( params: { query: string; filter?: MetadataFilter; topK?: number }, context?: RequestContext, ): Promise => { - const startTime = Date.now(); - - try { - // Resolve vector store if it's a function - const store: VectorStore = - typeof vectorStore === "function" - ? vectorStore(context || {}) - : vectorStore; - - // Generate query embedding - const embeddingProvider = await ProviderFactory.createProvider( - embeddingModel.provider, - embeddingModel.modelName, - ); - - // Check if provider has embed method - if ( - typeof (embeddingProvider as unknown as { embed?: unknown }).embed !== - "function" - ) { - throw new Error( - `Provider ${embeddingModel.provider} does not support embeddings`, - ); - } + return withSpan( + { + name: "neurolink.rag.vectorQuery", + tracer: tracers.rag, + attributes: { + "rag.vector.index": indexName, + "rag.vector.top_k": params.topK ?? topK, + "rag.vector.query_length": params.query.length, + }, + }, + async (span) => { + const startTime = Date.now(); + + try { + // Resolve vector store if it's a function + const store: VectorStore = + typeof vectorStore === "function" + ? vectorStore(context || {}) + : vectorStore; + + // Generate query embedding + const embeddingProvider = await ProviderFactory.createProvider( + embeddingModel.provider, + embeddingModel.modelName, + ); + + // Check if provider has embed method + if ( + typeof (embeddingProvider as unknown as { embed?: unknown }) + .embed !== "function" + ) { + throw new Error( + `Provider ${embeddingModel.provider} does not support embeddings`, + ); + } - const queryEmbedding = await ( - embeddingProvider as unknown as { - embed: (s: string) => Promise; - } - ).embed(params.query); - - // Query the vector store - let results = await store.query({ - indexName, - queryVector: queryEmbedding, - topK: params.topK || topK, - filter: params.filter, - includeVectors, - ...providerOptions, - }); - - let reranked = false; - - // Apply reranking if configured - if (rerankerConfig && results.length > 0) { - const rerankerModel = await ProviderFactory.createProvider( - typeof rerankerConfig.model === "object" - ? rerankerConfig.model.provider - : rerankerConfig.model, - typeof rerankerConfig.model === "object" - ? rerankerConfig.model.modelName - : rerankerConfig.model, - ); - - const rerankedResults = await rerank( - results, - params.query, - rerankerModel, - { - weights: rerankerConfig.weights, - topK: rerankerConfig.topK, - queryEmbedding, - }, - ); - - results = rerankedResults.map((r) => r.result); - reranked = true; - } + const queryEmbedding = await ( + embeddingProvider as unknown as { + embed: (s: string) => Promise; + } + ).embed(params.query); + + // Query the vector store + let results = await store.query({ + indexName, + queryVector: queryEmbedding, + topK: params.topK || topK, + filter: params.filter, + includeVectors, + ...providerOptions, + }); + + let reranked = false; + + // Apply reranking if configured + if (rerankerConfig && results.length > 0) { + const rerankerModel = await ProviderFactory.createProvider( + typeof rerankerConfig.model === "object" + ? rerankerConfig.model.provider + : rerankerConfig.model, + typeof rerankerConfig.model === "object" + ? rerankerConfig.model.modelName + : rerankerConfig.model, + ); + + const rerankedResults = await rerank( + results, + params.query, + rerankerModel, + { + weights: rerankerConfig.weights, + topK: rerankerConfig.topK, + queryEmbedding, + }, + ); + + results = rerankedResults.map((r) => r.result); + reranked = true; + } - // Format results - const relevantContext = results - .map((r, i) => `[${i + 1}] ${r.metadata?.text || r.text || ""}`) - .join("\n\n"); - - const queryTime = Date.now() - startTime; - - logger.info("[VectorQueryTool] Query completed", { - query: params.query.slice(0, 50), - resultsCount: results.length, - queryTime, - reranked, - filtered: !!params.filter, - }); - - return { - relevantContext, - sources: includeSources ? results : [], - totalResults: results.length, - metadata: { - queryTime, - reranked, - filtered: !!params.filter, - }, - }; - } catch (error) { - logger.error("[VectorQueryTool] Query failed", { - query: params.query.slice(0, 50), - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } + // Format results + const relevantContext = results + .map((r, i) => `[${i + 1}] ${r.metadata?.text || r.text || ""}`) + .join("\n\n"); + + const queryTime = Date.now() - startTime; + + logger.info("[VectorQueryTool] Query completed", { + query: params.query.slice(0, 50), + resultsCount: results.length, + queryTime, + reranked, + filtered: !!params.filter, + }); + + span.setAttribute("rag.vector.result_count", results.length); + span.setAttribute("rag.vector.reranked", reranked); + + return { + relevantContext, + sources: includeSources ? results : [], + totalResults: results.length, + metadata: { + queryTime, + reranked, + filtered: !!params.filter, + }, + }; + } catch (error) { + logger.error("[VectorQueryTool] Query failed", { + query: params.query.slice(0, 50), + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + ); // end withSpan }, }; } diff --git a/src/lib/sdk/toolRegistration.ts b/src/lib/sdk/toolRegistration.ts index cb1506fb9..351e0a895 100644 --- a/src/lib/sdk/toolRegistration.ts +++ b/src/lib/sdk/toolRegistration.ts @@ -6,13 +6,13 @@ import { z } from "zod"; import { logger } from "../utils/logger.js"; import type { - MCPServerInfo, - MCPServerCategory, JsonValue, + MCPServerCategory, + MCPServerInfo, + SDKToolContext, + SdkSimpleTool, ToolArgs, - SimpleTool as CoreSimpleTool, ZodUnknownSchema, - SDKToolContext, } from "../types/index.js"; import { createMCPServerInfo } from "../utils/mcpDefaults.js"; import { @@ -98,54 +98,12 @@ const VALIDATION_CONFIG = { ].map((pattern) => new RegExp(pattern.replace(/_/g, "[_-]"), "i")), } as const; -/** - * Context provided to tools during execution - * Type alias for backward compatibility - */ -type ToolContext = SDKToolContext; - -/** - * Simple tool interface for SDK users - * Extends the core SimpleTool with specific types - */ -type SimpleTool = Omit< - CoreSimpleTool, - "execute" -> & { - /** - * Tool description that helps AI understand when to use it - */ - description: string; - - /** - * Parameters schema using Zod (optional) - */ - parameters?: ZodUnknownSchema; - - /** - * Tool execution function - */ - execute: (params: TArgs, context?: ToolContext) => Promise; - - /** - * Optional metadata - */ - metadata?: { - category?: string; - version?: string; - author?: string; - tags?: string[]; - documentation?: string; - [key: string]: JsonValue | undefined; - }; -}; - /** * Creates a MCPServerInfo from a set of tools */ export function createMCPServerFromTools( serverId: string, - tools: Record, + tools: Record, metadata?: { title?: string; description?: string; @@ -205,7 +163,7 @@ function convertSchemaToJsonSchema(schema: unknown): object { /** * Helper to create a tool with type safety */ -export function createTool(config: SimpleTool): SimpleTool { +export function createTool(config: SdkSimpleTool): SdkSimpleTool { return config; } @@ -214,9 +172,9 @@ export function createTool(config: SimpleTool): SimpleTool { */ export function createValidatedTool( name: string, - config: SimpleTool, + config: SdkSimpleTool, options: { strict?: boolean; suggestions?: boolean } = {}, -): SimpleTool { +): SdkSimpleTool { const { strict = true, suggestions = true } = options; try { @@ -246,7 +204,7 @@ export function createValidatedTool( /** * Provide helpful suggestions for tool improvement */ -function provideToolSuggestions(name: string, tool: SimpleTool): void { +function provideToolSuggestions(name: string, tool: SdkSimpleTool): void { const suggestions: string[] = []; // Check for common improvements @@ -287,19 +245,19 @@ function provideToolSuggestions(name: string, tool: SimpleTool): void { * Helper to create a tool with typed parameters */ export function createTypedTool( - config: Omit & { + config: Omit & { parameters: TParams; execute: ( params: z.infer, - context?: ToolContext, + context?: SDKToolContext, ) => Promise | JsonValue; }, -): SimpleTool { - // Wrap the typed execute to match SimpleTool's signature. +): SdkSimpleTool { + // Wrap the typed execute to match SdkSimpleTool's signature. // The Zod schema validates params at runtime, so the cast within the wrapper is safe. const wrappedExecute = async ( params: ToolArgs, - context?: ToolContext, + context?: SDKToolContext, ): Promise => { const result = await config.execute(params as z.infer, context); return result; @@ -370,7 +328,7 @@ function validateToolDescriptionLegacy( /** * Validate tool configuration with detailed error messages */ -export function validateTool(name: string, tool: SimpleTool): void { +export function validateTool(name: string, tool: SdkSimpleTool): void { // Enhanced tool name validation using centralized utilities validateToolNameLegacy(name); @@ -494,7 +452,7 @@ export function validateTool(name: string, tool: SimpleTool): void { /** * Utility to validate multiple tools at once */ -export function validateTools(tools: Record): { +export function validateTools(tools: Record): { valid: string[]; invalid: Array<{ name: string; error: string }>; } { diff --git a/src/lib/server/middleware/abortSignal.ts b/src/lib/server/middleware/abortSignal.ts index 5d44fe06c..af236c63a 100644 --- a/src/lib/server/middleware/abortSignal.ts +++ b/src/lib/server/middleware/abortSignal.ts @@ -3,17 +3,11 @@ * Provides client disconnection handling for long-running requests */ -import type { MiddlewareDefinition, ServerContext } from "../../types/index.js"; - -/** - * Abort signal middleware options - */ -type AbortSignalMiddlewareOptions = { - /** Callback when abort is triggered */ - onAbort?: (ctx: ServerContext) => void; - /** Request timeout in milliseconds */ - timeout?: number; -}; +import type { + AbortSignalMiddlewareOptions, + MiddlewareDefinition, + ServerContext, +} from "../../types/index.js"; /** * Create abort signal middleware for handling client disconnections. diff --git a/src/lib/server/middleware/auth.ts b/src/lib/server/middleware/auth.ts index 2e7fdf1a6..0a4e85ad2 100644 --- a/src/lib/server/middleware/auth.ts +++ b/src/lib/server/middleware/auth.ts @@ -4,17 +4,20 @@ */ import type { + ApiKeyAuthOptions, + AuthResult, + AuthenticatedUser, + BearerAuthOptions, MiddlewareDefinition, ServerContext, - AuthenticatedUser, ServerServerAuthConfig, + TokenValidator, } from "../../types/index.js"; import { AuthenticationError, AuthorizationError, InvalidAuthenticationError, } from "../errors.js"; -import type { AuthResult } from "../../types/index.js"; /** * Check if request is from development playground. @@ -334,21 +337,6 @@ export class ApiKeyStore { /** * Options for bearer auth middleware */ -type BearerAuthOptions = { - /** Whether authentication is required (default: true) */ - required?: boolean; - /** Header name (default: "authorization") */ - headerName?: string; - /** Paths to skip authentication */ - skipPaths?: string[]; -}; - -/** - * Token validation function type - */ -type TokenValidator = ( - token: string, -) => Promise | AuthenticatedUser | null; /** * Create bearer token authentication middleware @@ -416,12 +404,6 @@ export function createBearerAuthMiddleware( /** * Options for API key auth middleware */ -type ApiKeyAuthOptions = { - /** Header name (default: "x-api-key") */ - headerName?: string; - /** Paths to skip authentication */ - skipPaths?: string[]; -}; /** * Create API key authentication middleware diff --git a/src/lib/server/middleware/common.ts b/src/lib/server/middleware/common.ts index 2747a1f3e..8ae1ed485 100644 --- a/src/lib/server/middleware/common.ts +++ b/src/lib/server/middleware/common.ts @@ -10,6 +10,8 @@ import { SpanStatus, SpanType, } from "../../observability/index.js"; +import { SpanStatusCode } from "@opentelemetry/api"; +import { tracers } from "../../telemetry/tracers.js"; import { logger } from "../../utils/logger.js"; /** @@ -26,48 +28,67 @@ export function createTimingMiddleware(): MiddlewareDefinition { name: "timing", order: 0, // Run first handler: async (ctx, next) => { - const startTime = Date.now(); - const startHrTime = process.hrtime.bigint(); - - // Store start time in metadata - ctx.metadata.requestStartTime = startTime; - - const span = SpanSerializer.createSpan( - SpanType.SERVER_REQUEST, - "server.middleware.timing", - { - "server.operation": "middleware", - "server.middleware": "timing", + return tracers.middleware.startActiveSpan( + "neurolink.middleware.timing", + async (otelSpan) => { + try { + const startTime = Date.now(); + const startHrTime = process.hrtime.bigint(); + + // Store start time in metadata + ctx.metadata.requestStartTime = startTime; + + const span = SpanSerializer.createSpan( + SpanType.SERVER_REQUEST, + "server.middleware.timing", + { + "server.operation": "middleware", + "server.middleware": "timing", + }, + ); + + try { + const result = await next(); + + // Calculate duration + const endHrTime = process.hrtime.bigint(); + const durationNs = Number(endHrTime - startHrTime); + const durationMs = durationNs / 1_000_000; + + // Add timing headers to responseHeaders (adapters read from here) + ctx.responseHeaders = ctx.responseHeaders || {}; + ctx.responseHeaders["X-Response-Time"] = + `${durationMs.toFixed(2)}ms`; + ctx.responseHeaders["Server-Timing"] = + `total;dur=${durationMs.toFixed(2)}`; + + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); + getMetricsAggregator().recordSpan(endedSpan); + + return result; + } catch (error) { + // Propagate error to OTel span so traces show ERROR status + otelSpan.recordException( + error instanceof Error ? error : new Error(String(error)), + ); + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: error instanceof Error ? error.message : String(error), + }); + + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); + endedSpan.statusMessage = + error instanceof Error ? error.message : String(error); + getMetricsAggregator().recordSpan(endedSpan); + throw error; + } + } finally { + otelSpan.end(); + } }, - ); - - try { - const result = await next(); - - // Calculate duration - const endHrTime = process.hrtime.bigint(); - const durationNs = Number(endHrTime - startHrTime); - const durationMs = durationNs / 1_000_000; - - // Add timing headers to responseHeaders (adapters read from here) - ctx.responseHeaders = ctx.responseHeaders || {}; - ctx.responseHeaders["X-Response-Time"] = `${durationMs.toFixed(2)}ms`; - ctx.responseHeaders["Server-Timing"] = - `total;dur=${durationMs.toFixed(2)}`; - - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); - getMetricsAggregator().recordSpan(endedSpan); - - return result; - } catch (error) { - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR); - endedSpan.statusMessage = - error instanceof Error ? error.message : String(error); - getMetricsAggregator().recordSpan(endedSpan); - throw error; - } + ); // end startActiveSpan }, }; } diff --git a/src/lib/server/middleware/deprecation.ts b/src/lib/server/middleware/deprecation.ts index d0d38fa88..4e14a954c 100644 --- a/src/lib/server/middleware/deprecation.ts +++ b/src/lib/server/middleware/deprecation.ts @@ -6,30 +6,11 @@ */ import type { + DeprecatedRouteInfo, + DeprecationConfig, MiddlewareDefinition, RouteDefinition, - DeprecatedRouteInfo, } from "../../types/index.js"; -/** - * Deprecation middleware configuration - */ -type DeprecationConfig = { - /** - * Array of route definitions to check for deprecation - * Routes with `deprecated.enabled: true` will have deprecation headers added - */ - routes: RouteDefinition[]; - - /** - * Custom header name for deprecation notice (default: "X-Deprecation-Notice") - */ - noticeHeader?: string; - - /** - * Whether to include Link header for alternative routes (default: true) - */ - includeLink?: boolean; -}; /** * Build a lookup map of deprecated routes diff --git a/src/lib/server/middleware/rateLimit.ts b/src/lib/server/middleware/rateLimit.ts index ce20eedac..6b42c73f6 100644 --- a/src/lib/server/middleware/rateLimit.ts +++ b/src/lib/server/middleware/rateLimit.ts @@ -3,78 +3,16 @@ * Provides configurable rate limiting for server adapters */ -import type { MiddlewareDefinition, ServerContext } from "../../types/index.js"; +import type { + FixedWindowRateLimitConfig, + MiddlewareDefinition, + RateLimitEntry, + RateLimitMiddlewareConfig, + RateLimitStore, + ServerContext, +} from "../../types/index.js"; import { RateLimitError as ServerRateLimitError } from "../errors.js"; -/** - * Rate limit middleware configuration - */ -type RateLimitMiddlewareConfig = { - /** Maximum requests per window */ - maxRequests: number; - - /** Time window in milliseconds */ - windowMs: number; - - /** Custom error message */ - message?: string; - - /** Skip rate limiting for certain paths */ - skipPaths?: string[]; - - /** - * Custom key generator for identifying clients - * Default: IP address - */ - keyGenerator?: (ctx: ServerContext) => string; - - /** - * Custom response handler for rate limit exceeded - */ - onRateLimitExceeded?: (ctx: ServerContext, retryAfter: number) => unknown; - - /** - * Custom rate limit store - * Default: in-memory store - */ - store?: RateLimitStore; -}; - -/** - * Rate limit entry - */ -type RateLimitEntry = { - count: number; - resetAt: number; -}; - -/** - * Rate limit store interface - * Implement this for custom storage (Redis, etc.) - */ -type RateLimitStore = { - /** - * Get the current entry for a key - */ - get(key: string): Promise; - - /** - * Set an entry for a key - */ - set(key: string, entry: RateLimitEntry): Promise; - - /** - * Increment the counter for a key - * Returns the current count and reset time - */ - increment(key: string, windowMs: number): Promise; - - /** - * Reset the counter for a key - */ - reset(key: string): Promise; -}; - /** * In-memory rate limit store */ @@ -330,24 +268,6 @@ export function createSlidingWindowRateLimitMiddleware( */ export { InMemoryRateLimitStore as MemoryRateLimitStore }; -/** - * Fixed window rate limit configuration (for standalone signature) - */ -type FixedWindowRateLimitConfig = { - /** Maximum requests per window */ - maxRequests: number; - /** Time window in milliseconds */ - windowMs: number; - /** Custom error message */ - message?: string; - /** Skip rate limiting for certain paths */ - skipPaths?: string[]; - /** Custom key generator */ - keyGenerator?: (ctx: ServerContext) => string; - /** Custom rate limit exceeded handler */ - onRateLimitExceeded?: (ctx: ServerContext, retryAfter: number) => unknown; -}; - /** * Create fixed window rate limit middleware * diff --git a/src/lib/server/middleware/validation.ts b/src/lib/server/middleware/validation.ts index b06f4f3a9..eb3caf7c1 100644 --- a/src/lib/server/middleware/validation.ts +++ b/src/lib/server/middleware/validation.ts @@ -3,91 +3,15 @@ * Provides schema-based request validation for server adapters */ -import type { MiddlewareDefinition, ServerContext } from "../../types/index.js"; +import type { + ExtendedValidationSchema, + MiddlewareDefinition, + MiddlewareRequestSchema, + PropertySchema, + ValidationConfig, +} from "../../types/index.js"; import { ValidationError as ServerValidationError } from "../errors.js"; -/** - * Validation configuration - */ -type ValidationConfig = { - /** Schema for validating request body */ - bodySchema?: ValidationSchema; - - /** Schema for validating query parameters */ - querySchema?: ValidationSchema; - - /** Schema for validating path parameters */ - paramsSchema?: ValidationSchema; - - /** Schema for validating headers */ - headersSchema?: ValidationSchema; - - /** - * Custom validation function - * Throw ValidationError for invalid requests - */ - customValidator?: (ctx: ServerContext) => Promise; - - /** Skip validation for certain paths */ - skipPaths?: string[]; - - /** Custom error formatter */ - errorFormatter?: (errors: ServerValidationError[]) => unknown; -}; - -/** - * Simple validation schema - * Can be extended with JSON Schema or Zod integration - */ -type ValidationSchema = { - /** Required fields */ - required?: string[]; - - /** Field type definitions */ - properties?: Record; - - /** Allow additional properties */ - additionalProperties?: boolean; -}; - -/** - * Property schema definition - */ -type PropertySchema = { - /** Property type */ - type: "string" | "number" | "boolean" | "object" | "array"; - - /** Minimum value (for numbers) or length (for strings/arrays) */ - minimum?: number; - - /** Maximum value (for numbers) or length (for strings/arrays) */ - maximum?: number; - - /** Minimum length for strings (alias for minimum) */ - minLength?: number; - - /** Maximum length for strings (alias for maximum) */ - maxLength?: number; - - /** Minimum items for arrays */ - minItems?: number; - - /** Maximum items for arrays */ - maxItems?: number; - - /** Pattern for string validation (regex) */ - pattern?: string; - - /** Enum of allowed values */ - enum?: unknown[]; - - /** Default value */ - default?: unknown; - - /** Custom validation function */ - validate?: (value: unknown) => boolean | string; -}; - /** * Re-export ValidationError from errors for convenience */ @@ -200,7 +124,7 @@ export function createRequestValidationMiddleware( */ function validateObject( obj: Record, - schema: ValidationSchema, + schema: MiddlewareRequestSchema, prefix: string, ): Array<{ field: string; message: string }> { const errors: Array<{ field: string; message: string }> = []; @@ -414,7 +338,7 @@ export function createFieldValidator( * ``` */ export function createBodyValidationMiddleware( - schema: ValidationSchema, + schema: MiddlewareRequestSchema, ): MiddlewareDefinition { return createRequestValidationMiddleware({ bodySchema: schema }); } @@ -433,7 +357,7 @@ export function createBodyValidationMiddleware( * ``` */ export function createQueryValidationMiddleware( - schema: ValidationSchema, + schema: MiddlewareRequestSchema, ): MiddlewareDefinition { return createRequestValidationMiddleware({ querySchema: schema }); } @@ -448,24 +372,6 @@ export const createValidationMiddleware = createRequestValidationMiddleware; // Common Schemas // ============================================ -/** - * Extended property schema for common schemas - */ -type ExtendedPropertySchema = PropertySchema & { - format?: string; -}; - -/** - * Extended validation schema for common schemas - */ -type ExtendedValidationSchema = { - type?: string; - format?: string; - required?: string[]; - properties?: Record; - additionalProperties?: boolean; -}; - /** * Common validation schemas for reuse */ diff --git a/src/lib/server/openapi/generator.ts b/src/lib/server/openapi/generator.ts index 90e2d0b55..a76a52766 100644 --- a/src/lib/server/openapi/generator.ts +++ b/src/lib/server/openapi/generator.ts @@ -5,6 +5,8 @@ import type { JsonObject, + OpenAPIGeneratorConfig, + OpenAPISpec, RouteDefinition, ServerAdapterConfig, } from "../../types/index.js"; @@ -24,57 +26,6 @@ import { ApiKeySecurityScheme, } from "./templates.js"; -// ============================================ -// Types -// ============================================ - -/** - * OpenAPI generator configuration - */ -type OpenAPIGeneratorConfig = { - /** API info override */ - info?: { - title?: string; - version?: string; - description?: string; - }; - /** Server configuration */ - servers?: Array<{ - url: string; - description?: string; - }>; - /** Include security schemes */ - includeSecurity?: boolean; - /** Base path for all routes */ - basePath?: string; - /** Additional tags */ - additionalTags?: Array<{ - name: string; - description: string; - }>; - /** Custom schemas to add */ - customSchemas?: Record; - /** Routes to document in the OpenAPI spec */ - routes?: RouteDefinition[]; -}; - -/** - * Generated OpenAPI specification - */ -type OpenAPISpec = { - openapi: "3.1.0"; - info: JsonObject; - servers: JsonObject[]; - tags: JsonObject[]; - paths: Record; - components: { - schemas: Record; - securitySchemes?: Record; - parameters?: Record; - }; - security?: JsonObject[]; -}; - // ============================================ // OpenAPI Generator Class // ============================================ diff --git a/src/lib/server/routes/agentRoutes.ts b/src/lib/server/routes/agentRoutes.ts index 4d1a713cc..8b3b0f151 100644 --- a/src/lib/server/routes/agentRoutes.ts +++ b/src/lib/server/routes/agentRoutes.ts @@ -3,6 +3,7 @@ * Endpoints for agent execution and streaming */ +import { SpanStatusCode } from "@opentelemetry/api"; import { ProviderFactory } from "../../factories/providerFactory.js"; import type { AgentExecuteRequest, @@ -14,6 +15,8 @@ import type { RouteGroup, ServerContext, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { createStreamRedactor } from "../utils/redaction.js"; import { AgentExecuteRequestSchema, @@ -52,53 +55,66 @@ export function createAgentRoutes(basePath: string = "/api"): RouteGroup { const request = validation.data as AgentExecuteRequest; - // Normalize input - const input = - typeof request.input === "string" - ? { text: request.input } - : request.input; - - const result = await ctx.neurolink.generate({ - input, - provider: request.provider, - model: request.model, - systemPrompt: request.systemPrompt, - temperature: request.temperature, - maxTokens: request.maxTokens, - // Note: tools should be passed as Record in generate options - // If request.tools is an array of tool names, we skip them - context: { - // When an authenticated user context exists (set by auth middleware), - // always use its IDs to prevent caller-supplied impersonation. - sessionId: ctx.user - ? ctx.session?.id - : (ctx.session?.id ?? request.sessionId), - userId: ctx.user ? ctx.user.id : request.userId, - userEmail: ctx.user?.email, - userRoles: ctx.user?.roles, - requestId: ctx.requestId, + return withSpan( + { + name: "neurolink.http.execute", + tracer: tracers.http, + attributes: { + "http.route": "/api/agent/execute", + "ai.provider": request.provider || "default", + "ai.model": request.model || "default", + }, }, - }); + async () => { + // Normalize input + const input = + typeof request.input === "string" + ? { text: request.input } + : request.input; - // Map tool calls from SDK format to API format - const toolCalls = result.toolCalls?.map( - (tc: { - toolCallId: string; - toolName: string; - args: Record; - }) => ({ - name: tc.toolName, - arguments: tc.args, - }), - ); + const result = await ctx.neurolink.generate({ + input, + provider: request.provider, + model: request.model, + systemPrompt: request.systemPrompt, + temperature: request.temperature, + maxTokens: request.maxTokens, + // Note: tools should be passed as Record in generate options + // If request.tools is an array of tool names, we skip them + context: { + // When an authenticated user context exists (set by auth middleware), + // always use its IDs to prevent caller-supplied impersonation. + sessionId: ctx.user + ? ctx.session?.id + : (ctx.session?.id ?? request.sessionId), + userId: ctx.user ? ctx.user.id : request.userId, + userEmail: ctx.user?.email, + userRoles: ctx.user?.roles, + requestId: ctx.requestId, + }, + }); - return { - content: result.content || "", - provider: result.provider || request.provider || "unknown", - model: result.model || request.model || "unknown", - usage: result.usage, - toolCalls, - }; + // Map tool calls from SDK format to API format + const toolCalls = result.toolCalls?.map( + (tc: { + toolCallId: string; + toolName: string; + args: Record; + }) => ({ + name: tc.toolName, + arguments: tc.args, + }), + ); + + return { + content: result.content || "", + provider: result.provider || request.provider || "unknown", + model: result.model || request.model || "unknown", + usage: result.usage, + toolCalls, + }; + }, + ); // end withSpan }, description: "Execute agent with prompt", tags: ["agent"], @@ -150,11 +166,32 @@ export function createAgentRoutes(basePath: string = "/api"): RouteGroup { // Create redactor (no-op if redaction is not enabled) const redactor = createStreamRedactor(ctx.redaction); - // Wrap stream to apply redaction to each chunk + // Wrap stream with a span that stays open for the full consumption + // lifetime, not just the generator creation. async function* redactedStream(): AsyncIterable { - for await (const chunk of result.stream) { - // Apply redaction to chunk (returns unchanged if redaction disabled) - yield redactor(chunk); + const streamSpan = tracers.http.startSpan("neurolink.http.stream", { + attributes: { + "http.route": "/api/agent/stream", + "ai.provider": request.provider || "default", + "ai.model": request.model || "default", + }, + }); + try { + for await (const chunk of result.stream) { + yield redactor(chunk); + } + streamSpan.setStatus({ code: SpanStatusCode.OK }); + } catch (err) { + streamSpan.recordException( + err instanceof Error ? err : new Error(String(err)), + ); + streamSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } finally { + streamSpan.end(); } } @@ -198,19 +235,33 @@ export function createAgentRoutes(basePath: string = "/api"): RouteGroup { try { const providerName = request.provider || "openai"; - const provider = await ProviderFactory.createProvider( - providerName, - request.model, + return await withSpan( + { + name: "neurolink.http.embed", + tracer: tracers.http, + attributes: { + "http.route": "/api/agent/embed", + "ai.provider": providerName, + "ai.model": request.model || "default", + }, + }, + async () => { + const provider = await ProviderFactory.createProvider( + providerName, + request.model, + ); + const embedding = await provider.embed( + request.text, + request.model, + ); + return { + embedding, + provider: providerName, + model: request.model || "default", + dimension: embedding.length, + }; + }, ); - - const embedding = await provider.embed(request.text, request.model); - - return { - embedding, - provider: providerName, - model: request.model || "default", - dimension: embedding.length, - }; } catch (error) { return createError( "EXECUTION_FAILED", @@ -247,23 +298,37 @@ export function createAgentRoutes(basePath: string = "/api"): RouteGroup { try { const providerName = request.provider || "openai"; - const provider = await ProviderFactory.createProvider( - providerName, - request.model, - ); + return await withSpan( + { + name: "neurolink.http.embedMany", + tracer: tracers.http, + attributes: { + "http.route": "/api/agent/embed-many", + "ai.provider": providerName, + "ai.model": request.model || "default", + "ai.embed.count": request.texts.length, + }, + }, + async () => { + const provider = await ProviderFactory.createProvider( + providerName, + request.model, + ); - const embeddings = await provider.embedMany( - request.texts, - request.model, - ); + const embeddings = await provider.embedMany( + request.texts, + request.model, + ); - return { - embeddings, - provider: providerName, - model: request.model || "default", - count: embeddings.length, - dimension: embeddings[0]?.length ?? 0, - }; + return { + embeddings, + provider: providerName, + model: request.model || "default", + count: embeddings.length, + dimension: embeddings[0]?.length ?? 0, + }; + }, + ); } catch (error) { return createError( "EXECUTION_FAILED", diff --git a/src/lib/server/routes/claudeProxyRoutes.ts b/src/lib/server/routes/claudeProxyRoutes.ts index 016227c66..116710f6f 100644 --- a/src/lib/server/routes/claudeProxyRoutes.ts +++ b/src/lib/server/routes/claudeProxyRoutes.ts @@ -32,6 +32,8 @@ import { serializeClaudeResponse, } from "../../proxy/claudeFormat.js"; import type { ModelRouter } from "../../proxy/modelRouter.js"; +import { tracers } from "../../telemetry/tracers.js"; +import { withSpan } from "../../telemetry/withSpan.js"; import { ProxyTracer, recordFallbackAttempt } from "../../proxy/proxyTracer.js"; import { createRawStreamCapture } from "../../proxy/rawStreamCapture.js"; import { @@ -70,14 +72,18 @@ import type { ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeRequestRuntimeContext, + ClaudeSnapshot, + ClaudeSnapshotBody, InternalResult, LoadedClaudeAccountContext, + ParsedClaudeError, ParsedClaudeRequest, PreparedAnthropicAccountAttempt, ProxyBodyCaptureLogger, ProxyPassthroughAccount, - RuntimeAccountState, + ProxyTranslationAttempt, RouteGroup, + RuntimeAccountState, ServerContext, } from "../../types/index.js"; import { logger } from "../../utils/logger.js"; @@ -113,7 +119,7 @@ const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000] as const; /** Maximum upstream 429 attempts per account before rotating to the next account. * Total attempts per account = this + 1 (the initial call plus this many retries). */ -const MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES = 5; +const MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES = 10; /** Max time to sleep between 429 retries. Caps large upstream retry-after values * so we don't hold the client connection open for minutes. */ const MAX_RATE_LIMIT_RETRY_DELAY_MS = 30_000; @@ -124,15 +130,14 @@ const UPSTREAM_FETCH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes const accountRuntimeState = new Map(); -type ProxyTranslationAttempt = { - provider?: string; - model?: string; - label: string; -}; - /** Track whether we've run the one-time startup prune. */ let startupPruneDone = false; +/** Default cooling period when retries are exhausted and upstream didn't + * provide a retry-after header. Short enough to recover quickly, long + * enough to avoid immediately hammering the same account. */ +const DEFAULT_COOLING_PERIOD_MS = 60_000; + /** Advance the primary account index when the current primary is exhausted * (429 retries exhausted or auth failure). This is what makes fill-first work: * we stick to one account until it's unusable. Only advances when the exhausted @@ -152,25 +157,42 @@ function advancePrimaryIfCurrent( primaryAccountIndex = (primaryAccountIndex + 1) % enabledCount; } +/** If the configured home primary (index 0) is no longer cooling, reset + * primaryAccountIndex back to 0 so traffic returns to the preferred account + * once its rate limit window expires. Called at the start of each request. */ +function maybeResetPrimaryToHome( + enabledAccounts: ProxyPassthroughAccount[], +): void { + if (enabledAccounts.length <= 1 || primaryAccountIndex === 0) { + return; + } + const homeState = accountRuntimeState.get(enabledAccounts[0].key); + if ( + !homeState || + !homeState.coolingUntil || + Date.now() >= homeState.coolingUntil + ) { + // Home account is no longer cooling — reset to it + primaryAccountIndex = 0; + if (homeState?.coolingUntil) { + homeState.coolingUntil = undefined; + logger.always( + `[proxy] home primary account=${enabledAccounts[0].label} cooling expired, resetting primaryAccountIndex to 0`, + ); + } + } +} + +/** Check if an account is currently in its cooling window. */ +function isAccountCooling(accountKey: string): boolean { + const state = accountRuntimeState.get(accountKey); + return !!state?.coolingUntil && Date.now() < state.coolingUntil; +} + // --------------------------------------------------------------------------- // OAuth polyfill helpers (extracted to reduce block nesting) // --------------------------------------------------------------------------- -type ClaudeSnapshotBody = { - metadataUserId?: string; - billingHeader?: string; - agentBlock?: string; - sessionId?: string; -}; - -type ClaudeSnapshot = { - accountKey: string; - capturedAt: string; - source: "claude-code"; - headers: Record; - body?: ClaudeSnapshotBody; -}; - const snapshotCache = new Map< string, { snapshot: ClaudeSnapshot; loadedAt: number } @@ -3291,6 +3313,33 @@ async function handleAnthropicAuthRetry(args: { const retryStatus = retryResp.status; const retryBody = await retryResp.text(); + // Capture full response headers and body for all auth-retry errors. + // Redact sensitive headers and cap body size before persisting. + const retryRespHeaders: Record = {}; + retryResp.headers.forEach((value, key) => { + retryRespHeaders[key] = value; + }); + const safeRetryHeaders = { ...retryRespHeaders }; + delete safeRetryHeaders["authorization"]; + delete safeRetryHeaders["x-api-key"]; + const cappedRetryBody = + retryBody.length > 4000 + ? retryBody.slice(0, 4000) + "...[truncated]" + : retryBody; + tracer?.logUpstreamResponseHeaders(safeRetryHeaders); + tracer?.logUpstreamResponseBody(cappedRetryBody); + logProxyBody({ + phase: "upstream_response", + headers: safeRetryHeaders, + body: cappedRetryBody, + bodySize: Buffer.byteLength(retryBody, "utf8"), + contentType: retryRespHeaders["content-type"] ?? "application/json", + account: account.label, + accountType: account.type, + attempt: attemptNumber, + responseStatus: retryStatus, + durationMs: Date.now() - fetchStartMs, + }); authRetryError = `retry ${authRetry + 1}/${MAX_AUTH_RETRIES} failed with status ${retryStatus}`; currentLastError = retryBody; logger.debug( @@ -4143,6 +4192,9 @@ async function fetchAnthropicAccountResponse(args: { orderedAccounts: ProxyPassthroughAccount[]; tracer?: ProxyTracer; logAttempt: AnthropicAttemptLogger; + logProxyBody: ProxyBodyCaptureLogger; + fetchStartMs: number; + attemptNumber: number; currentLastError: unknown; currentSawRateLimit: boolean; currentSawNetworkError: boolean; @@ -4158,6 +4210,9 @@ async function fetchAnthropicAccountResponse(args: { orderedAccounts: _orderedAccounts, tracer, logAttempt, + logProxyBody, + fetchStartMs, + attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, @@ -4207,9 +4262,37 @@ async function fetchAnthropicAccountResponse(args: { sawRateLimit = true; const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after")); recordAttemptError(account.label, account.type, 429); + // Capture full response headers and body for diagnostics (parity with + // handleAnthropicNonOkResponse which does this for all other error statuses). + const errRespHeaders: Record = {}; + response.headers.forEach((value, key) => { + errRespHeaders[key] = value; + }); lastError = await response.text(); + // Redact sensitive headers and cap body before persisting + const safe429Headers = { ...errRespHeaders }; + delete safe429Headers["authorization"]; + delete safe429Headers["x-api-key"]; + const capped429Body = + String(lastError).length > 4000 + ? String(lastError).slice(0, 4000) + "...[truncated]" + : String(lastError); + tracer?.logUpstreamResponseHeaders(safe429Headers); + tracer?.logUpstreamResponseBody(capped429Body); + logProxyBody({ + phase: "upstream_response", + headers: safe429Headers, + body: capped429Body, + bodySize: Buffer.byteLength(String(lastError), "utf8"), + contentType: errRespHeaders["content-type"] ?? "application/json", + account: account.label, + accountType: account.type, + attempt: attemptNumber, + responseStatus: 429, + durationMs: Date.now() - fetchStartMs, + }); logger.always( - `[proxy] ← 429 account=${account.label} retry-after=${retryAfterMs}ms (upstream)`, + `[proxy] ← 429 account=${account.label} retry-after=${retryAfterMs}ms (upstream) ratelimit-status=${errRespHeaders["anthropic-ratelimit-unified-status"] ?? "unknown"}`, ); logAttempt(429, "rate_limit_error", String(lastError)); tracer?.setError("rate_limit_error", String(lastError).slice(0, 500)); @@ -4293,9 +4376,18 @@ async function handleAnthropicRoutedClaudeRequest(args: { }; const acctSelectionSpan = tracer?.startAccountSelection(); - // No partition / cooldown gating — every account is always eligible. - // Retries are handled inline per-account using upstream retry-after. - accountLoop: for (const account of orderedAccounts) { + // Try to return to the home primary account if its cooling has expired. + maybeResetPrimaryToHome(enabledAccounts); + + // Skip accounts that are still cooling from a recent 429-exhaustion, + // but keep them as last-resort if ALL accounts are cooling. + const nonCoolingAccounts = orderedAccounts.filter( + (a) => !isAccountCooling(a.key), + ); + const effectiveAccounts = + nonCoolingAccounts.length > 0 ? nonCoolingAccounts : orderedAccounts; + + accountLoop: for (const account of effectiveAccounts) { const accountState = getOrCreateRuntimeState(account.key); let transientSameAccountRetries = 0; let rateLimitSameAccountRetries = 0; @@ -4358,6 +4450,9 @@ async function handleAnthropicRoutedClaudeRequest(args: { orderedAccounts, tracer, logAttempt, + logProxyBody, + fetchStartMs: preparedAttempt.fetchStartMs, + attemptNumber: loopState.attemptNumber, currentLastError: loopState.lastError, currentSawRateLimit: loopState.sawRateLimit, currentSawNetworkError: loopState.sawNetworkError, @@ -4389,13 +4484,19 @@ async function handleAnthropicRoutedClaudeRequest(args: { fetchResult.retrySameAccount && fetchResult.retryAfterMs !== undefined ) { + // Mark account as cooling so subsequent requests don't hammer it + const coolingMs = Math.min( + fetchResult.retryAfterMs || DEFAULT_COOLING_PERIOD_MS, + DEFAULT_COOLING_PERIOD_MS, + ); + accountState.coolingUntil = Date.now() + coolingMs; advancePrimaryIfCurrent( account.key, enabledAccounts.length, orderedAccounts[0]?.key, ); logger.always( - `[proxy] exhausted ${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES} rate-limit retries for account=${account.label}; rotating`, + `[proxy] exhausted ${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES} rate-limit retries for account=${account.label}; cooling for ${coolingMs}ms, rotating`, ); continue accountLoop; } @@ -4520,6 +4621,11 @@ async function handleAnthropicRoutedClaudeRequest(args: { break accountLoop; } + // Clear cooling on success — account is healthy again + if (accountState.coolingUntil) { + accountState.coolingUntil = undefined; + } + const successResult = await handleAnthropicSuccessfulResponse({ ctx, body, @@ -4745,24 +4851,32 @@ export function createClaudeProxyRoutes( { method: "GET", path: `${basePath}/v1/models`, - handler: async (_ctx: ServerContext) => { - const models = [ - "claude-sonnet-4-20250514", - "claude-sonnet-4-5-20250929", - "claude-haiku-4-5-20241022", - "claude-opus-4-20250514", - ]; - - return { - object: "list", - data: models.map((id) => ({ - id, - object: "model", - created: 1700000000, - owned_by: "anthropic", - })), - }; - }, + handler: async (_ctx: ServerContext) => + withSpan( + { + name: "neurolink.http.claudeProxy.listModels", + tracer: tracers.http, + attributes: { "http.route": `${basePath}/v1/models` }, + }, + async () => { + const models = [ + "claude-sonnet-4-20250514", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20241022", + "claude-opus-4-20250514", + ]; + + return { + object: "list", + data: models.map((id) => ({ + id, + object: "model", + created: 1700000000, + owned_by: "anthropic", + })), + }; + }, + ), description: "List available Claude models", tags: ["claude-proxy", "models"], }, @@ -4773,29 +4887,45 @@ export function createClaudeProxyRoutes( { method: "POST", path: `${basePath}/v1/messages/count_tokens`, - handler: async (ctx: ServerContext) => { - const body = ctx.body as - | { model?: string; messages?: Array<{ content: unknown }> } - | undefined; - - if (!body?.model || !body?.messages) { - return buildClaudeError( - 400, - "Missing required fields: model, messages", - ); - } - - // Simple estimation using character-to-token heuristic - const text = body.messages - .map((m) => - typeof m.content === "string" - ? m.content - : JSON.stringify(m.content), - ) - .join(" "); + handler: async (ctx: ServerContext) => + withSpan( + { + name: "neurolink.http.claudeProxy.countTokens", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/v1/messages/count_tokens`, + }, + }, + async (span) => { + const body = ctx.body as + | { model?: string; messages?: Array<{ content: unknown }> } + | undefined; + + if ( + typeof body?.model !== "string" || + !Array.isArray(body?.messages) + ) { + return buildClaudeError( + 400, + "Missing required fields: model, messages", + ); + } - return { input_tokens: Math.ceil(text.length / 4) }; - }, + // Simple estimation using character-to-token heuristic + const text = body.messages + .map((m) => + typeof m.content === "string" + ? m.content + : JSON.stringify(m.content), + ) + .join(" "); + + const inputTokens = Math.ceil(text.length / 4); + span.setAttribute("ai.model", body.model); + span.setAttribute("gen_ai.usage.input_tokens", inputTokens); + return { input_tokens: inputTokens }; + }, + ), description: "Count tokens for a messages request", tags: ["claude-proxy", "tokens"], }, @@ -5000,11 +5130,6 @@ const TRANSIENT_HTTP_STATUSES = new Set([ 408, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 529, ]); -type ParsedClaudeError = { - errorType?: string; - message?: string; -}; - /** * Parse a Claude error payload when available. */ diff --git a/src/lib/server/routes/healthRoutes.ts b/src/lib/server/routes/healthRoutes.ts index 2d0d7a216..ca9860220 100644 --- a/src/lib/server/routes/healthRoutes.ts +++ b/src/lib/server/routes/healthRoutes.ts @@ -9,6 +9,31 @@ import type { RouteGroup, ServerContext, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; + +/** + * Wrap a health-route handler with an OTel span that records the route, + * request id, and overall health status. + */ +function tracedHealthHandler( + name: string, + route: string, + fn: (ctx: ServerContext) => Promise, +): (ctx: ServerContext) => Promise { + return (ctx: ServerContext) => + withSpan( + { + name, + tracer: tracers.http, + attributes: { + "http.route": route, + "http.request.id": ctx.requestId ?? "", + }, + }, + () => fn(ctx), + ); +} /** * Create health check routes @@ -20,205 +45,273 @@ export function createHealthRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/health`, - handler: async (): Promise => { - return { - status: "ok", - timestamp: new Date().toISOString(), - uptime: process.uptime() * 1000, - version: process.env.npm_package_version || "unknown", - }; - }, + handler: tracedHealthHandler( + "neurolink.http.health.check", + `${basePath}/health`, + async (): Promise => { + return { + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime() * 1000, + version: process.env.npm_package_version || "unknown", + }; + }, + ), description: "Basic health check", tags: ["health"], }, { method: "GET", path: `${basePath}/health/live`, - handler: async (): Promise<{ status: string; timestamp: string }> => { - // Liveness probe - just checks if the server is running - return { - status: "alive", - timestamp: new Date().toISOString(), - }; - }, + handler: tracedHealthHandler( + "neurolink.http.health.live", + `${basePath}/health/live`, + async (): Promise<{ status: string; timestamp: string }> => { + // Liveness probe - just checks if the server is running + return { + status: "alive", + timestamp: new Date().toISOString(), + }; + }, + ), description: "Kubernetes liveness probe", tags: ["health"], }, { method: "GET", path: `${basePath}/health/ready`, - handler: async (ctx: ServerContext): Promise => { - // Readiness probe - checks if all dependencies are ready - const tools = await ctx.toolRegistry.listTools(); - const hasTools = tools.length > 0; - const hasExternalManager = !!ctx.externalServerManager; + handler: (ctx: ServerContext): Promise => + withSpan( + { + name: "neurolink.http.health.ready", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/health/ready`, + "http.request.id": ctx.requestId ?? "", + }, + }, + async (span) => { + // Readiness probe - checks if all dependencies are ready + const tools = await ctx.toolRegistry.listTools(); + const hasTools = tools.length > 0; + const hasExternalManager = !!ctx.externalServerManager; - // Check external servers if available - let externalServersReady = true; - if (ctx.externalServerManager) { - const statuses = ctx.externalServerManager.getServerStatuses(); - for (const status of statuses) { - if (status.status !== "connected") { - externalServersReady = false; - break; + // Check external servers if available + let externalServersReady = true; + if (ctx.externalServerManager) { + const statuses = ctx.externalServerManager.getServerStatuses(); + for (const status of statuses) { + if (status.status !== "connected") { + externalServersReady = false; + break; + } + } } - } - } - const isReady = - hasTools || !hasExternalManager || externalServersReady; + const isReady = + hasTools || !hasExternalManager || externalServersReady; - return { - ready: isReady, - timestamp: new Date().toISOString(), - services: { - neurolink: true, - tools: hasTools, - externalServers: externalServersReady, + span.setAttribute("health.ready", isReady); + span.setAttribute("health.tools_count", tools.length); + span.setAttribute( + "health.external_servers_ready", + externalServersReady, + ); + + return { + ready: isReady, + timestamp: new Date().toISOString(), + services: { + neurolink: true, + tools: hasTools, + externalServers: externalServersReady, + }, + }; }, - }; - }, + ), description: "Kubernetes readiness probe", tags: ["health"], }, { method: "GET", path: `${basePath}/health/startup`, - handler: async (ctx: ServerContext) => { - // Startup probe - checks if the application has started successfully - const tools = await ctx.toolRegistry.listTools(); + handler: (ctx: ServerContext) => + withSpan( + { + name: "neurolink.http.health.startup", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/health/startup`, + "http.request.id": ctx.requestId ?? "", + }, + }, + async (span) => { + // Startup probe - checks if the application has started successfully + const tools = await ctx.toolRegistry.listTools(); + span.setAttribute("health.tools_count", tools.length); - return { - started: true, - timestamp: new Date().toISOString(), - services: { - neurolink: true, - toolsLoaded: tools.length, - externalServerManager: !!ctx.externalServerManager, + return { + started: true, + timestamp: new Date().toISOString(), + services: { + neurolink: true, + toolsLoaded: tools.length, + externalServerManager: !!ctx.externalServerManager, + }, + }; }, - }; - }, + ), description: "Kubernetes startup probe", tags: ["health"], }, { method: "GET", path: `${basePath}/health/detailed`, - handler: async (ctx: ServerContext) => { - const tools = await ctx.toolRegistry.listTools(); + handler: (ctx: ServerContext) => + withSpan( + { + name: "neurolink.http.health.detailed", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/health/detailed`, + "http.request.id": ctx.requestId ?? "", + }, + }, + async (span) => { + const tools = await ctx.toolRegistry.listTools(); - // Group tools by source - const toolsBySource: Record = {}; - for (const tool of tools) { - const source = - typeof tool.source === "string" - ? tool.source - : tool.serverId || "built-in"; - toolsBySource[source] = (toolsBySource[source] || 0) + 1; - } + // Group tools by source + const toolsBySource: Record = {}; + for (const tool of tools) { + const source = + typeof tool.source === "string" + ? tool.source + : tool.serverId || "built-in"; + toolsBySource[source] = (toolsBySource[source] || 0) + 1; + } - // Get external server statuses - const externalServers: Array<{ - name: string; - status: string; - toolCount: number; - }> = []; - if (ctx.externalServerManager) { - const statuses = ctx.externalServerManager.getServerStatuses(); - for (const status of statuses) { - externalServers.push({ - name: status.serverId, - status: status.status, - toolCount: status.toolCount, - }); - } - } + // Get external server statuses + const externalServers: Array<{ + name: string; + status: string; + toolCount: number; + }> = []; + if (ctx.externalServerManager) { + const statuses = ctx.externalServerManager.getServerStatuses(); + for (const status of statuses) { + externalServers.push({ + name: status.serverId, + status: status.status, + toolCount: status.toolCount, + }); + } + } - // Memory status - const memory = ctx.neurolink.conversationMemory; - const memoryStatus = { - available: !!memory, - type: memory?.constructor.name || "none", - }; + // Memory status + const memory = ctx.neurolink.conversationMemory; + const memoryStatus = { + available: !!memory, + type: memory?.constructor.name || "none", + }; - // Build the base health response - const healthResponse: Record = { - status: "ok", - timestamp: new Date().toISOString(), - uptime: process.uptime() * 1000, - version: process.env.npm_package_version || "unknown", - node: { - version: process.version, - platform: process.platform, - arch: process.arch, - }, - memory: { - ...memoryStatus, - process: { - heapUsed: Math.round( - process.memoryUsage().heapUsed / 1024 / 1024, - ), - heapTotal: Math.round( - process.memoryUsage().heapTotal / 1024 / 1024, - ), - rss: Math.round(process.memoryUsage().rss / 1024 / 1024), - external: Math.round( - process.memoryUsage().external / 1024 / 1024, - ), - }, - }, - tools: { - total: tools.length, - bySource: toolsBySource, - }, - externalServers: { - count: externalServers.length, - servers: externalServers, - }, - }; + // Build the base health response + const healthResponse: Record = { + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime() * 1000, + version: process.env.npm_package_version || "unknown", + node: { + version: process.version, + platform: process.platform, + arch: process.arch, + }, + memory: { + ...memoryStatus, + process: { + heapUsed: Math.round( + process.memoryUsage().heapUsed / 1024 / 1024, + ), + heapTotal: Math.round( + process.memoryUsage().heapTotal / 1024 / 1024, + ), + rss: Math.round(process.memoryUsage().rss / 1024 / 1024), + external: Math.round( + process.memoryUsage().external / 1024 / 1024, + ), + }, + }, + tools: { + total: tools.length, + bySource: toolsBySource, + }, + externalServers: { + count: externalServers.length, + servers: externalServers, + }, + }; - // Add proxy account pool status when proxy mode is active - if (ctx.metadata?.accountPool) { - const pool = ctx.metadata.accountPool as { - getAllAccounts: () => Array<{ - id: string; - label?: string; - status: string; - requestCount: number; - subscriptionTier?: string; - }>; - getHealthyCount: () => number; - getStrategy: () => string; - }; - const allAccounts = pool.getAllAccounts(); - const statusCounts: Record = {}; - for (const a of allAccounts) { - statusCounts[a.status] = (statusCounts[a.status] || 0) + 1; - } - healthResponse.proxy = { - totalAccounts: allAccounts.length, - statusDistribution: statusCounts, - healthyCount: pool.getHealthyCount(), - strategy: pool.getStrategy(), - }; - } + // Add proxy account pool status when proxy mode is active + if (ctx.metadata?.accountPool) { + const pool = ctx.metadata.accountPool as { + getAllAccounts: () => Array<{ + id: string; + label?: string; + status: string; + requestCount: number; + subscriptionTier?: string; + }>; + getHealthyCount: () => number; + getStrategy: () => string; + }; + const allAccounts = pool.getAllAccounts(); + const statusCounts: Record = {}; + for (const a of allAccounts) { + statusCounts[a.status] = (statusCounts[a.status] || 0) + 1; + } + healthResponse.proxy = { + totalAccounts: allAccounts.length, + statusDistribution: statusCounts, + healthyCount: pool.getHealthyCount(), + strategy: pool.getStrategy(), + }; + span.setAttribute("health.proxy.accounts", allAccounts.length); + span.setAttribute( + "health.proxy.healthy", + pool.getHealthyCount(), + ); + } - return healthResponse; - }, + span.setAttribute("health.tools_count", tools.length); + span.setAttribute( + "health.external_servers_count", + externalServers.length, + ); + span.setAttribute( + "health.memory.heap_mb", + Math.round(process.memoryUsage().heapUsed / 1024 / 1024), + ); + + return healthResponse; + }, + ), description: "Detailed health information", tags: ["health"], }, { method: "GET", path: `${basePath}/version`, - handler: async () => { - return { - name: "@juspay/neurolink", - version: process.env.npm_package_version || "unknown", - node: process.version, - timestamp: new Date().toISOString(), - }; - }, + handler: tracedHealthHandler( + "neurolink.http.version", + `${basePath}/version`, + async () => { + return { + name: "@juspay/neurolink", + version: process.env.npm_package_version || "unknown", + node: process.version, + timestamp: new Date().toISOString(), + }; + }, + ), description: "Get version information", tags: ["health", "version"], }, diff --git a/src/lib/server/routes/index.ts b/src/lib/server/routes/index.ts index d4cf9bbf8..42ef06bc1 100644 --- a/src/lib/server/routes/index.ts +++ b/src/lib/server/routes/index.ts @@ -3,7 +3,11 @@ * Pre-built route definitions for common NeuroLink endpoints */ -import type { RouteDefinition, RouteGroup } from "../../types/index.js"; +import type { + CreateRoutesOptions, + RouteDefinition, + RouteGroup, +} from "../../types/index.js"; import { createAgentRoutes } from "./agentRoutes.js"; import { createClaudeProxyRoutes } from "./claudeProxyRoutes.js"; // ClaudeProxyDeps removed @@ -23,32 +27,6 @@ export { createMemoryRoutes } from "./memoryRoutes.js"; export { createOpenApiRoutes } from "./openApiRoutes.js"; export { createToolRoutes } from "./toolRoutes.js"; -/** - * Options for creating routes - */ -type CreateRoutesOptions = { - /** Enable OpenAPI/Swagger documentation endpoints (default: false) */ - enableSwagger?: boolean; - /** - * Callback to get registered routes for OpenAPI spec generation. - * This callback is invoked at request time when the OpenAPI spec is accessed, - * allowing it to reflect all routes registered with the adapter. - * - * When using `registerAllRoutes`, this is automatically bound to `adapter.listRoutes()` - * if the adapter supports it and no custom callback is provided. - * - * If not provided (and adapter doesn't have listRoutes), the spec will use - * default endpoint definitions. - */ - getRoutes?: () => RouteDefinition[]; - /** - * Enable or disable the Claude-compatible proxy routes. - * When true, registers /v1/messages, /v1/models, and /v1/messages/count_tokens - * endpoints that accept Anthropic API format requests. - */ - claudeProxy?: boolean; -}; - /** * Create all standard routes * Convenience method that combines all route groups diff --git a/src/lib/server/routes/mcpRoutes.ts b/src/lib/server/routes/mcpRoutes.ts index 64e1ea990..b7be7f311 100644 --- a/src/lib/server/routes/mcpRoutes.ts +++ b/src/lib/server/routes/mcpRoutes.ts @@ -3,12 +3,15 @@ * Endpoints for MCP server management */ +import { SpanStatusCode } from "@opentelemetry/api"; import { z } from "zod"; import type { MCPServerStatusResponse, RouteGroup, ServerContext, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { createErrorResponse, ServerNameParamSchema, @@ -17,6 +20,46 @@ import { validateRequest, } from "../utils/validation.js"; +/** + * Wrap a route handler with an OTel span for HTTP observability. + */ +function tracedMcpHandler( + name: string, + route: string, + fn: (ctx: ServerContext) => Promise, +): (ctx: ServerContext) => Promise { + return (ctx: ServerContext) => + withSpan( + { + name, + tracer: tracers.http, + attributes: { + "http.route": route, + "http.request.id": ctx.requestId, + }, + }, + async (otelSpan) => { + const result = await fn(ctx); + // Detect returned error responses (not thrown) and mark span as failed. + // Only flag when the error value is truthy to avoid false positives + // from handlers that always include an `error` key (e.g. handleGetServer). + if (result && typeof result === "object") { + const errVal = (result as Record).error; + if (errVal !== undefined && errVal !== null) { + const errMsg = + typeof errVal === "string" + ? errVal + : typeof (errVal as { message?: unknown })?.message === "string" + ? (errVal as { message: string }).message + : "MCP handler error"; + otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg }); + } + } + return result; + }, + ); +} + /** * MCP tool execution params schema */ @@ -434,49 +477,77 @@ export function createMCPRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/mcp/servers`, - handler: handleListServers, + handler: tracedMcpHandler( + "neurolink.http.mcp.listServers", + `${basePath}/mcp/servers`, + handleListServers, + ), description: "List all MCP servers", tags: ["mcp"], }, { method: "GET", path: `${basePath}/mcp/servers/:name`, - handler: handleGetServer, + handler: tracedMcpHandler( + "neurolink.http.mcp.getServer", + `${basePath}/mcp/servers/:name`, + handleGetServer, + ), description: "Get MCP server status", tags: ["mcp"], }, { method: "POST", path: `${basePath}/mcp/servers/:name/reconnect`, - handler: handleReconnectServer, + handler: tracedMcpHandler( + "neurolink.http.mcp.reconnectServer", + `${basePath}/mcp/servers/:name/reconnect`, + handleReconnectServer, + ), description: "Reconnect to an MCP server", tags: ["mcp"], }, { method: "DELETE", path: `${basePath}/mcp/servers/:name`, - handler: handleRemoveServer, + handler: tracedMcpHandler( + "neurolink.http.mcp.removeServer", + `${basePath}/mcp/servers/:name`, + handleRemoveServer, + ), description: "Remove an MCP server", tags: ["mcp"], }, { method: "GET", path: `${basePath}/mcp/servers/:name/tools`, - handler: handleListServerTools, + handler: tracedMcpHandler( + "neurolink.http.mcp.listServerTools", + `${basePath}/mcp/servers/:name/tools`, + handleListServerTools, + ), description: "List tools from a specific MCP server", tags: ["mcp", "tools"], }, { method: "POST", path: `${basePath}/mcp/servers/:name/tools/:toolName/execute`, - handler: handleExecuteTool, + handler: tracedMcpHandler( + "neurolink.http.mcp.executeTool", + `${basePath}/mcp/servers/:name/tools/:toolName/execute`, + handleExecuteTool, + ), description: "Execute a tool from a specific MCP server", tags: ["mcp", "tools"], }, { method: "GET", path: `${basePath}/mcp/health`, - handler: handleMCPHealth, + handler: tracedMcpHandler( + "neurolink.http.mcp.health", + `${basePath}/mcp/health`, + handleMCPHealth, + ), description: "Health check for all MCP servers", tags: ["mcp", "health"], }, diff --git a/src/lib/server/routes/memoryRoutes.ts b/src/lib/server/routes/memoryRoutes.ts index fcb84fe45..df53f7ca7 100644 --- a/src/lib/server/routes/memoryRoutes.ts +++ b/src/lib/server/routes/memoryRoutes.ts @@ -4,6 +4,8 @@ */ import type { RouteGroup, ServerContext } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { createErrorResponse, IdParamSchema, @@ -11,6 +13,28 @@ import { validateParams, } from "../utils/validation.js"; +/** + * Wrap a route handler with an OTel span for HTTP observability. + */ +function tracedMemoryHandler( + name: string, + route: string, + fn: (ctx: ServerContext) => Promise, +): (ctx: ServerContext) => Promise { + return (ctx: ServerContext) => + withSpan( + { + name, + tracer: tracers.http, + attributes: { + "http.route": route, + "http.request.id": ctx.requestId, + }, + }, + () => fn(ctx), + ); +} + /** * Handler: Get messages for a session */ @@ -503,7 +527,11 @@ export function createMemoryRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/memory/sessions/:id/messages`, - handler: handleGetSessionMessages, + handler: tracedMemoryHandler( + "neurolink.http.memory.getSessionMessages", + `${basePath}/memory/sessions/:id/messages`, + handleGetSessionMessages, + ), description: "Get messages for a session", tags: ["memory"], }, @@ -511,7 +539,11 @@ export function createMemoryRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/memory/sessions/:id`, - handler: handleGetSession, + handler: tracedMemoryHandler( + "neurolink.http.memory.getSession", + `${basePath}/memory/sessions/:id`, + handleGetSession, + ), description: "Get session by ID", tags: ["memory"], }, @@ -519,35 +551,55 @@ export function createMemoryRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/memory/sessions`, - handler: handleListSessions, + handler: tracedMemoryHandler( + "neurolink.http.memory.listSessions", + `${basePath}/memory/sessions`, + handleListSessions, + ), description: "List all conversation sessions", tags: ["memory"], }, { method: "GET", path: `${basePath}/memory/stats`, - handler: handleGetStats, + handler: tracedMemoryHandler( + "neurolink.http.memory.stats", + `${basePath}/memory/stats`, + handleGetStats, + ), description: "Get memory statistics", tags: ["memory"], }, { method: "DELETE", path: `${basePath}/memory/sessions/:sessionId`, - handler: handleClearSession, + handler: tracedMemoryHandler( + "neurolink.http.memory.clearSession", + `${basePath}/memory/sessions/:sessionId`, + handleClearSession, + ), description: "Clear a conversation session", tags: ["memory"], }, { method: "DELETE", path: `${basePath}/memory/sessions`, - handler: handleClearAllSessions, + handler: tracedMemoryHandler( + "neurolink.http.memory.clearAllSessions", + `${basePath}/memory/sessions`, + handleClearAllSessions, + ), description: "Clear all conversation sessions", tags: ["memory"], }, { method: "GET", path: `${basePath}/memory/health`, - handler: handleMemoryHealth, + handler: tracedMemoryHandler( + "neurolink.http.memory.health", + `${basePath}/memory/health`, + handleMemoryHealth, + ), description: "Check memory system health", tags: ["memory", "health"], }, diff --git a/src/lib/server/routes/openApiRoutes.ts b/src/lib/server/routes/openApiRoutes.ts index 3483984a7..0b98f813a 100644 --- a/src/lib/server/routes/openApiRoutes.ts +++ b/src/lib/server/routes/openApiRoutes.ts @@ -6,6 +6,8 @@ import { logger } from "../../utils/logger.js"; import { OpenAPIGenerator } from "../openapi/generator.js"; import type { RouteDefinition, RouteGroup } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; /** * Create OpenAPI documentation routes @@ -53,64 +55,99 @@ export function createOpenApiRoutes( { method: "GET", path: `${basePath}/openapi.json`, - handler: async () => { - const routes = getRoutes?.() ?? []; + handler: async () => + withSpan( + { + name: "neurolink.http.openapi.json", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/openapi.json`, + "openapi.format": "json", + }, + }, + async (span) => { + const routes = getRoutes?.() ?? []; - if (!getRoutes) { - logger.warn( - "[OpenAPI] No getRoutes callback provided. OpenAPI spec will use default endpoint definitions. " + - "Use registerAllRoutes(server, basePath, { enableSwagger: true }) to automatically include your routes.", - ); - } else if (routes.length === 0) { - logger.debug( - "[OpenAPI] getRoutes returned empty array. No custom routes will be documented.", - ); - } + if (!getRoutes) { + logger.warn( + "[OpenAPI] No getRoutes callback provided. OpenAPI spec will use default endpoint definitions. " + + "Use registerAllRoutes(server, basePath, { enableSwagger: true }) to automatically include your routes.", + ); + } else if (routes.length === 0) { + logger.debug( + "[OpenAPI] getRoutes returned empty array. No custom routes will be documented.", + ); + } - const generator = new OpenAPIGenerator({ - basePath, - routes, - }); - return generator.generate(); - }, + span.setAttribute("openapi.route_count", routes.length); + + const generator = new OpenAPIGenerator({ + basePath, + routes, + }); + return generator.generate(); + }, + ), description: "Get OpenAPI specification as JSON", tags: ["openapi", "documentation"], }, { method: "GET", path: `${basePath}/openapi.yaml`, - handler: async () => { - const routes = getRoutes?.() ?? []; + handler: async () => + withSpan( + { + name: "neurolink.http.openapi.yaml", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/openapi.yaml`, + "openapi.format": "yaml", + }, + }, + async (span) => { + const routes = getRoutes?.() ?? []; + + if (!getRoutes) { + logger.warn( + "[OpenAPI] No getRoutes callback provided. OpenAPI spec will use default endpoint definitions. " + + "Use registerAllRoutes(server, basePath, { enableSwagger: true }) to automatically include your routes.", + ); + } else if (routes.length === 0) { + logger.debug( + "[OpenAPI] getRoutes returned empty array. No custom routes will be documented.", + ); + } - if (!getRoutes) { - logger.warn( - "[OpenAPI] No getRoutes callback provided. OpenAPI spec will use default endpoint definitions. " + - "Use registerAllRoutes(server, basePath, { enableSwagger: true }) to automatically include your routes.", - ); - } else if (routes.length === 0) { - logger.debug( - "[OpenAPI] getRoutes returned empty array. No custom routes will be documented.", - ); - } + span.setAttribute("openapi.route_count", routes.length); - const generator = new OpenAPIGenerator({ - basePath, - routes, - }); - return { - _raw: true, - contentType: "text/yaml", - body: generator.toYAML(), - }; - }, + const generator = new OpenAPIGenerator({ + basePath, + routes, + }); + return { + _raw: true, + contentType: "text/yaml", + body: generator.toYAML(), + }; + }, + ), description: "Get OpenAPI specification as YAML", tags: ["openapi", "documentation"], }, { method: "GET", path: `${basePath}/docs`, - handler: async () => { - const html = ` + handler: async () => + withSpan( + { + name: "neurolink.http.openapi.docs", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/docs`, + }, + }, + async () => { + const html = ` NeuroLink API Documentation @@ -131,12 +168,13 @@ export function createOpenApiRoutes( `; - return { - _raw: true, - contentType: "text/html", - body: html, - }; - }, + return { + _raw: true, + contentType: "text/html", + body: html, + }; + }, + ), description: "Swagger UI documentation page", tags: ["openapi", "documentation"], }, diff --git a/src/lib/server/routes/toolRoutes.ts b/src/lib/server/routes/toolRoutes.ts index bb2d787ad..de699cde9 100644 --- a/src/lib/server/routes/toolRoutes.ts +++ b/src/lib/server/routes/toolRoutes.ts @@ -3,12 +3,15 @@ * Endpoints for tool listing, discovery, and execution */ +import { SpanStatusCode } from "@opentelemetry/api"; import type { RouteGroup, ServerContext, ToolExecuteRequest, ToolExecuteResponse, } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { createErrorResponse, ToolArgumentsSchema, @@ -34,19 +37,28 @@ export function createToolRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/tools`, - handler: async (ctx: ServerContext) => { - const tools = await ctx.toolRegistry.listTools(); + handler: async (ctx: ServerContext) => + withSpan( + { + name: "neurolink.http.tools.list", + tracer: tracers.http, + attributes: { "http.route": `${basePath}/tools` }, + }, + async (span) => { + const tools = await ctx.toolRegistry.listTools(); + span.setAttribute("tools.count", tools.length); - return { - tools: tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - source: tool.source || "built-in", - })), - total: tools.length, - }; - }, + return { + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + source: tool.source || "built-in", + })), + total: tools.length, + }; + }, + ), description: "List all available tools", tags: ["tools"], }, @@ -54,44 +66,55 @@ export function createToolRoutes(basePath: string = "/api"): RouteGroup { { method: "GET", path: `${basePath}/tools/search`, - handler: async (ctx: ServerContext) => { - const { q, source, limit } = ctx.query; - const tools = await ctx.toolRegistry.listTools(); + handler: async (ctx: ServerContext) => + withSpan( + { + name: "neurolink.http.tools.search", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/tools/search`, + "tools.search.query": (ctx.query.q as string) ?? "", + }, + }, + async () => { + const { q, source, limit } = ctx.query; + const tools = await ctx.toolRegistry.listTools(); - let filtered = tools; + let filtered = tools; - // Filter by search query - if (q) { - const query = q.toLowerCase(); - filtered = filtered.filter( - (tool) => - tool.name.toLowerCase().includes(query) || - (tool.description && - tool.description.toLowerCase().includes(query)), - ); - } + // Filter by search query + if (q) { + const query = q.toLowerCase(); + filtered = filtered.filter( + (tool) => + tool.name.toLowerCase().includes(query) || + (tool.description && + tool.description.toLowerCase().includes(query)), + ); + } - // Filter by source - if (source) { - filtered = filtered.filter( - (tool) => (tool.source || "built-in") === source, - ); - } + // Filter by source + if (source) { + filtered = filtered.filter( + (tool) => (tool.source || "built-in") === source, + ); + } - // Apply limit - const maxResults = limit ? parseInt(limit, 10) : 50; - filtered = filtered.slice(0, maxResults); + // Apply limit + const maxResults = limit ? parseInt(limit, 10) : 50; + filtered = filtered.slice(0, maxResults); - return { - tools: filtered.map((tool) => ({ - name: tool.name, - description: tool.description, - source: tool.source || "built-in", - })), - total: filtered.length, - query: q || null, - }; - }, + return { + tools: filtered.map((tool) => ({ + name: tool.name, + description: tool.description, + source: tool.source || "built-in", + })), + total: filtered.length, + query: q || null, + }; + }, + ), description: "Search tools by name or description", tags: ["tools"], }, @@ -116,45 +139,72 @@ export function createToolRoutes(basePath: string = "/api"): RouteGroup { } const request = validation.data as ToolExecuteRequest; - const startTime = Date.now(); - try { - // Get tool from registry - const tools = await ctx.toolRegistry.listTools(); - const tool = tools.find((t) => t.name === request.name); + return withSpan( + { + name: "neurolink.http.tools.execute", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/tools/execute`, + "tool.name": request.name, + }, + }, + async (span) => { + const startTime = Date.now(); - if (!tool) { - return { - success: false, - error: `Tool '${request.name}' not found`, - duration: Date.now() - startTime, - }; - } + try { + // Get tool from registry + const tools = await ctx.toolRegistry.listTools(); + const tool = tools.find((t) => t.name === request.name); - // Execute the tool - const result = await ctx.toolRegistry.executeTool( - request.name, - request.arguments, - ); + if (!tool) { + span.setAttribute("tool.found", false); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `Tool '${request.name}' not found`, + }); + return { + success: false, + error: `Tool '${request.name}' not found`, + duration: Date.now() - startTime, + }; + } - return { - success: true, - data: result, - duration: Date.now() - startTime, - metadata: { - toolName: request.name, - sessionId: request.sessionId || null, - }, - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - return { - success: false, - error: errorMessage, - duration: Date.now() - startTime, - }; - } + // Execute the tool + const result = await ctx.toolRegistry.executeTool( + request.name, + request.arguments, + ); + + span.setAttribute("tool.success", true); + return { + success: true, + data: result, + duration: Date.now() - startTime, + metadata: { + toolName: request.name, + sessionId: request.sessionId || null, + }, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + span.setAttribute("tool.success", false); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage, + }); + span.recordException( + error instanceof Error ? error : new Error(errorMessage), + ); + return { + success: false, + error: errorMessage, + duration: Date.now() - startTime, + }; + } + }, + ); }, description: "Execute a tool with arguments", tags: ["tools"], @@ -193,41 +243,68 @@ export function createToolRoutes(basePath: string = "/api"): RouteGroup { } const args = bodyValidation.data; - const startTime = Date.now(); - try { - // Get tool from registry - const tools = await ctx.toolRegistry.listTools(); - const tool = tools.find((t) => t.name === name); + return withSpan( + { + name: "neurolink.http.tools.executeByName", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/tools/:name/execute`, + "tool.name": name, + }, + }, + async (span) => { + const startTime = Date.now(); - if (!tool) { - return { - success: false, - error: `Tool '${name}' not found`, - duration: Date.now() - startTime, - }; - } + try { + // Get tool from registry + const tools = await ctx.toolRegistry.listTools(); + const tool = tools.find((t) => t.name === name); - // Execute the tool - const result = await ctx.toolRegistry.executeTool(name, args); + if (!tool) { + span.setAttribute("tool.found", false); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `Tool '${name}' not found`, + }); + return { + success: false, + error: `Tool '${name}' not found`, + duration: Date.now() - startTime, + }; + } - return { - success: true, - data: result, - duration: Date.now() - startTime, - metadata: { - toolName: name, - }, - }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - return { - success: false, - error: errorMessage, - duration: Date.now() - startTime, - }; - } + // Execute the tool + const result = await ctx.toolRegistry.executeTool(name, args); + + span.setAttribute("tool.success", true); + return { + success: true, + data: result, + duration: Date.now() - startTime, + metadata: { + toolName: name, + }, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + span.setAttribute("tool.success", false); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage, + }); + span.recordException( + error instanceof Error ? error : new Error(errorMessage), + ); + return { + success: false, + error: errorMessage, + duration: Date.now() - startTime, + }; + } + }, + ); }, description: "Execute a specific tool by name", tags: ["tools"], @@ -249,24 +326,44 @@ export function createToolRoutes(basePath: string = "/api"): RouteGroup { } const { name } = paramValidation.data; - const tools = await ctx.toolRegistry.listTools(); - const tool = tools.find((t) => t.name === name); - if (!tool) { - return createErrorResponse( - "TOOL_NOT_FOUND", - `Tool '${name}' not found`, - undefined, - ctx.requestId, - ); - } + return withSpan( + { + name: "neurolink.http.tools.get", + tracer: tracers.http, + attributes: { + "http.route": `${basePath}/tools/:name`, + "tool.name": name, + }, + }, + async (span) => { + const tools = await ctx.toolRegistry.listTools(); + const tool = tools.find((t) => t.name === name); + + if (!tool) { + span.setAttribute("tool.found", false); + span.setStatus({ + code: SpanStatusCode.ERROR, + message: `Tool '${name}' not found`, + }); + return createErrorResponse( + "TOOL_NOT_FOUND", + `Tool '${name}' not found`, + undefined, + ctx.requestId, + ); + } - return { - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - source: tool.source || "built-in", - }; + span.setAttribute("tool.found", true); + + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + source: tool.source || "built-in", + }; + }, + ); }, description: "Get tool details by name", tags: ["tools"], diff --git a/src/lib/server/streaming/dataStream.ts b/src/lib/server/streaming/dataStream.ts index 74790c775..67aa3044b 100644 --- a/src/lib/server/streaming/dataStream.ts +++ b/src/lib/server/streaming/dataStream.ts @@ -5,47 +5,19 @@ */ import type { - DataStreamWriter, CloseHandler, DataStreamEvent, + DataStreamEventType, + DataStreamResponseConfig, + DataStreamWriter, + DataStreamWriterConfig, + FinishEvent, + SSEEventOptions, } from "../../types/index.js"; -import type { DataStreamEventType } from "../../types/index.js"; -// ============================================ -// Event Types -// ============================================ -/** - * Finish event - */ -type FinishEvent = DataStreamEvent & { - type: "finish"; - data: { - reason?: string; - usage?: { - input: number; - output: number; - total: number; - }; - }; -}; - // ============================================ // Data Stream Writer Implementation // ============================================ -/** - * Configuration for DataStreamWriter - */ -type DataStreamWriterConfig = { - /** Writer function to send data */ - write: (chunk: string) => void | Promise; - /** Function to close the stream */ - close?: () => void | Promise; - /** Format: sse (Server-Sent Events) or ndjson (Newline-delimited JSON) */ - format?: "sse" | "ndjson"; - /** Include timestamps in events */ - includeTimestamps?: boolean; -}; - /** * Creates a data stream writer */ @@ -141,16 +113,6 @@ export function createDataStreamWriter( /** * Configuration for DataStreamResponse */ -type DataStreamResponseConfig = { - /** Content type header */ - contentType?: "text/event-stream" | "application/x-ndjson"; - /** Initial headers */ - headers?: Record; - /** Keep-alive interval in milliseconds */ - keepAliveInterval?: number; - /** Include timestamps in events */ - includeTimestamps?: boolean; -}; /** * Data stream response class @@ -474,16 +436,6 @@ export function createNDJSONHeaders( /** * SSE Event options for formatSSEEvent */ -type SSEEventOptions = { - /** Event type (optional) */ - event?: string; - /** Event data (required) */ - data: string; - /** Event ID (optional) */ - id?: string; - /** Retry interval in milliseconds (optional) */ - retry?: number; -}; /** * Format a Server-Sent Events (SSE) message diff --git a/src/lib/server/voice/voiceWebSocketHandler.ts b/src/lib/server/voice/voiceWebSocketHandler.ts index 105d96e0a..74d9256fd 100644 --- a/src/lib/server/voice/voiceWebSocketHandler.ts +++ b/src/lib/server/voice/voiceWebSocketHandler.ts @@ -7,31 +7,16 @@ import { CartesiaStream } from "../../adapters/tts/cartesiaHandler.js"; import { NeuroLink } from "../../neurolink.js"; import { logger } from "../../utils/logger.js"; import { withTimeout } from "../../utils/async/withTimeout.js"; +import type { + ClientControlMessage, + ConversationMessage, + Message, + SonioxMessage, +} from "../../types/index.js"; const SONIOX_URL = process.env.SONIOX_WS_URL ?? "wss://stt-rt.soniox.com/transcribe-websocket"; -type ConversationMessage = { - role: "user" | "assistant"; - content: string; -}; - -type SonioxToken = { - is_final?: boolean; - text?: string; -}; - -type SonioxMessage = { - error?: string; - status?: string; - type?: string; - tokens?: SonioxToken[]; -}; - -type ClientControlMessage = { - type?: string; -}; - function getRequiredEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -113,11 +98,6 @@ function parseClientControlMessage(data: string): ClientControlMessage | null { } } -type Message = { - role: "system" | "user" | "assistant"; - content: string; -}; - async function streamAnswer( neurolink: NeuroLink, messages: Message[], diff --git a/src/lib/services/server/ai/observability/instrumentation.ts b/src/lib/services/server/ai/observability/instrumentation.ts index 4bd419f19..53f64ca4a 100644 --- a/src/lib/services/server/ai/observability/instrumentation.ts +++ b/src/lib/services/server/ai/observability/instrumentation.ts @@ -9,7 +9,7 @@ import { LangfuseSpanProcessor } from "@langfuse/otel"; import type { Context } from "@opentelemetry/api"; -import { metrics, trace } from "@opentelemetry/api"; +import { metrics, SpanStatusCode, trace } from "@opentelemetry/api"; import { W3CTraceContextPropagator } from "@opentelemetry/core"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; @@ -492,29 +492,151 @@ class ContextEnricher implements SpanProcessor { attributes["gen_ai.request.model"]; if (isGenAISpan) { + const model = + (attributes["gen_ai.request.model"] as string) || + (attributes["ai.model.id"] as string); + const provider = + (attributes["gen_ai.system"] as string) || + (attributes["ai.model.provider"] as string); + logger.debug(`${LOG_PREFIX} GenAI span detected`, { spanName: readableSpan.name, - model: - attributes["gen_ai.request.model"] || attributes["ai.model.id"], - provider: - attributes["gen_ai.system"] || attributes["ai.model.provider"], + model, + provider, }); - // Log token usage for observability - const inputTokens = - attributes["gen_ai.usage.input_tokens"] || - attributes["ai.usage.promptTokens"]; - const outputTokens = - attributes["gen_ai.usage.output_tokens"] || - attributes["ai.usage.completionTokens"]; - - if (inputTokens !== undefined || outputTokens !== undefined) { - logger.debug(`${LOG_PREFIX} Token usage captured`, { - inputTokens, - outputTokens, - totalTokens: attributes["gen_ai.usage.total_tokens"], - }); + // L4/L6 fix: Set explicit Langfuse observation attributes so + // cost dashboards and model analytics work correctly. + try { + const mAttrs = ( + span as unknown as { attributes: Record } + ).attributes; + + // L6: Model identity + if (model) { + mAttrs["gen_ai.response.model"] = model; + } + + // L4: Usage details — aggregate from AI SDK attributes into a + // structured JSON object that Langfuse can parse for cost analysis. + const inputTokens = + (attributes["gen_ai.usage.input_tokens"] as number) ?? + (attributes["ai.usage.promptTokens"] as number); + const outputTokens = + (attributes["gen_ai.usage.output_tokens"] as number) ?? + (attributes["ai.usage.completionTokens"] as number); + const totalTokens = + (attributes["gen_ai.usage.total_tokens"] as number) ?? + (inputTokens !== undefined && outputTokens !== undefined + ? inputTokens + outputTokens + : undefined); + const reasoningTokens = + (attributes["gen_ai.usage.reasoning_tokens"] as number) ?? + (attributes["ai.usage.reasoningTokens"] as number); + const cachedTokens = attributes[ + "gen_ai.usage.input_cached_tokens" + ] as number; + + if (inputTokens !== undefined || outputTokens !== undefined) { + const usageDetails: Record = {}; + if (inputTokens !== undefined) { + usageDetails.input = inputTokens; + } + if (outputTokens !== undefined) { + usageDetails.output = outputTokens; + } + if (totalTokens !== undefined) { + usageDetails.total = totalTokens; + } + if (reasoningTokens !== undefined) { + usageDetails.reasoning_tokens = reasoningTokens; + } + if (cachedTokens !== undefined) { + usageDetails.input_cached_tokens = cachedTokens; + } + mAttrs["langfuse.usage_details"] = JSON.stringify(usageDetails); + + logger.debug(`${LOG_PREFIX} Token usage captured`, { + inputTokens, + outputTokens, + totalTokens, + }); + } + + // L7: Model parameters — surface temperature and max_tokens for + // generation tuning visibility. + const temperature = + attributes["gen_ai.request.temperature"] ?? + attributes["ai.settings.temperature"]; + const maxTokens = + attributes["gen_ai.request.max_tokens"] ?? + attributes["ai.settings.maxTokens"]; + const topP = + attributes["gen_ai.request.top_p"] ?? + attributes["ai.settings.topP"]; + if ( + temperature !== undefined || + maxTokens !== undefined || + topP !== undefined + ) { + const params: Record = {}; + if (temperature !== undefined) { + params.temperature = temperature; + } + if (maxTokens !== undefined) { + params.max_tokens = maxTokens; + } + if (topP !== undefined) { + params.top_p = topP; + } + mAttrs["gen_ai.request.model_parameters"] = JSON.stringify(params); + } + } catch { + // Read-only attributes — cannot enrich; Pipeline A will still + // export the raw GenAI attributes that Langfuse can parse. + } + } + + // P8 fix: Propagate error status to Langfuse-consumable attributes. + // OTel ReadableSpan attributes may be readonly at onEnd() time; the type + // cast attempts late mutation. LangfuseSpanProcessor runs after + // ContextEnricher in the spanProcessors array and reads these attributes, + // so setting them here allows Langfuse to surface the correct level and + // status message on the trace/generation. + const readableStatus = ( + span as unknown as { status?: { code?: number; message?: string } } + ).status; + try { + const mutableAttrs = ( + span as unknown as { attributes: Record } + ).attributes; + + if (readableStatus?.code === SpanStatusCode.ERROR) { + mutableAttrs["langfuse.level"] = "ERROR"; + if (readableStatus.message) { + mutableAttrs["langfuse.status_message"] = readableStatus.message; + } + } else { + // P8 extended: Detect WARNING-level conditions on non-ERROR spans. + // The AI SDK sets ai.finishReason on its spans; content-filter and + // length finish reasons indicate partial failures that deserve WARNING. + const finishReason = + mutableAttrs["ai.finishReason"] ?? + mutableAttrs["gen_ai.response.finish_reasons"]; + const reasonStr = Array.isArray(finishReason) + ? finishReason.join(",") + : String(finishReason ?? ""); + if (reasonStr.includes("content-filter") || reasonStr === "length") { + mutableAttrs["langfuse.level"] = "WARNING"; + mutableAttrs["langfuse.status_message"] = + `Generation stopped: finishReason=${reasonStr}`; + } } + } catch { + // Readonly enforcement by OTel SDK — mutation not possible; log at debug. + logger.debug( + `${LOG_PREFIX} Could not set langfuse.level on span (read-only attributes)`, + ); } } catch (error) { // Don't fail span processing on errors diff --git a/src/lib/session/globalSessionState.ts b/src/lib/session/globalSessionState.ts index a5fe35ffd..e11d93ebc 100644 --- a/src/lib/session/globalSessionState.ts +++ b/src/lib/session/globalSessionState.ts @@ -2,8 +2,10 @@ import { nanoid } from "nanoid"; import { NeuroLink } from "../neurolink.js"; import type { ConversationMemoryConfig, - NeurolinkConstructorConfig, + LoopSessionState, McpOutputStrategy, + NeurolinkConstructorConfig, + SessionVariableValue, } from "../types/index.js"; import { buildObservabilityConfigFromEnv } from "../utils/observabilityHelpers.js"; @@ -43,17 +45,6 @@ function buildMcpOutputLimitsFromEnv(): }; } -// Define a specific type for session variable values -type SessionVariableValue = string | number | boolean; - -type LoopSessionState = { - neurolinkInstance: NeuroLink; - sessionId: string; - isActive: boolean; - conversationMemoryConfig?: ConversationMemoryConfig; - sessionVariables: Record; -}; - export class GlobalSessionManager { private static instance: GlobalSessionManager; private loopSession: LoopSessionState | null = null; diff --git a/src/lib/telemetry/traceContext.ts b/src/lib/telemetry/traceContext.ts new file mode 100644 index 000000000..29485f49c --- /dev/null +++ b/src/lib/telemetry/traceContext.ts @@ -0,0 +1,22 @@ +import { trace, context } from "@opentelemetry/api"; + +/** + * Extract the current OTel trace context for use by Pipeline B spans. + * Returns undefined values when no OTel context is active, allowing + * Pipeline B spans to share the same trace as Pipeline A spans. + */ +export function getActiveTraceContext(): { + traceId?: string; + parentSpanId?: string; +} { + const activeSpan = trace.getSpan(context.active()); + if (!activeSpan) { + return {}; + } + const ctx = activeSpan.spanContext(); + // Invalid trace IDs are all zeros — don't use those + if (!ctx.traceId || ctx.traceId === "00000000000000000000000000000000") { + return {}; + } + return { traceId: ctx.traceId, parentSpanId: ctx.spanId }; +} diff --git a/src/lib/telemetry/tracers.ts b/src/lib/telemetry/tracers.ts index 618464c15..7f8112512 100644 --- a/src/lib/telemetry/tracers.ts +++ b/src/lib/telemetry/tracers.ts @@ -16,4 +16,6 @@ export const tracers = { processor: trace.getTracer("neurolink.processor"), file: trace.getTracer("neurolink.file"), autoresearch: trace.getTracer("neurolink.autoresearch"), + auth: trace.getTracer("neurolink.auth"), + workflow: trace.getTracer("neurolink.workflow"), } as const; diff --git a/src/lib/types/action.ts b/src/lib/types/action.ts index 23e85c7af..2dd42e5d1 100644 --- a/src/lib/types/action.ts +++ b/src/lib/types/action.ts @@ -263,3 +263,6 @@ export type ActionInputValidation = { export type ProviderKeyMapping = { [K in AIProviderName]?: (keyof ActionProviderKeys)[]; }; + +/** Provider-to-required-keys map used by actionInputs.ts. */ +export type ProviderKeyMap = Record; diff --git a/src/lib/types/artifact.ts b/src/lib/types/artifact.ts index 36e42a9af..ff7c57ec0 100644 --- a/src/lib/types/artifact.ts +++ b/src/lib/types/artifact.ts @@ -79,3 +79,9 @@ export type ArtifactStore = { /** Generate a short preview string from a serialized payload. */ generatePreview(payload: string): string; }; + +/** + * In-memory index row tracked by LocalTempArtifactStore. + * Combines metadata with the on-disk path. + */ +export type IndexEntry = ArtifactMeta & { path: string }; diff --git a/src/lib/types/auth.ts b/src/lib/types/auth.ts index 86dd50a3a..f52aecf6f 100644 --- a/src/lib/types/auth.ts +++ b/src/lib/types/auth.ts @@ -978,25 +978,6 @@ export type AuthProviderFactoryFn = ( config: AuthProviderConfig, ) => Promise; -/** - * Auth provider registration entry - */ -export type AuthProviderRegistration = { - /** Provider type */ - type: AuthProviderType; - /** Factory function */ - factory: AuthProviderFactoryFn; - /** Provider aliases */ - aliases: string[]; - /** Provider metadata */ - metadata?: { - name: string; - description: string; - version?: string; - documentation?: string; - }; -}; - // ============================================================================= // HEALTH CHECK TYPES // ============================================================================= @@ -1269,3 +1250,179 @@ export type SessionManagerStorage = { /** Health check */ isHealthy(): Promise; }; + +/** Cached JWKS entry with TTL. Used by Cognito and Keycloak providers. */ +export type AuthJWKSCacheEntry = { + jwks: JWKS; + expiresAt: number; +}; + +// ============================================================================= +// AUTH PROVIDER FACTORY (from auth/AuthProviderFactory.ts) +// ============================================================================= + +/** Async constructor for an auth provider given its config. */ +export type AuthProviderConstructor = ( + config: AuthProviderConfig, +) => Promise; + +/** Registration row for an auth provider in AuthProviderFactory. */ +export type AuthProviderRegistration = { + factory: AuthProviderConstructor; + aliases: string[]; + metadata?: AuthProviderMetadata; +}; + +// ============================================================================= +// CLAUDE CODE IDENTITY (from auth/anthropicOAuth.ts) +// ============================================================================= + +/** Synthetic Claude Code client identity used for quota + identification. */ +export type ClaudeCodeIdentity = { + deviceId: string; + accountUuid: string; + sessionId: string; + metadataUserId: string; +}; + +// ============================================================================= +// AUTH MIDDLEWARE (from auth/middleware/AuthMiddleware.ts) +// ============================================================================= + +/** Minimal request object accepted by the auth middleware. */ +export type IncomingRequest = { + method?: string; + url?: string; + path?: string; + headers?: Record; + cookies?: Record; + query?: Record; + body?: unknown; + ip?: string; + user?: AuthUser; + authContext?: AuthenticatedContext; +}; + +/** Minimal Express-style response object used by the auth middleware. */ +export type OutgoingResponse = { + status(code: number): OutgoingResponse; + json(body: unknown): void; +}; + +/** Middleware handler function type for the auth layer. */ +export type AuthMiddlewareHandler = ( + context: TContext, +) => Promise; + +/** Result produced by an auth middleware handler. */ +export type AuthMiddlewareResult = { + proceed: boolean; + context?: AuthenticatedContext; + error?: { + statusCode: number; + message: string; + code?: string; + }; +}; + +/** Next-function for Express-style middleware chaining. */ +export type NextFunction = () => Promise; + +/** Express-style auth middleware signature. */ +export type ExpressMiddleware = ( + req: IncomingRequest, + res: OutgoingResponse, + next: NextFunction, +) => Promise; + +// ============================================================================= +// RATE LIMIT (from auth/middleware/rateLimitByUser.ts) +// ============================================================================= + +/** Token bucket state for a single user. */ +export type TokenBucket = { + tokens: number; + lastRefill: number; + userId: string; +}; + +/** Rate limit configuration per user or role. */ +export type AuthRateLimitConfig = { + maxRequests: number; + windowMs: number; + roleLimits?: Record; + userLimits?: Record; + skipRoles?: string[]; + message?: string; +}; + +/** Rate limit result. */ +export type RateLimitResult = { + allowed: boolean; + remaining: number; + resetIn: number; + limit: number; + error?: string; +}; + +/** Result of an atomic consume operation against a token bucket. */ +export type AtomicConsumeResult = { + bucket: TokenBucket; + consumed: boolean; +}; + +/** Storage contract for rate-limit buckets (memory or Redis). */ +export type RateLimitStorage = { + getBucket(userId: string): Promise; + setBucket(userId: string, bucket: TokenBucket): Promise; + deleteBucket(userId: string): Promise; + healthCheck(): Promise; + cleanup(): Promise; + atomicConsume?( + userId: string, + limit: number, + windowMs: number, + nowMs: number, + ): Promise; +}; + +/** + * Minimal Redis client shape used by the rate-limiter to avoid a hard + * dependency on the full `RedisClientType`. Named with an Auth prefix to + * avoid collision with `RedisClientType` from the redis package. + */ +export type AuthRateLimitRedisClient = { + connect(): Promise; + quit(): Promise; + ping(): Promise; + get(key: string): Promise; + setEx(key: string, seconds: number, value: string): Promise; + del(key: string): Promise; + eval(script: string, numkeys: number, ...args: string[]): Promise; +}; + +/** Middleware-level outcome returned by the rate limiter. */ +export type RateLimitMiddlewareResult = { + proceed: boolean; + rateLimitResult: RateLimitResult; + response?: Response; +}; + +// ============================================================================= +// AUTH0 (from auth/providers/auth0.ts) +// ============================================================================= + +/** Auth0 JWT payload structure. */ +export type Auth0TokenPayload = { + sub: string; + email?: string; + name?: string; + picture?: string; + email_verified?: boolean; + roles?: string[]; + permissions?: string[]; + iat: number; + exp: number; + aud: string | string[]; + iss: string; +}; diff --git a/src/lib/types/autoresearch.ts b/src/lib/types/autoresearch.ts index 97199f04c..60a5a9c21 100644 --- a/src/lib/types/autoresearch.ts +++ b/src/lib/types/autoresearch.ts @@ -6,6 +6,10 @@ * keeps or discards each change — running unattended for hours. */ +import type { RepoPolicy } from "../autoresearch/repoPolicy.js"; +import type { ResearchStateStore } from "../autoresearch/stateStore.js"; +import type { ResultRecorder } from "../autoresearch/resultRecorder.js"; +import type { ExperimentRunner } from "../autoresearch/runner.js"; import type { ThinkingLevel } from "./config.js"; // ── Metric Configuration ───────────────────────────────── @@ -249,3 +253,16 @@ export const AUTORESEARCH_DEFAULTS = { branchPrefix: "autoresearch/", thinkingLevel: "medium" as ThinkingLevel, } as const; + +// ============================================================================= +// RESEARCH TOOLS FACTORY (from autoresearch/tools.ts) +// ============================================================================= + +/** Dependencies required to create research tools. */ +export type ResearchToolsDeps = { + config: ResearchConfig; + stateStore: ResearchStateStore; + repoPolicy: RepoPolicy; + runner: ExperimentRunner; + recorder: ResultRecorder; +}; diff --git a/src/lib/types/cli.ts b/src/lib/types/cli.ts index 7768873bd..3e4fc733c 100644 --- a/src/lib/types/cli.ts +++ b/src/lib/types/cli.ts @@ -659,6 +659,18 @@ export type SetupArgs = { help?: boolean; }; +/** + * Narrowed ProviderInfo used by the main `neurolink setup` command, + * where the descriptive fields are always populated. + */ +export type SetupProviderInfo = ProviderInfo & + Required< + Pick< + ProviderInfo, + "bestFor" | "models" | "strengths" | "pricing" | "setupCommand" + > + >; + /** * Provider information for setup display */ @@ -1272,3 +1284,511 @@ export type SetupResult = { responseTime?: number; }>; }; + +/** + * Shared options for every provider-specific CLI setup command + * (anthropic, azure, bedrock, gcp, google-ai, openai). + */ +export type ProviderSetupOptions = { + checkOnly?: boolean; + interactive?: boolean; +}; + +/** Shared yargs-argv shape for every provider-specific CLI setup command. */ +export type ProviderSetupArgv = { + check?: boolean; + nonInteractive?: boolean; +}; + +/** + * Superset provider-setup config. `endpoint` is Azure-only; other providers + * leave it undefined. Pre-consolidation there were 4 near-duplicate types + * (Anthropic/Azure/GoogleAI/OpenAI); they are now one. + */ +export type ProviderSetupConfig = { + apiKey?: string; + model?: string; + endpoint?: string; + isReconfiguring?: boolean; +}; + +// ============================================================================= +// AUTH COMMAND (from cli/commands/auth.ts) +// ============================================================================= + +/** Providers supported by the `neurolink auth` command. */ +export type SupportedProvider = "anthropic"; + +// ============================================================================= +// AUTORESEARCH COMMAND (from cli/commands/autoresearch.ts) +// ============================================================================= + +/** Arguments for `neurolink autoresearch init`. */ +export type AutoresearchInitArgs = { + repoPath: string; + tag: string; + target: string; + immutable: string; + runCommand: string; + metricName: string; + metricPattern: string; + metricDirection: string; + timeout: number; + provider?: string; + model?: string; +}; + +// ============================================================================= +// EVALUATE COMMAND (from cli/commands/evaluate.ts) +// ============================================================================= + +/** Base options shared across all `neurolink evaluate` subcommands. */ +export type BaseEvaluateArgs = { + json?: boolean; + verbose?: boolean; + format?: "text" | "json" | "table"; +}; + +/** Arguments for the bare `neurolink evaluate` invocation. */ +export type DirectEvaluateArgs = BaseEvaluateArgs & { + input?: string; + query?: string; + scorers?: string[]; + context?: string; + threshold?: number; +}; + +/** Arguments for `neurolink evaluate run`. */ +export type EvaluateRunArgs = BaseEvaluateArgs & { + input?: string; + output?: string; + context?: string[]; + groundTruth?: string; + pipeline?: string; + scorer?: string[]; +}; + +/** Arguments for `neurolink evaluate score`. */ +export type EvaluateScoreArgs = BaseEvaluateArgs & { + scorer: string; + input?: string; + output?: string; + context?: string[]; + groundTruth?: string; +}; + +/** Arguments for `neurolink evaluate report`. */ +export type EvaluateReportArgs = BaseEvaluateArgs & { + input?: string; + output?: string; + context?: string[]; + groundTruth?: string; + "ground-truth"?: string; + pipeline?: string; + scorer?: string[]; + outputFile?: string; + "output-file"?: string; +}; + +/** Arguments for `neurolink evaluate presets`. */ +export type EvaluatePresetsArgs = { + preset?: string; + json?: boolean; +}; + +/** Arguments for `neurolink evaluate scorers` (list-scorers). */ +export type EvaluateScorersArgs = { + category?: string; + type?: string; + json?: boolean; + detailed?: boolean; +}; + +/** Arguments for `neurolink evaluate run-pipeline`. */ +export type RunPipelineArgs = BaseEvaluateArgs & { + preset: string; + input: string; + query?: string; + context?: string; + threshold?: number; +}; + +// ============================================================================= +// MCP COMMAND (from cli/commands/mcp.ts) +// ============================================================================= + +/** Info row for an MCP tool annotation. */ +export type ToolAnnotationInfo = { + serverName: string; + serverId: string; + toolName: string; + description: string; + annotations: import("./mcp.js").MCPToolAnnotations; +}; + +/** Tool target used by the annotation printer. */ +export type AnnotatedToolTarget = { + name: string; + description: string; + serverId: string; + serverName: string; +}; + +// ============================================================================= +// RAG COMMAND (from cli/commands/rag.ts) +// ============================================================================= + +/** Arguments for `neurolink rag chunk`. */ +export type RagChunkArgs = import("./rag.js").RAGCommandArgs & { + file: string; + output?: string; + extract?: boolean; +}; + +/** Arguments for `neurolink rag index`. */ +export type RagIndexArgs = import("./rag.js").RAGCommandArgs & { + file: string; + indexName?: string; +}; + +/** Arguments for `neurolink rag query`. */ +export type RagQueryArgs = import("./rag.js").RAGCommandArgs & { + query: string; + indexName?: string; +}; + +// ============================================================================= +// SERVE COMMAND (from cli/commands/serve.ts) +// ============================================================================= + +/** Minimal server instance contract used by `neurolink serve`. */ +export type ServerInstance = { + initialize: () => Promise; + start: () => Promise; + stop: () => Promise; + registerRouteGroup: (group: import("./server.js").RouteGroup) => void; + listRoutes?: () => import("./server.js").RouteDefinition[]; +}; + +/** Persisted state for a running `neurolink serve` process. */ +export type ServeState = { + pid: number; + port: number; + host: string; + framework: string; + startTime: string; + basePath: string; + configFile?: string; +}; + +// ============================================================================= +// BEDROCK SETUP COMMAND (from cli/commands/setup-bedrock.ts) +// ============================================================================= + +/** Captured Bedrock setup configuration data. */ +export type BedrockConfigData = { + accessKeyId?: string; + secretAccessKey?: string; + region?: string; + model?: string; +}; + +/** Status of Bedrock setup configuration flags. */ +export type BedrockConfigStatus = { + hasAccessKey: boolean; + hasSecretKey: boolean; + hasRegion: boolean; +}; + +// ============================================================================= +// GCP SETUP COMMAND (from cli/commands/setup-gcp.ts) +// ============================================================================= + +/** Status of each GCP auth method tried by setup-gcp. */ +export type GcpAuthMethodStatus = { + method1: { + complete: boolean; + hasCredentials: boolean; + missingVars: string[]; + }; + method2: { + complete: boolean; + hasServiceAccountKey: boolean; + missingVars: string[]; + }; + method3: { + complete: boolean; + hasClientEmail: boolean; + hasPrivateKey: boolean; + missingVars: string[]; + }; + common: { + hasProject: boolean; + hasLocation: boolean; + missingVars: string[]; + }; +}; + +// ============================================================================= +// PROVIDER SETUP COMMANDS (from cli/commands/setup-*.ts) +// ============================================================================= + +/** Arguments for `neurolink setup huggingface`. */ +export type SetupHuggingFaceArgs = { + check?: boolean; + "non-interactive"?: boolean; +}; + +/** Arguments for `neurolink setup mistral`. */ +export type SetupMistralArgs = { + check?: boolean; + "non-interactive"?: boolean; +}; + +// ============================================================================= +// TASK COMMAND (from cli/commands/task.ts) +// ============================================================================= + +/** Arguments for `neurolink task create`. */ +export type TaskCreateArgs = { + name: string; + prompt: string; + cron?: string; + timezone?: string; + every?: string; + at?: string; + mode: string; + provider?: string; + model?: string; + maxRuns?: number; + maxTokens?: number; + temperature?: number; + systemPrompt?: string; +}; + +/** Arguments for `neurolink task update`. */ +export type TaskUpdateArgs = { + taskId: string; + prompt?: string; + cron?: string; + every?: string; + at?: string; + mode?: string; +}; + +/** Arguments for `neurolink task logs`. */ +export type TaskLogsArgs = { + taskId: string; + limit: number; + status?: string; + full?: boolean; +}; + +// ============================================================================= +// VOICE SERVER COMMAND (from cli/commands/voiceServer.ts) +// ============================================================================= + +/** Arguments for `neurolink voice-server`. */ +export type VoiceServerArgs = { + port: number; +}; + +// ============================================================================= +// INTERACTIVE SETUP (from cli/utils/interactiveSetup.ts) +// ============================================================================= + +/** Provider config row used by the interactive setup wizard. */ +export type InteractiveProviderConfig = { + id: AIProviderName; + name: string; + description: string; + envVars: Array<{ + key: string; + prompt: string; + secure?: boolean; + default?: string; + optional?: boolean; + }>; +}; + +// ============================================================================= +// VIDEO FILE UTILS (from cli/utils/videoFileUtils.ts) +// ============================================================================= + +/** Result of saving video to file. */ +export type VideoSaveResult = { + success: boolean; + path: string; + size: number; + error?: string; +}; + +// ============================================================================= +// CLI CONFIG (from cli/commands/config.ts) +// ============================================================================= + +/** Provider identifier recognized by the `neurolink config` command. */ +export type CliConfigProvider = + | "auto" + | "openai" + | "bedrock" + | "vertex" + | "anthropic" + | "azure" + | "google-ai" + | "huggingface" + | "ollama" + | "mistral"; + +/** Analytics config for the healthcare evaluation domain. */ +export type CliHealthcareAnalyticsConfig = { + trackPatientData: boolean; + trackDiagnosticAccuracy: boolean; + trackTreatmentOutcomes: boolean; +}; + +/** Analytics config for the analytics evaluation domain. */ +export type CliAnalyticsDomainAnalyticsConfig = { + trackDataQuality: boolean; + trackModelPerformance: boolean; + trackBusinessImpact: boolean; +}; + +/** Analytics config for the finance evaluation domain. */ +export type CliFinanceAnalyticsConfig = { + trackRiskMetrics: boolean; + trackRegulatory: boolean; + trackPortfolioImpact: boolean; +}; + +/** Analytics config for the ecommerce evaluation domain. */ +export type CliEcommerceAnalyticsConfig = { + trackConversions: boolean; + trackUserBehavior: boolean; + trackRevenueImpact: boolean; +}; + +/** Generic shape of a single domain entry in the CLI config. */ +export type CliDomainConfig< + TAnalytics extends Record = Record, +> = { + evaluationCriteria: string[]; + analyticsConfig: TAnalytics; +}; + +/** + * Materialized shape of the CLI config parsed from `~/.neurolink/config.json`. + * Matches the output of `ConfigSchema.parse()` defined in + * `src/cli/commands/config.ts`. The schema is annotated with + * `z.ZodType` so drift fails at compile time. + */ +export type CliNeuroLinkConfig = { + defaultProvider: CliConfigProvider; + providers: { + openai?: { apiKey?: string; model: string; baseURL?: string }; + bedrock?: { + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + sessionToken?: string; + model: string; + }; + vertex?: { + projectId?: string; + location: string; + credentials?: string; + serviceAccountKey?: string; + clientEmail?: string; + privateKey?: string; + model: string; + }; + anthropic?: { apiKey?: string; model: string }; + azure?: { + apiKey?: string; + endpoint?: string; + deploymentId?: string; + model: string; + }; + "google-ai"?: { apiKey?: string; model: string }; + huggingface?: { apiKey?: string; model: string }; + ollama?: { baseUrl: string; model: string; timeout: number }; + mistral?: { apiKey?: string; model: string }; + }; + profiles: Record; + preferences: { + outputFormat: "text" | "json" | "yaml"; + temperature: number; + maxTokens?: number; + enableLogging: boolean; + enableCaching: boolean; + cacheStrategy: "memory" | "file" | "redis"; + defaultEvaluationDomain?: string; + enableAnalyticsByDefault: boolean; + enableEvaluationByDefault: boolean; + }; + domains: { + healthcare: CliDomainConfig; + analytics: CliDomainConfig; + finance: CliDomainConfig; + ecommerce: CliDomainConfig; + }; +}; + +// ============================================================================= +// MCP TOOL DISCOVERY CLI (from cli/commands/mcp.ts) +// ============================================================================= + +/** Row in the MCP tools listing produced by `neurolink mcp tools`. */ +export type MCPToolWithServer = { + name: string; + description: string; + serverId: string; + serverName: string; + inputSchema?: object; + category?: string; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + requiresConfirmation?: boolean; + tags?: string[]; + }; +}; + +/** Per-server discovery result produced by `neurolink mcp discover`. */ +export type MCPDiscoveryResult = { + serverId: string; + serverName: string; + toolCount: number; + tools: Array<{ + name: string; + description: string; + annotations: Record; + }>; +}; + +// ============================================================================= +// SERVE COMMAND ROUTE REFLECTION (from cli/commands/server.ts) +// ============================================================================= + +/** + * Minimal route-group shape reflected at runtime by `neurolink serve routes`. + * Named with a `CliServe` prefix to disambiguate from the richer RouteGroup + * in server.ts (§Rule 9). + */ +export type CliServeRouteGroup = { + prefix: string; + routes: Array<{ + method: string; + path: string; + description?: string; + }>; +}; + +/** Flat per-route row used by the `neurolink serve routes` listing. */ +export type CliServeFlatRoute = { + method: string; + path: string; + description?: string; + group: string; +}; diff --git a/src/lib/types/client.ts b/src/lib/types/client.ts index 5cb0d1040..ab5e79e0e 100644 --- a/src/lib/types/client.ts +++ b/src/lib/types/client.ts @@ -1587,3 +1587,36 @@ export type ClientWebSocketMessage = WSClientMessage; /** @see WSClientEventHandlers */ export type WebSocketEventHandlers = WSClientEventHandlers; + +/** + * Superset internal config for SSE and WebSocket client wrappers. + * The 9 shared fields are required. Protocol-specific fields + * (useNativeEventSource for SSE; heartbeatInterval/queueSize for WS) + * are optional — each client populates only its own fields. + */ +export type ClientInternalConfig = { + baseUrl: string; + apiKey: string; + token: string; + timeout: number; + headers: Record; + autoReconnect: boolean; + maxReconnectAttempts: number; + reconnectDelay: number; + maxReconnectDelay: number; + useNativeEventSource?: boolean; + heartbeatInterval?: number; + queueSize?: number; +}; + +/** + * Internal stream chunk format used by the AI-SDK adapter's push/pull queue. + * Distinct from the public NeuroLink `StreamChunk` (stream.ts) — this one + * mirrors the underlying `ai` package event shape (text-delta / finish). + */ +export type AiSdkStreamChunk = { + type: "text-delta" | "finish"; + textDelta?: string; + finishReason?: string; + usage?: { promptTokens: number; completionTokens: number }; +}; diff --git a/src/lib/types/common.ts b/src/lib/types/common.ts index a136195b5..3a34a8f78 100644 --- a/src/lib/types/common.ts +++ b/src/lib/types/common.ts @@ -2,6 +2,8 @@ * Common utility types for NeuroLink */ +import type { NeuroLink } from "../neurolink.js"; +import type { ConversationMemoryConfig } from "./conversation.js"; import type { AutoresearchErrorEvent, AutoresearchExperimentCompletedEvent, @@ -326,58 +328,6 @@ export type InfraRetryOptions = { shouldRetry?: (error: Error) => boolean; }; -// ============================================================================= -// RETRY OPTIONS (moved from utils/async/retry.ts) -// ============================================================================= - -/** - * Configuration options for retry operations with exponential backoff. - * Named AsyncRetryOptions to avoid collision with utilities.ts RetryOptions. - */ -export type AsyncRetryOptions = { - /** - * Maximum number of retry attempts (not including the initial attempt). - * @default 3 - */ - maxRetries: number; - - /** - * Initial delay between retries in milliseconds. - * @default 1000 - */ - baseDelayMs: number; - - /** - * Maximum delay cap in milliseconds. - * @default 30000 - */ - maxDelayMs: number; - - /** - * Multiplier for exponential backoff. - * @default 2 - */ - backoffMultiplier?: number; - - /** - * Function to determine if a retry should be attempted. - * Return false to stop retrying immediately. - */ - shouldRetry?: (error: Error, attempt: number) => boolean; - - /** - * Whether to add random jitter to backoff delays to prevent thundering herd. - * @default true - */ - addJitter?: boolean; - - /** - * Callback invoked before each retry attempt. - * Useful for logging or metrics. - */ - onRetry?: (error: Error, attempt: number, delayMs: number) => void; -}; - // ============================================================================= // TOKEN UTILS TYPES (moved from utils/tokenUtils.ts) // ============================================================================= @@ -633,3 +583,19 @@ export type StreamingParser = { export type HippocampusMemory = import("@juspay/hippocampus").HippocampusConfig & { enabled?: boolean }; + +// ============================================================================= +// SESSION STATE (from session/globalSessionState.ts) +// ============================================================================= + +/** Value types accepted as session variables by the loop REPL. */ +export type SessionVariableValue = string | number | boolean; + +/** State snapshot for the active REPL loop session. */ +export type LoopSessionState = { + neurolinkInstance: NeuroLink; + sessionId: string; + isActive: boolean; + conversationMemoryConfig?: ConversationMemoryConfig; + sessionVariables: Record; +}; diff --git a/src/lib/types/context.ts b/src/lib/types/context.ts index 625118758..2400f49cf 100644 --- a/src/lib/types/context.ts +++ b/src/lib/types/context.ts @@ -920,3 +920,9 @@ export type DeduplicationResult = { messages: ChatMessage[]; filesDeduped: number; }; + +/** Options for FileSummarizationService. */ +export type FileSummarizationServiceOptions = { + provider?: string; + model?: string; +}; diff --git a/src/lib/types/evaluation.ts b/src/lib/types/evaluation.ts index cc0e2855f..58c186aee 100644 --- a/src/lib/types/evaluation.ts +++ b/src/lib/types/evaluation.ts @@ -10,7 +10,11 @@ import type { ToolExecution } from "./tools.js"; import type { JsonObject } from "./common.js"; import type { AggregatedScores, + EvaluationTraceContext, PipelineConfig, + ReportConfig, + ReportFormat, + ScoreResult, ScorerInput, } from "./scorer.js"; @@ -378,3 +382,409 @@ export type ScorerFunction = (input: ScorerInput) => Promise<{ reasoning: string; metadata?: JsonObject; }>; + +// ============================================================================ +// BATCH EVALUATION TYPES (superset merges — see CLAUDE.md Rule 9) +// ============================================================================ + +/** + * Superset batch progress. `pending` is canonical; `remaining` in the + * pipeline's batchStrategy was renamed during consolidation (same value). + */ +export type BatchProgress = { + total: number; + completed: number; + failed: number; + pending: number; + percentComplete: number; + succeeded?: number; + estimatedTimeRemaining?: number; +}; + +/** Input item for BatchEvaluator. */ +export type BatchEvaluationItem = { + id: string; + options: LanguageModelV3CallOptions; + result: GenerateResult; + threshold?: number; +}; + +/** Result of a single item in BatchEvaluator. */ +export type BatchEvaluationItemResult = { + id: string; + success: boolean; + data?: EvaluationData; + error?: { + message: string; + code?: string; + retryable?: boolean; + }; + duration: number; + retryCount: number; +}; + +/** Result of a single item in the pipeline batchStrategy. */ +export type BatchItemResult = { + index: number; + input: ScorerInput; + result?: PipelineResult; + error?: string; + duration: number; +}; + +/** + * Superset batch evaluation config. Union of pre-consolidation types + * (BatchEvaluationConfig in BatchEvaluator, BatchConfig in batchStrategy). + */ +export type BatchEvaluationConfig = EvaluationConfig & { + concurrency?: number; + continueOnError?: boolean; + onProgress?: (progress: BatchProgress) => void; + maxRetries?: number; + retryDelay?: number; + onItemComplete?: (result: BatchEvaluationItemResult) => void; + batchDelay?: number; + onResult?: (result: BatchItemResult) => void; +}; + +/** + * Superset batch-result. `results` is a union of both item-result flavors; + * summary field names chosen from BatchEvaluator (`succeeded`, `passingRate`). + */ +export type BatchEvaluationResult = { + results: BatchEvaluationItemResult[] | BatchItemResult[]; + summary: { + total: number; + succeeded: number; + failed: number; + averageScore: number; + averageDuration: number; + totalDuration: number; + passingRate: number; + }; + allSucceeded?: boolean; +}; + +// ============================================================================= +// EVALUATION AGGREGATOR (from evaluation/EvaluationAggregator.ts) +// ============================================================================= + +/** Statistical summary of evaluation scores. */ +export type ScoreStatistics = { + min: number; + max: number; + mean: number; + median: number; + stdDev: number; + variance: number; + p25: number; + p75: number; + p90: number; + p95: number; +}; + +/** Score distribution across ranges. */ +export type ScoreDistribution = { + /** Items scoring 1-3 (poor) */ + poor: number; + /** Items scoring 4-5 (below average) */ + belowAverage: number; + /** Items scoring 6-7 (average) */ + average: number; + /** Items scoring 8-9 (good) */ + good: number; + /** Items scoring 10 (excellent) */ + excellent: number; +}; + +/** Trend analysis results. */ +export type TrendAnalysis = { + direction: "improving" | "declining" | "stable"; + slope: number; + rSquared: number; + percentChange: number; + movingAverage: number; +}; + +/** Dimension-specific analysis for RAGAS metrics. */ +export type DimensionAnalysis = { + relevance: ScoreStatistics; + accuracy: ScoreStatistics; + completeness: ScoreStatistics; + overall: ScoreStatistics; + correlations: { + relevanceAccuracy: number; + relevanceCompleteness: number; + accuracyCompleteness: number; + }; +}; + +/** Quality alerts summary. */ +export type AlertSummary = { + total: number; + high: number; + medium: number; + offTopic: number; + alertRate: number; +}; + +/** Comprehensive aggregation result. */ +export type AggregationResult = { + count: number; + statistics: ScoreStatistics; + distribution: ScoreDistribution; + dimensions: DimensionAnalysis; + sequenceTrend?: TrendAnalysis; + alerts: AlertSummary; + passingRate: number; + avgEvaluationTime: number; + metadata: { + aggregatedAt: string; + threshold: number; + evaluationModels: string[]; + }; +}; + +// ============================================================================= +// EVALUATOR FACTORY / REGISTRY (from evaluation/EvaluatorFactory.ts, EvaluatorRegistry.ts) +// ============================================================================= + +/** Configuration preset for common evaluation scenarios. */ +export type EvaluatorPreset = { + name: string; + description: string; + config?: EvaluationConfig; +}; + +/** Configuration for evaluation strategies. */ +export type EvaluationStrategyConfig = { + evaluationModel?: string; + provider?: string; + threshold?: number; + promptGenerator?: (context: { + userQuery: string; + history: string; + tools: string; + retryInfo: string; + aiResponse: string; + }) => string; + options?: Record; +}; + +/** Function that performs evaluation and returns results. */ +export type EvaluationStrategyFunction = ( + options: LanguageModelV3CallOptions, + result: GenerateResult, + config?: EvaluationStrategyConfig, +) => Promise<{ + evaluationResult: EvaluationResult; + evalContext: EnhancedEvaluationContext; +}>; + +/** Metadata for registered evaluation strategies. */ +export type EvaluationStrategyMetadata = { + name: string; + description: string; + requiresLLM: boolean; + defaultModel?: string; + defaultProvider?: string; + version: string; + features: string[]; +}; + +// ============================================================================= +// EVALUATION ERRORS (from evaluation/errors/EvaluationError.ts) +// ============================================================================= + +/** Canonical evaluation error code. */ +export type EvaluationErrorCode = + | "EVALUATION_FAILED" + | "PARSE_ERROR" + | "STRATEGY_NOT_FOUND" + | "PROVIDER_ERROR" + | "CONFIGURATION_ERROR" + | "CUSTOM_EVALUATOR_ERROR" + | "BATCH_EVALUATION_ERROR" + | "AGGREGATION_ERROR" + | "REGISTRY_ERROR" + | "MAX_RETRIES_EXCEEDED" + | "TIMEOUT_ERROR" + | "RATE_LIMIT_ERROR"; + +/** Extended evaluation context for error details. */ +export type EvaluationErrorContext = { + userQueryLength?: number; + aiResponseLength?: number; + attemptNumber?: number; + previousScores?: number[]; + strategy?: string; + evaluationModel?: string; + provider?: string; + rawResponseLength?: number; + additionalContext?: Record; +}; + +// ============================================================================= +// LANGFUSE ADAPTER (from evaluation/hooks/langfuseAdapter.ts) +// ============================================================================= + +/** Minimal Langfuse client interface for evaluation hooks. */ +export type LangfuseClient = { + score: (params: { + name: string; + value: number; + traceId?: string; + observationId?: string; + comment?: string; + metadata?: Record; + }) => Promise; + trace?: (params: { + name: string; + metadata?: Record; + tags?: string[]; + }) => { id: string }; + shutdown?: () => Promise; +}; + +/** Langfuse adapter configuration. */ +export type LangfuseAdapterConfig = { + client: LangfuseClient; + scorePrefix?: string; + includeMetadata?: boolean; + tags?: string[]; + sendPipelineScores?: boolean; + sendScorerScores?: boolean; +}; + +// ============================================================================= +// OBSERVABILITY HOOKS (from evaluation/hooks/observabilityHooks.ts) +// ============================================================================= + +/** Events emitted by the evaluation pipeline. */ +export type EvaluationEvents = { + "scorer:start": { + scorerId: string; + scorerName: string; + timestamp: number; + traceContext?: EvaluationTraceContext; + }; + "scorer:end": { + scorerId: string; + scorerName: string; + result: ScoreResult; + timestamp: number; + duration: number; + traceContext?: EvaluationTraceContext; + }; + "scorer:error": { + scorerId: string; + scorerName: string; + error: string; + timestamp: number; + traceContext?: EvaluationTraceContext; + }; + "pipeline:start": { + pipelineName: string; + scorerCount: number; + timestamp: number; + correlationId: string; + traceContext?: EvaluationTraceContext; + }; + "pipeline:end": { + pipelineName: string; + result: PipelineResult; + timestamp: number; + duration: number; + traceContext?: EvaluationTraceContext; + }; + "pipeline:error": { + pipelineName: string; + error: string; + timestamp: number; + traceContext?: EvaluationTraceContext; + }; +}; + +/** + * Flat span attribute map used by the evaluation observability layer. + * Named EvaluationSpanAttributes to disambiguate from the richer telemetry + * SpanAttributes in span.ts (§Rule 9 domain prefix). + */ +export type EvaluationSpanAttributes = Record< + string, + string | number | boolean +>; + +// ============================================================================= +// METRICS COLLECTOR (from evaluation/reporting/metricsCollector.ts) +// ============================================================================= + +/** Metrics captured per scorer execution. */ +export type ScorerMetrics = { + scorerId: string; + scorerName: string; + totalExecutions: number; + successfulExecutions: number; + failedExecutions: number; + passedCount: number; + failedCount: number; + totalScore: number; + minScore: number; + maxScore: number; + totalDuration: number; + averageDuration: number; + averageScore: number; + passRate: number; + lastExecutionTime: number; +}; + +/** Metrics captured per evaluation pipeline. */ +export type PipelineMetrics = { + pipelineName: string; + totalExecutions: number; + passedCount: number; + failedCount: number; + totalScore: number; + minScore: number; + maxScore: number; + totalDuration: number; + averageDuration: number; + averageScore: number; + passRate: number; + lastExecutionTime: number; + scorerMetrics: Map; +}; + +/** Aggregated metrics across pipelines and scorers. */ +export type AggregatedMetrics = { + totalEvaluations: number; + overallPassRate: number; + averageScore: number; + averageDuration: number; + scoreDistribution: { + excellent: number; + good: number; + fair: number; + poor: number; + failing: number; + }; + pipelineMetrics: Map; + scorerMetrics: Map; + collectionStartTime: number; + lastUpdateTime: number; +}; + +// ============================================================================= +// REPORT GENERATOR (from evaluation/reporting/reportGenerator.ts) +// ============================================================================= + +/** Generated evaluation report envelope. */ +export type GeneratedReport = { + format: ReportFormat; + content: string; + metadata: { + generatedAt: number; + format: ReportFormat; + config: ReportConfig; + }; +}; diff --git a/src/lib/types/file.ts b/src/lib/types/file.ts index f3ef9301d..ad3a6f25c 100644 --- a/src/lib/types/file.ts +++ b/src/lib/types/file.ts @@ -441,3 +441,8 @@ export type SvgSanitizationResult = { /** Whether any content was modified */ wasModified: boolean; }; + +/** Contract implemented by each file-detection strategy. */ +export type DetectionStrategy = { + detect(input: FileInput): Promise; +}; diff --git a/src/lib/types/mcp.ts b/src/lib/types/mcp.ts index c0b34aced..1477e07dd 100644 --- a/src/lib/types/mcp.ts +++ b/src/lib/types/mcp.ts @@ -321,13 +321,16 @@ export type MCPStatus = { }; /** - * Call record for circuit breaker statistics tracking - * Extracted from mcpCircuitBreaker.ts for centralized type management + * Call record for circuit breaker statistics tracking. + * Superset shape: MCP breaker uses {timestamp, success, duration}; + * RAG breaker also tracks `operationType` (optional, for routing and + * metrics). Both import from here. */ export type CallRecord = { timestamp: number; success: boolean; duration: number; + operationType?: string; }; /** @@ -2535,3 +2538,140 @@ export type MCPTool = ToolInfo & { serverId?: string; category?: string; }; + +// ============================================================================= +// REQUEST BATCHER (from mcp/batching/requestBatcher.ts) +// ============================================================================= + +/** Pending request in the batcher queue. */ +export type PendingRequest = { + id: string; + tool: string; + args: unknown; + serverId?: string; + resolve: (value: T) => void; + reject: (error: Error) => void; + addedAt: number; +}; + +// ============================================================================= +// TOOL CACHE (from mcp/caching/toolCache.ts) +// ============================================================================= + +/** + * Cached entry held by ToolCache. Named McpCacheEntry to disambiguate from + * the response-caching middleware's CacheEntry in server.ts (Rule 9). + */ +export type McpCacheEntry = { + value: T; + expires: number; + createdAt: number; + accessedAt: number; + accessCount: number; + key: string; +}; + +// ============================================================================= +// MULTI-SERVER MANAGER (from mcp/multiServerManager.ts) +// ============================================================================= + +/** Runtime metrics tracked per MCP server by MultiServerManager. */ +export type ServerMetrics = { + activeRequests: number; + totalRequests: number; + completedRequests: number; + averageResponseTime: number; + errorRate: number; + lastHealthCheck?: Date; + isHealthy: boolean; +}; + +// ============================================================================= +// AI WORKFLOW TOOLS (from mcp/servers/aiProviders/aiWorkflowTools.ts) +// ============================================================================= + +/** Synthesized test case produced by the AI workflow test-generator. */ +export type WorkflowTestCase = { + name: string; + type: string; + code: string; + description: string; + assertions: number; +}; + +/** Result of the code-refactoring AI workflow. */ +export type RefactoringResult = { + refactoredCode: string; + changes: string[]; + improvements: string[]; + metrics: { + linesReduced: number; + complexityReduction: number; + readabilityScore: number; + }; +}; + +/** Result of the AI documentation-generation workflow. */ +export type DocumentationResult = { + documentation: string; + sections: string[]; + examples: string[]; + coverage: number; +}; + +/** Result of the AI debugging workflow. */ +export type DebugResult = { + issues: Array<{ + type: string; + severity: "low" | "medium" | "high"; + description: string; + location?: string; + }>; + suggestions: string[]; + possibleCauses: string[]; + fixedOutput?: string; +}; + +// ============================================================================= +// AI ANALYSIS TOOLS (from mcp/servers/aiProviders/aiAnalysisTools.ts) +// ============================================================================= + +/** Provider name accepted by the AI analysis MCP tools. */ +export type AiAnalysisProvider = + | "openai" + | "bedrock" + | "vertex" + | "anthropic" + | "google-ai" + | "azure" + | "huggingface" + | "ollama" + | "mistral"; + +/** Parsed input for the analyze-ai-usage MCP tool. */ +export type AnalyzeUsageParams = { + sessionId?: string; + timeRange: "1h" | "24h" | "7d" | "30d"; + provider?: AiAnalysisProvider; + includeTokenBreakdown: boolean; + includeCostEstimation: boolean; +}; + +/** Parsed input for the benchmark-provider-performance MCP tool. */ +export type BenchmarkParams = { + providers?: AiAnalysisProvider[]; + testPrompts?: string[]; + iterations: number; + metrics: Array<"latency" | "quality" | "cost" | "tokens">; + maxTokens: number; +}; + +/** Parsed input for the optimize-prompt-parameters MCP tool. */ +export type OptimizeParametersParams = { + prompt: string; + provider?: AiAnalysisProvider; + targetLength?: number; + style: "creative" | "balanced" | "precise" | "factual"; + optimizeFor: "speed" | "quality" | "cost" | "tokens"; + iterations: number; +}; diff --git a/src/lib/types/middleware.ts b/src/lib/types/middleware.ts index 72c357b96..e7f6cccbf 100644 --- a/src/lib/types/middleware.ts +++ b/src/lib/types/middleware.ts @@ -1,6 +1,11 @@ import type { LanguageModelMiddleware } from "ai"; import type { JsonValue } from "../types/common.js"; import type { EvaluationData, GetPromptFunction } from "./evaluation.js"; +import type { + AuthenticatedUser, + RouteDefinition, + ServerContext, +} from "./server.js"; /** * Metadata type for NeuroLink middleware * Provides additional information about middleware without affecting execution @@ -330,3 +335,140 @@ export type LifecycleMiddlewareConfig = { onError?: OnErrorCallback; onChunk?: OnChunkCallback; }; + +// ============================================================================= +// SERVER MIDDLEWARE (from server/middleware/*.ts) +// ============================================================================= + +/** Options for the abort-signal middleware. */ +export type AbortSignalMiddlewareOptions = { + onAbort?: (ctx: ServerContext) => void; + timeout?: number; +}; + +/** Options for the bearer-token auth middleware. */ +export type BearerAuthOptions = { + required?: boolean; + headerName?: string; + skipPaths?: string[]; +}; + +/** Token-validation function signature. */ +export type TokenValidator = ( + token: string, +) => Promise | AuthenticatedUser | null; + +/** Options for the API-key auth middleware. */ +export type ApiKeyAuthOptions = { + headerName?: string; + skipPaths?: string[]; +}; + +/** Configuration for the route-deprecation middleware. */ +export type DeprecationConfig = { + routes: RouteDefinition[]; + noticeHeader?: string; + includeLink?: boolean; +}; + +/** Rate-limit middleware configuration. */ +export type RateLimitMiddlewareConfig = { + maxRequests: number; + windowMs: number; + message?: string; + skipPaths?: string[]; + keyGenerator?: (ctx: ServerContext) => string; + onRateLimitExceeded?: (ctx: ServerContext, retryAfter: number) => unknown; + store?: RateLimitStore; +}; + +/** Rate-limit counter entry tracked per key. */ +export type RateLimitEntry = { + count: number; + resetAt: number; +}; + +/** Rate-limit store contract (memory or Redis). */ +export type RateLimitStore = { + get(key: string): Promise; + set(key: string, entry: RateLimitEntry): Promise; + increment(key: string, windowMs: number): Promise; + reset(key: string): Promise; +}; + +/** Simple fixed-window rate-limit configuration. */ +export type FixedWindowRateLimitConfig = { + maxRequests: number; + windowMs: number; + message?: string; + skipPaths?: string[]; + keyGenerator?: (ctx: ServerContext) => string; + onRateLimitExceeded?: (ctx: ServerContext, retryAfter: number) => unknown; +}; + +/** Per-field entry inside a ServerValidationError's `errors` array. */ +export type ValidationErrorPayload = { + field: string; + message: string; + value?: unknown; +}; + +/** + * Minimal structural view of the server-side ValidationError class used by + * the request-validation middleware's errorFormatter callback. + */ +export type ValidationErrorInfo = { + errors: ValidationErrorPayload[]; + requestId?: string; +}; + +/** Validation configuration for the request-validation middleware. */ +export type ValidationConfig = { + bodySchema?: MiddlewareRequestSchema; + querySchema?: MiddlewareRequestSchema; + paramsSchema?: MiddlewareRequestSchema; + headersSchema?: MiddlewareRequestSchema; + customValidator?: (ctx: ServerContext) => Promise; + skipPaths?: string[]; + errorFormatter?: (errors: ValidationErrorInfo[]) => unknown; +}; + +/** + * Simple structural validation schema used by the request-validation + * middleware. Named MiddlewareRequestSchema to disambiguate from the zod + * `ValidationSchema` exported from aliases.ts (§Rule 9 domain prefix). + */ +export type MiddlewareRequestSchema = { + required?: string[]; + properties?: Record; + additionalProperties?: boolean; +}; + +/** Schema for an individual property in ValidationSchema. */ +export type PropertySchema = { + type: "string" | "number" | "boolean" | "object" | "array"; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + minItems?: number; + maxItems?: number; + pattern?: string; + enum?: unknown[]; + default?: unknown; + validate?: (value: unknown) => boolean | string; +}; + +/** PropertySchema with an extra `format` tag for common schemas. */ +export type ExtendedPropertySchema = PropertySchema & { + format?: string; +}; + +/** Extended validation schema for common schemas. */ +export type ExtendedValidationSchema = { + type?: string; + format?: string; + required?: string[]; + properties?: Record; + additionalProperties?: boolean; +}; diff --git a/src/lib/types/multimodal.ts b/src/lib/types/multimodal.ts index 61392e0b6..85f10a2fd 100644 --- a/src/lib/types/multimodal.ts +++ b/src/lib/types/multimodal.ts @@ -618,3 +618,80 @@ export function isMultimodalMessageContent( ): content is MessageContent[] { return Array.isArray(content); } + +// ============================================================================= +// DIRECTOR PIPELINE (from adapters/video/directorPipeline.ts) +// ============================================================================= + +/** Result of a single director-mode clip generation. */ +export type ClipResult = { buffer: Buffer; processingTime: number }; + +/** Completion status for ordered circuit-breaker tracking. */ +export type ClipCompletion = + | { status: "pending" } + | { status: "success"; result: ClipResult } + | { status: "failure"; error: Error }; + +/** State shared across clip-generation tasks for circuit-breaker logic. */ +export type ClipGenState = { + consecutiveFailures: number; + circuitOpen: boolean; + results: Array; + completions: ClipCompletion[]; + nextExpectedIndex: number; +}; + +/** Result of a single director-mode transition generation. */ +export type TransitionResult = { + buffer: Buffer | null; + fromSegment: number; + toSegment: number; + duration: number; + processingTime: number; +}; + +// ============================================================================= +// VERTEX VIDEO (from adapters/video/vertexVideoHandler.ts) +// ============================================================================= + +/** Polling result envelope returned by Vertex Veo long-running operations. */ +export type VertexOperationResult = { + done?: boolean; + response?: { + videos?: Array<{ + bytesBase64Encoded?: string; + gcsUri?: string; + }>; + }; + error?: { + message?: string; + }; +}; + +// ============================================================================= +// IMAGE COMPRESSOR (from utils/imageCompressor.ts) +// ============================================================================= + +/** Output format accepted by the image compressor. */ +export type SupportedFormat = "jpeg" | "png" | "webp"; + +/** Options consumed by compressImage(). */ +export type CompressionOptions = { + provider: import("./providers.js").ProviderName; + quality?: number; + maxDimension?: number; + format?: SupportedFormat; +}; + +/** Result of compressImage() with metadata. */ +export type CompressionResult = { + buffer: Buffer; + originalSize: number; + compressedSize: number; + compressionRatio: number; + metadata: { + width: number; + height: number; + format: string; + }; +}; diff --git a/src/lib/types/observability.ts b/src/lib/types/observability.ts index 43631161c..c1e097b77 100644 --- a/src/lib/types/observability.ts +++ b/src/lib/types/observability.ts @@ -482,3 +482,108 @@ export type SpanOptions = { kind?: import("@opentelemetry/api").SpanKind; attributes?: Record; }; + +// ============================================================================= +// METRICS TRACE CONTEXT (from neurolink.ts) +// ============================================================================= + +/** Trace + parent span IDs used to correlate metric records with spans. */ +export type MetricsTraceContext = { + traceId: string; + parentSpanId: string; +}; + +// ============================================================================= +// EXPORTER REGISTRY CIRCUIT BREAKER (from observability/exporterRegistry.ts) +// ============================================================================= + +/** + * Runtime state for the observability exporter circuit breaker. + * Prefixed to disambiguate from the richer MCP CircuitBreakerState in mcp.ts. + */ +export type ObservabilityCircuitBreakerState = { + failures: number; + lastFailure: number; + state: "closed" | "open" | "half-open"; +}; + +/** + * Minimal config for the observability exporter circuit breaker. + * Prefixed to disambiguate from the richer MCP CircuitBreakerConfig in mcp.ts. + */ +export type ObservabilityCircuitBreakerConfig = { + failureThreshold: number; + resetTimeout: number; +}; + +// ============================================================================= +// SENTRY EXPORTER (from observability/exporters/sentryExporter.ts) +// ============================================================================= + +/** Minimal view of the dynamically-imported @sentry/node module. */ +export type SentryModule = { + init: (options: { + dsn: string; + tracesSampleRate: number; + release?: string; + environment: string; + }) => void; + withScope: (callback: (scope: SentryScope) => void) => void; + captureException: (error: Error) => void; + startInactiveSpan: (options: { + name: string; + op: string; + startTime: number; + attributes?: Record; + }) => { end: (timestamp?: number) => void }; + flush: (timeout: number) => Promise; + close: (timeout: number) => Promise; +}; + +/** Sentry scope surface used by SentryExporter.withScope callbacks. */ +export type SentryScope = { + setTags: (tags: Record) => void; + setContext: (name: string, context: Record) => void; + setUser: (user: { id: string }) => void; +}; + +// ============================================================================= +// METRICS AGGREGATOR (from observability/metricsAggregator.ts) +// ============================================================================= + +/** Aggregated metrics for a single time window. */ +export type TimeWindowStats = { + windowStart: Date; + windowEnd: Date; + windowDurationMs: number; + requestCount: number; + errorCount: number; + successRate: number; + throughput: number; + latency: LatencyStats; + tokens: TokenUsageStats; + costByProvider: Map; + costByModel: Map; +}; + +/** Configuration for MetricsAggregator. */ +export type MetricsAggregatorConfig = { + maxSpansRetained?: number; + enableTimeWindows?: boolean; + timeWindowMs?: number; + maxTimeWindows?: number; +}; + +// ============================================================================= +// TOKEN TRACKER (from observability/tokenTracker.ts) +// ============================================================================= + +/** + * Per-million-token pricing used by the observability TokenTracker. + * Prefixed to disambiguate from the richer providers.ts ModelPricing. + */ +export type ObservabilityModelPricing = { + inputPricePerMillion: number; + outputPricePerMillion: number; + cachedInputPricePerMillion?: number; +}; diff --git a/src/lib/types/processor.ts b/src/lib/types/processor.ts index f41f090e3..fb17a1c3a 100644 --- a/src/lib/types/processor.ts +++ b/src/lib/types/processor.ts @@ -993,3 +993,66 @@ export type BatchFileProcessingResult = { reason: string; }>; }; + +// ============================================================================= +// EXCEL PROCESSOR (from processors/document/ExcelProcessor.ts) +// ============================================================================= + +/** Alias for ExcelJS.CellValue to avoid leaking exceljs types across files. */ +export type CellValue = import("exceljs").CellValue; + +// ============================================================================= +// ERROR HELPERS (from processors/errors/errorHelpers.ts) +// ============================================================================= + +/** Summary of file processing operations. */ +export type FileProcessingSummary = { + totalFiles: number; + processedFiles: Array<{ + filename: string; + size?: number; + type?: string; + }>; + failedFiles: Array<{ + filename: string; + error: FileProcessingError; + }>; + skippedFiles: Array<{ + filename: string; + reason: string; + suggestedAlternative?: string; + }>; + warnings: Array<{ + filename: string; + message: string; + }>; +}; + +// ============================================================================= +// ERROR SERIALIZER (from processors/errors/errorSerializer.ts) +// ============================================================================= + +/** Serialized error representation with full context. */ +export type SerializedError = { + errorId: string; + errorFingerprint: string; + errorType: string; + message: string; + stack?: string; + stackFrames?: string[]; + statusCode?: number; + isOperational?: boolean; + isRetryable?: boolean; + code?: string; + metadata?: Record; + cause?: SerializedError; + timestamp: string; +}; + +/** Options for error serialization. */ +export type SerializeOptions = { + includeStack?: boolean; + maxDepth?: number; + filterStacks?: boolean; + context?: Record; +}; diff --git a/src/lib/types/providers.ts b/src/lib/types/providers.ts index 92e3d7297..ef70f680e 100644 --- a/src/lib/types/providers.ts +++ b/src/lib/types/providers.ts @@ -2,7 +2,12 @@ * Provider-specific type definitions for NeuroLink */ -import type { UnknownRecord, JsonValue } from "./common.js"; +import type { + UnknownRecord, + JsonValue, + StreamingCapability, +} from "./common.js"; +import type { ProviderError } from "./errors.js"; import { AIProviderName, AnthropicModels, @@ -618,23 +623,6 @@ export type ProviderFactory = ( sdk?: unknown, ) => Promise; -/** - * Provider constructor type - */ -export type ProviderConstructor = { - new (modelName?: string, providerName?: string, sdk?: unknown): unknown; -}; - -/** - * Provider registration entry - */ -export type ProviderRegistration = { - name: string; - constructor: ProviderConstructor | ProviderFactory; - capabilities?: ProviderCapabilities; - defaultConfig?: IndividualProviderConfig; -}; - /** * Configuration options for the provider registry */ @@ -1769,3 +1757,166 @@ export type ToolWithLegacyParams = { /** Legacy field from AI SDK v3/v4 */ parameters?: unknown; }; + +// ============================================================================= +// PROVIDER FACTORY (from factories/providerFactory.ts) +// ============================================================================= + +/** + * Provider constructor interface - supports both sync constructors and async + * factory functions. + */ +export type ProviderConstructor = + | { + new ( + modelName?: string, + providerName?: string, + sdk?: UnknownRecord, + region?: string, + ): AIProvider; + } + | (( + modelName?: string, + providerName?: string, + sdk?: UnknownRecord, + region?: string, + ) => Promise); + +/** Provider registration entry held by ProviderFactory. */ +export type ProviderRegistration = { + constructor: ProviderConstructor; + defaultModel?: string; + aliases?: string[]; +}; + +// ============================================================================= +// IMAGE GEN (from image-gen/ImageGenService.ts) +// ============================================================================= + +/** Minimal NeuroLink-like instance accepted by the image generation service. */ +export type NeuroLinkInstance = { + generate: (options: Record) => Promise; +}; + +// ============================================================================= +// OLLAMA (from providers/ollama.ts) +// ============================================================================= + +/** ProviderError enriched with HTTP response fields from Ollama. */ +export type OllamaHttpError = ProviderError & { + statusCode: number; + statusText: string; + responseBody: string; +}; + +// ============================================================================= +// SAGEMAKER DETECTION (from providers/sagemaker/detection.ts) +// ============================================================================= + +/** Model type detection result. */ +export type ModelDetectionResult = { + type: StreamingCapability["modelType"]; + confidence: number; + evidence: string[]; + suggestedConfig?: Partial; +}; + +/** Endpoint health and metadata information. */ +export type EndpointHealth = { + status: "healthy" | "unhealthy" | "unknown"; + responseTime: number; + metadata?: Record; + modelInfo?: { + name?: string; + version?: string; + framework?: string; + architecture?: string; + }; +}; + +/** Configuration object for a detection test wrapper. */ +export type DetectionTestConfig = { + test: () => Promise; + index: number; + testName: string; + endpointName: string; + semaphore: { + acquire(): Promise; + release(): void; + }; + incrementRateLimit: () => void; + maxRateLimitRetries: number; + rateLimitState: { count: number }; +}; + +/** Configuration object for parallel detection test execution. */ +export type ParallelDetectionConfig = { + maxConcurrentTests: number; + maxRateLimitRetries: number; + initialRateLimitCount: number; +}; + +// ============================================================================= +// SAGEMAKER DIAGNOSTICS (from providers/sagemaker/diagnostics.ts) +// ============================================================================= + +/** Individual SageMaker diagnostic result. */ +export type DiagnosticResult = { + name: string; + category: "configuration" | "connectivity" | "streaming"; + status: "pass" | "fail" | "warning"; + message: string; + details?: string; + recommendation?: string; +}; + +/** Aggregated SageMaker diagnostic report. */ +export type DiagnosticReport = { + overallStatus: "healthy" | "issues" | "critical"; + results: DiagnosticResult[]; + summary: { + total: number; + passed: number; + failed: number; + warnings: number; + }; +}; + +// ============================================================================= +// SAGEMAKER LANGUAGE MODEL (from providers/sagemaker/language-model.ts) +// ============================================================================= + +/** SageMaker tool_call item in the OpenAI-compatible payload shape. */ +export type SageMakerOpenAIToolCall = { + type: "function"; + id: string; + function: { + name: string; + arguments: string; + }; +}; + +// ============================================================================= +// GOOGLE AI STUDIO LIVE AUDIO (from providers/googleAiStudio.ts) +// ============================================================================= + +/** + * Event pushed through the Google AI Studio voice session's internal queue + * while audio chunks stream back from the Gemini Live API. + */ +export type GoogleLiveAudioQueueItem = + | { type: "audio"; audio: import("./stream.js").AudioChunk } + | { type: "end" } + | { type: "error"; error: unknown }; + +// ============================================================================= +// GOOGLE VERTEX NATIVE PARTS (from providers/googleVertex.ts) +// ============================================================================= + +/** + * Single part inside a Google Vertex "native" (non-AI-SDK) generateContent + * payload — either inline text or an inline base64 data blob. + */ +export type VertexNativePart = + | { text: string } + | { inlineData: { mimeType: string; data: string } }; diff --git a/src/lib/types/proxy.ts b/src/lib/types/proxy.ts index 91978cbc8..12194c5b8 100644 --- a/src/lib/types/proxy.ts +++ b/src/lib/types/proxy.ts @@ -14,7 +14,10 @@ * - src/lib/server/routes/claudeProxyRoutes.ts (runtime state, deps) */ -import type { Span } from "@opentelemetry/api"; +import type { Counter, Histogram, Span } from "@opentelemetry/api"; +import type { Hono } from "hono"; +import type { Ora } from "ora"; +import type { MCPToolRegistry } from "../mcp/toolRegistry.js"; import type { ProxyTracer } from "../proxy/proxyTracer.js"; import type { FallbackEntry, @@ -772,6 +775,10 @@ export type RuntimeAccountState = { permanentlyDisabled: boolean; lastToken?: string; lastRefreshToken?: string; + /** Epoch-ms timestamp until which the account should not be used for new + * requests (set after 429 retries are exhausted). Other requests arriving + * during this window will skip the account rather than hammering it again. */ + coolingUntil?: number; }; /** A passthrough account used in the proxy route handler. */ @@ -897,3 +904,393 @@ export type ProxyPaths = { /** Whether this is a dev-mode isolated instance */ isDev: boolean; }; + +// ============================================================================= +// PROXY TRACER TYPES (from proxy/proxyTracer.ts) +// ============================================================================= + +/** OTel metric instruments used by the proxy tracer. */ +export type ProxyMetrics = { + requestsTotal: Counter; + requestDuration: Histogram; + tokensInput: Counter; + tokensOutput: Counter; + tokensCacheRead: Counter; + tokensCacheCreation: Counter; + tokensReasoning: Counter; + costTotal: Counter; + errorsTotal: Counter; + retriesTotal: Counter; + modelSubstitutionTotal: Counter; + requestBodySize: Histogram; + responseBodySize: Histogram; + fallbackAttemptsTotal: Counter; + fallbackSuccessTotal: Counter; + fallbackFailureTotal: Counter; +}; + +/** Context for a proxy request at the root span level. */ +export type ProxyRequestContext = { + requestId: string; + method: string; + path: string; + model: string; + stream: boolean; + toolCount: number; + sessionId?: string; + userAgent?: string; + clientApp?: string; +}; + +/** Context recorded when an account is selected for a proxy request. */ +export type AccountSelectionContext = { + strategy: string; + accountsTotal: number; + accountsHealthy: number; + selectedAccount: string; + accountType: string; + rateLimitBefore5h?: number; + rateLimitBefore7d?: number; +}; + +/** Context for a single upstream attempt (one per retry). */ +export type UpstreamAttemptContext = { + attempt: number; + account: string; + polyfillHeaders: boolean; + polyfillBody: boolean; + upstreamUrl: string; +}; + +/** Token usage and rate-limit utilisation recorded at end of request. */ +export type UsageContext = { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + reasoningTokens?: number; + rateLimitAfter5h?: number; + rateLimitAfter7d?: number; +}; + +// ============================================================================= +// PROXY ENV TYPES (from proxy/proxyEnv.ts) +// ============================================================================= + +/** Where a proxy env file path was sourced from. */ +export type ProxyEnvSource = "cli" | "environment" | "default" | "none"; + +/** Result of resolving which proxy env file to load. */ +export type ProxyEnvResolution = { + path?: string; + source: ProxyEnvSource; + required: boolean; +}; + +/** Result of loading the proxy env file. */ +export type ProxyEnvLoadResult = { + loaded: boolean; + path?: string; + source: ProxyEnvSource; +}; + +/** Options controlling proxy env file resolution. */ +export type ProxyEnvOptions = { + explicitEnvFile?: string; + env?: NodeJS.ProcessEnv; + homeDir?: string; +}; + +// ============================================================================= +// PROXY FETCH TYPES (from proxy/proxyFetch.ts) +// ============================================================================= + +/** Snapshot of proxy-related environment variables captured at startup. */ +export type ProxyEnvironmentSnapshot = { + httpsProxy?: string; + httpProxy?: string; + allProxy?: string; + socksProxy?: string; + noProxy?: string; +}; + +// ============================================================================= +// QUIET DETECTOR (from proxy/quietDetector.ts) +// ============================================================================= + +/** Result of a traffic-quiet check. */ +export type QuietStatus = { + isQuiet: boolean; + lastActivityAt: Date | null; + silenceDurationMs: number; +}; + +// ============================================================================= +// RAW STREAM CAPTURE (from proxy/rawStreamCapture.ts) +// ============================================================================= + +/** Accumulated upstream body capture from a raw stream. */ +export type RawStreamCapture = { + totalBytes: number; + text: string; + truncated: boolean; +}; + +/** Transformed stream pair used to capture upstream bodies without buffering. */ +export type RawStreamCaptureResult = { + stream: TransformStream; + capture: Promise; +}; + +// ============================================================================= +// REQUEST LOGGER (from proxy/requestLogger.ts) +// ============================================================================= + +/** Single captured body/headers entry written to disk by the proxy logger. */ +export type ProxyBodyCaptureEntry = { + timestamp: string; + requestId: string; + phase: string; + model: string; + stream: boolean; + headers?: Record; + body?: unknown; + bodySize?: number; + contentType?: string; + responseStatus?: number; + durationMs?: number; + account?: string; + accountType?: string; + attempt?: number; + traceId?: string; + spanId?: string; + metadata?: Record; +}; + +/** Persisted artifact produced when a body is stored to disk. */ +export type StoredBodyArtifact = { + bodyPath?: string; + bodySha256?: string; + redactedBodyBytes?: number; + storedFileBytes?: number; + redactedBody?: string; + bodyTruncated?: boolean; +}; + +/** File the proxy logger tracks for rotation and cleanup. */ +export type ManagedLogFile = { + path: string; + mtime: number; + size: number; +}; + +// ============================================================================= +// SSE INTERCEPTOR (from proxy/sseInterceptor.ts) +// ============================================================================= + +/** Individual content block observed during an SSE stream. */ +export type SSEContentBlock = { + index: number; + type: "text" | "thinking" | "tool_use" | "tool_result"; + /** Accumulated text for text blocks. Capped at MAX_BLOCK_CONTENT_BYTES. */ + text?: string; + /** Accumulated thinking content. Capped at MAX_BLOCK_CONTENT_BYTES. */ + thinking?: string; + /** Tool name for tool_use blocks. */ + toolName?: string; + /** Tool call id for tool_use blocks. */ + toolId?: string; + /** Accumulated partial JSON input for tool_use blocks. Capped at MAX_BLOCK_CONTENT_BYTES. */ + toolInput?: string; +}; + +/** Aggregated telemetry resolved when an SSE stream completes. */ +export type SSETelemetry = { + messageId: string; + model: string; + usage: { + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + totalTokens: number; + }; + contentBlocks: SSEContentBlock[]; + stopReason: string | null; + stopSequence: string | null; + eventCount: number; + streamDurationMs: number; + totalBytesReceived: number; + events: Array<{ type: string; timestamp: number; data: string }>; + rawText?: string; +}; + +/** Mutable accumulator the SSE interceptor uses internally. */ +export type TelemetryAccumulator = { + messageId: string; + model: string; + inputTokens: number; + outputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + contentBlocks: SSEContentBlock[]; + blockByteCounts: Map; + stopReason: string | null; + stopSequence: string | null; + eventCount: number; + startTime: number; + totalBytesReceived: number; + events: Array<{ type: string; timestamp: number; data: string }>; + rawTextChunks?: string[]; + rawTextBytes: number; + rawTextTruncated: boolean; + eventLogTruncated: boolean; +}; + +/** Result of createSSEInterceptor: the pass-through stream and a telemetry promise. */ +export type SSEInterceptorResult = { + stream: TransformStream; + telemetry: Promise; +}; + +/** Options for createSSEInterceptor. */ +export type SSEInterceptorOptions = { + captureRawText?: boolean; +}; + +// ============================================================================= +// UPDATE CHECKER (from proxy/updateChecker.ts, proxy/updateState.ts) +// ============================================================================= + +/** Outcome of a proxy auto-update version check against npm. */ +export type UpdateCheckResult = { + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; +}; + +/** Parsed major.minor.patch components of a semver string. */ +export type SemVer = { + major: number; + minor: number; + patch: number; +}; + +/** Entry describing a version suppressed from auto-update. */ +export type SuppressedVersion = { + suppressedAt: string; + reason: string; +}; + +/** Persisted state for the proxy auto-update feature. */ +export type UpdateState = { + lastCheckAt: string; + lastCheckVersion: string; + suppressedVersions: Record; + lastUpdateAt: string | null; + lastUpdateVersion: string | null; +}; + +// ============================================================================= +// YAML LOADER (from proxy/proxyConfig.ts) +// ============================================================================= + +/** Shape of the dynamically-imported js-yaml module. */ +export type YamlModule = { + load(content: string): unknown; + default?: { load(content: string): unknown }; +}; + +// ============================================================================= +// CLAUDE SNAPSHOT (from server/routes/claudeProxyRoutes.ts) +// ============================================================================= + +/** Parsed fields captured from a Claude Code client request body. */ +export type ClaudeSnapshotBody = { + metadataUserId?: string; + billingHeader?: string; + agentBlock?: string; + sessionId?: string; +}; + +/** Snapshot of headers and body from a Claude Code request, used for polyfill. */ +export type ClaudeSnapshot = { + accountKey: string; + capturedAt: string; + source: "claude-code"; + headers: Record; + body?: ClaudeSnapshotBody; +}; + +/** Parsed shape of a Claude API error body. */ +export type ParsedClaudeError = { + errorType?: string; + message?: string; +}; + +// ============================================================================= +// PROXY CLI COMMAND TYPES (from cli/commands/proxy.ts) +// ============================================================================= + +/** ora spinner instance held by proxy CLI commands, nullable when --quiet. */ +export type ProxySpinner = Ora | null; + +/** Load-balancing strategy used by the proxy across accounts. */ +export type ProxyStartStrategy = "round-robin" | "fill-first"; + +/** Alias for the model router's constructor configuration. */ +export type ProxyModelRouterConfig = ProxyRoutingConfig; + +/** Partial proxy config consumed by the start command. */ +export type LoadedProxyConfig = { + routing?: Partial & { + strategy?: ProxyStartStrategy; + }; +}; + +/** + * Handle for a NeuroLink runtime created by the proxy start command. + * The `neurolink` field is typed structurally (only the method used by the + * proxy layer is exposed) so types/proxy.ts does not depend on the full + * NeuroLink class. + */ +export type ProxyNeurolinkRuntime = { + neurolink: { + getToolRegistry(): MCPToolRegistry; + }; + cleanupLogs: (daysToKeep?: number, maxFiles?: number) => void; +}; + +/** Hono app + readiness state created by the proxy start command. */ +export type ProxyStartApp = { + app: Hono; + readiness: ProxyReadinessState; +}; + +/** Stats shape consumed by the proxy status printer. */ +export type StatusStats = { + totalAttempts?: number; + totalRequests: number; + totalSuccess: number; + totalErrors: number; + totalRateLimits: number; + accounts?: { + label: string; + type: string; + attempts?: number; + requests?: number; + success?: number; + errors?: number; + rateLimits?: number; + cooling: boolean; + }[]; +}; + +/** Sub-action of the `proxy telemetry` CLI command. */ +export type ProxyTelemetryAction = + | "setup" + | "start" + | "stop" + | "status" + | "logs" + | "import-dashboard"; diff --git a/src/lib/types/rag.ts b/src/lib/types/rag.ts index 3b0e1a6d3..9c08d48d7 100644 --- a/src/lib/types/rag.ts +++ b/src/lib/types/rag.ts @@ -1491,3 +1491,72 @@ export type RAGCommandArgs = { /** Use Graph RAG */ graph?: boolean; }; + +// ============================================================================= +// JSON CHUNKER (from rag/chunking/jsonChunker.ts) +// ============================================================================= + +/** Options for the recursive JSON chunk extractor. */ +export type ExtractChunksOptions = { + data: unknown; + path: string; + depth: number; + maxDepth: number; + maxSize: number; + splitKeys: string[]; + preserveKeys: string[]; + includeJsonPath: boolean; +}; + +// ============================================================================= +// MDOCUMENT (from rag/document/MDocument.ts) +// ============================================================================= + +/** Document processing state held by MDocument. */ +export type DocumentState = { + content: string; + type: DocumentType; + metadata: Record; + chunks: Chunk[]; + embeddings: number[][]; + history: string[]; +}; + +// ============================================================================= +// RAG ERRORS (from rag/errors/RAGError.ts) +// ============================================================================= + +/** Canonical RAG error code. */ +export type RAGErrorCode = + | "RAG_CHUNKING_ERROR" + | "RAG_CHUNKING_INVALID_CONFIG" + | "RAG_CHUNKING_STRATEGY_NOT_FOUND" + | "RAG_CHUNKING_EMPTY_CONTENT" + | "RAG_CHUNKING_SIZE_EXCEEDED" + | "RAG_METADATA_EXTRACTION_ERROR" + | "RAG_METADATA_EXTRACTION_TIMEOUT" + | "RAG_METADATA_SCHEMA_INVALID" + | "RAG_METADATA_EXTRACTOR_NOT_FOUND" + | "RAG_EMBEDDING_ERROR" + | "RAG_EMBEDDING_DIMENSION_MISMATCH" + | "RAG_EMBEDDING_RATE_LIMIT" + | "RAG_EMBEDDING_PROVIDER_ERROR" + | "RAG_VECTOR_QUERY_ERROR" + | "RAG_VECTOR_QUERY_TIMEOUT" + | "RAG_VECTOR_STORE_UNAVAILABLE" + | "RAG_VECTOR_STORE_CONNECTION_ERROR" + | "RAG_VECTOR_INDEX_NOT_FOUND" + | "RAG_RERANKER_ERROR" + | "RAG_RERANKER_NOT_FOUND" + | "RAG_RERANKER_API_ERROR" + | "RAG_GRAPH_ERROR" + | "RAG_GRAPH_TRAVERSAL_ERROR" + | "RAG_GRAPH_NODE_NOT_FOUND" + | "RAG_PIPELINE_ERROR" + | "RAG_PIPELINE_STAGE_FAILED" + | "RAG_PIPELINE_PARTIAL_FAILURE" + | "RAG_CIRCUIT_BREAKER_OPEN" + | "RAG_CIRCUIT_BREAKER_HALF_OPEN_LIMIT" + | "RAG_OPERATION_TIMEOUT" + | "RAG_RETRY_EXHAUSTED" + | "RAG_INVALID_CONFIGURATION"; diff --git a/src/lib/types/scorer.ts b/src/lib/types/scorer.ts index 88d674667..5a5339078 100644 --- a/src/lib/types/scorer.ts +++ b/src/lib/types/scorer.ts @@ -459,3 +459,227 @@ export type ReportConfig = { /** Include timing information */ includeTiming?: boolean; }; + +// ============================================================================= +// CONTENT SIMILARITY SCORER +// ============================================================================= + +/** Similarity metric types. */ +export type SimilarityMetric = + | "jaccard" + | "cosine" + | "levenshtein" + | "dice" + | "overlap"; + +/** Configuration specific to content similarity scoring. */ +export type ContentSimilarityConfig = RuleScorerConfig & { + metric?: SimilarityMetric; + metrics?: SimilarityMetric[]; + metricCombination?: "average" | "min" | "max" | "weighted"; + metricWeights?: Record; + normalizeText?: boolean; + tokenLevel?: "word" | "character" | "ngram"; + ngramSize?: number; + compareWith?: "groundTruth" | "context" | "custom"; + referenceText?: string; +}; + +/** Similarity calculation detail row. */ +export type SimilarityDetails = { + metric: SimilarityMetric; + score: number; + responseTokens: number; + referenceTokens: number; + commonTokens?: number; +}; + +// ============================================================================= +// FORMAT SCORER +// ============================================================================= + +/** Expected format types evaluated by the format scorer. */ +export type FormatType = + | "json" + | "markdown" + | "code" + | "list" + | "numbered-list" + | "bullet-list" + | "table" + | "yaml" + | "xml" + | "plain" + | "html" + | "custom"; + +/** Code language types for the code-format validator. */ +export type CodeLanguage = + | "javascript" + | "typescript" + | "python" + | "java" + | "c" + | "cpp" + | "csharp" + | "go" + | "rust" + | "sql" + | "bash" + | "any"; + +/** Configuration specific to format scoring. */ +export type FormatScorerConfig = RuleScorerConfig & { + expectedFormat?: FormatType; + allowedFormats?: FormatType[]; + codeLanguage?: CodeLanguage; + jsonSchema?: object; + markdownRequirements?: { + hasHeadings?: boolean; + hasCodeBlocks?: boolean; + hasLinks?: boolean; + hasLists?: boolean; + minHeadingLevel?: number; + maxHeadingLevel?: number; + }; + listRequirements?: { + minItems?: number; + maxItems?: number; + itemPattern?: string; + }; + customPattern?: string; + strictFormat?: boolean; +}; + +/** Format validation result. */ +export type FormatValidationResult = { + isValid: boolean; + detectedFormat: FormatType | null; + issues: string[]; + structureAnalysis?: object; +}; + +// ============================================================================= +// KEYWORD COVERAGE SCORER +// ============================================================================= + +/** Configuration specific to keyword coverage scoring. */ +export type KeywordCoverageConfig = RuleScorerConfig & { + keywords?: string[]; + minCoverage?: number; + caseInsensitive?: boolean; + wordBoundary?: boolean; + synonyms?: Record; + keywordWeights?: Record; +}; + +/** Keyword coverage result details. */ +export type KeywordCoverageDetails = { + totalKeywords: number; + foundKeywords: string[]; + missingKeywords: string[]; + coverageRatio: number; + weightedCoverage: number; +}; + +// ============================================================================= +// LENGTH SCORER +// ============================================================================= + +/** Length measurement unit. */ +export type LengthUnit = + | "words" + | "characters" + | "sentences" + | "paragraphs" + | "tokens"; + +/** Length constraint type. */ +export type LengthConstraintType = + | "exact" + | "range" + | "minimum" + | "maximum" + | "ratio"; + +/** Configuration specific to length scoring. */ +export type LengthScorerConfig = RuleScorerConfig & { + unit?: LengthUnit; + constraintType?: LengthConstraintType; + minLength?: number; + maxLength?: number; + exactLength?: number; + tolerance?: number; + ratioTarget?: number; + ratioReference?: "query" | "context"; + scoringMode?: "binary" | "proportional"; +}; + +/** Length measurement result. */ +export type LengthMeasurement = { + words: number; + characters: number; + sentences: number; + paragraphs: number; + estimatedTokens: number; +}; + +// ============================================================================= +// SCORER REGISTRY (from evaluation/scorers/scorerRegistry.ts) +// ============================================================================= + +/** Row describing a built-in scorer in the scorer registry seed list. */ +export type BuiltInScorerDefinition = { + metadata: ScorerMetadata; + factory: ScorerFactory; + aliases?: string[]; +}; + +// ============================================================================= +// LLM SCORER INTERMEDIATE SHAPES (parsed from judge-LLM JSON responses) +// ============================================================================= + +/** Bias instance reported by the bias-detection scorer. */ +export type BiasInstance = { + type?: string; + text?: string; + explanation?: string; + severity?: string; +}; + +/** Context score row reported by the context-relevancy scorer. */ +export type ContextScoreItem = { + index?: number; + score?: number; + reasoning?: string; + keyInfo?: string[]; +}; + +/** Claim row reported by the faithfulness scorer. */ +export type ClaimItem = { + claim?: string; + supported?: boolean; + evidence?: string; +}; + +/** Hallucination row reported by the hallucination scorer. */ +export type HallucinationItem = { + text?: string; + reason?: string; + severity?: string; +}; + +/** Tone-shift location reported by the tone-consistency scorer. */ +export type ToneShift = { + location?: string; + from?: string; + to?: string; + severity?: string; +}; + +/** Flagged content row reported by the toxicity scorer. */ +export type FlaggedItem = { + text?: string; + category?: string; + severity?: string; +}; diff --git a/src/lib/types/server.ts b/src/lib/types/server.ts index 8a4605c83..201424b0a 100644 --- a/src/lib/types/server.ts +++ b/src/lib/types/server.ts @@ -1338,3 +1338,130 @@ export type CacheEntry = { ttlMs: number; headers?: Record; }; + +/** Voice-server conversation turn — superset role set. */ +export type Message = { + role: "system" | "user" | "assistant"; + content: string; +}; + +/** Subset of Message that excludes the system role (assistant+user only). */ +export type ConversationMessage = { + role: "user" | "assistant"; + content: string; +}; + +// ============================================================================= +// OPENAPI GENERATOR (from server/openapi/generator.ts) +// ============================================================================= + +/** Configuration passed to the OpenAPI spec generator. */ +export type OpenAPIGeneratorConfig = { + info?: { + title?: string; + version?: string; + description?: string; + }; + servers?: Array<{ + url: string; + description?: string; + }>; + includeSecurity?: boolean; + basePath?: string; + additionalTags?: Array<{ + name: string; + description: string; + }>; + customSchemas?: Record; + routes?: RouteDefinition[]; +}; + +/** Structured OpenAPI 3.1 specification object. */ +export type OpenAPISpec = { + openapi: "3.1.0"; + info: JsonObject; + servers: JsonObject[]; + tags: JsonObject[]; + paths: Record; + components: { + schemas: Record; + securitySchemes?: Record; + parameters?: Record; + }; + security?: JsonObject[]; +}; + +// ============================================================================= +// ROUTE HELPERS (from server/routes/index.ts) +// ============================================================================= + +/** Options for createAllRoutes / createRoutes. */ +export type CreateRoutesOptions = { + enableSwagger?: boolean; + getRoutes?: () => RouteDefinition[]; + claudeProxy?: boolean; +}; + +// ============================================================================= +// DATA STREAM (from server/streaming/dataStream.ts) +// ============================================================================= + +/** Data stream finish event. */ +export type FinishEvent = DataStreamEvent & { + type: "finish"; + data: { + reason?: string; + usage?: { + input: number; + output: number; + total: number; + }; + }; +}; + +/** Configuration for DataStreamWriter. */ +export type DataStreamWriterConfig = { + write: (chunk: string) => void | Promise; + close?: () => void | Promise; + format?: "sse" | "ndjson"; + includeTimestamps?: boolean; +}; + +/** Configuration for the DataStreamResponse wrapper. */ +export type DataStreamResponseConfig = { + contentType?: "text/event-stream" | "application/x-ndjson"; + headers?: Record; + keepAliveInterval?: number; + includeTimestamps?: boolean; +}; + +/** Options for a single SSE message. */ +export type SSEEventOptions = { + event?: string; + data: string; + id?: string; + retry?: number; +}; + +// ============================================================================= +// VOICE WEBSOCKET (from server/voice/voiceWebSocketHandler.ts) +// ============================================================================= + +/** Single token emitted by the Soniox STT stream. */ +export type SonioxToken = { + is_final?: boolean; + text?: string; +}; + +/** Envelope received from the Soniox STT WebSocket. */ +export type SonioxMessage = { + error?: string; + status?: string; + type?: string; + tokens?: SonioxToken[]; +}; + +/** Control message received from the voice client over WebSocket. */ +export type ClientControlMessage = { + type?: string; +}; diff --git a/src/lib/types/span.ts b/src/lib/types/span.ts index 2a04505de..816612f2e 100644 --- a/src/lib/types/span.ts +++ b/src/lib/types/span.ts @@ -51,6 +51,7 @@ export enum SpanStatus { UNSET = 0, OK = 1, ERROR = 2, + WARNING = 3, } /** diff --git a/src/lib/types/tools.ts b/src/lib/types/tools.ts index 4c144e647..4ef73b149 100644 --- a/src/lib/types/tools.ts +++ b/src/lib/types/tools.ts @@ -4,6 +4,7 @@ */ import { z } from "zod"; +import type { Tool } from "ai"; import type { ErrorInfo, JsonObject, @@ -416,6 +417,61 @@ export type SimpleTool = { execute: (params: TArgs, context?: ToolContext) => Promise; }; +/** + * Simple tool type accepted by the SDK registerTool() helper. Uses + * SDKToolContext (richer tool context with request metadata). + */ +export type SdkSimpleTool = Omit< + SimpleTool, + "execute" +> & { + description: string; + parameters?: ZodUnknownSchema; + execute: (params: TArgs, context?: SDKToolContext) => Promise; + metadata?: { + category?: string; + version?: string; + author?: string; + tags?: string[]; + documentation?: string; + [key: string]: JsonValue | undefined; + }; +}; + +// ============================================================================= +// DIRECT TOOLS CATEGORIES (from agent/directTools.ts) +// ============================================================================= + +/** Subset of directAgentTools exposing only the "basic" category. */ +export type BasicToolsMap = { + getCurrentTime: Tool; + calculateMath: Tool; +}; + +/** Subset of directAgentTools exposing only the "filesystem" category. */ +export type FilesystemToolsMap = { + readFile: Tool; + listDirectory: Tool; + writeFile: Tool; +}; + +/** Subset of directAgentTools exposing the "utility" category. */ +export type UtilityToolsMap = { + getCurrentTime: Tool; + calculateMath: Tool; + listDirectory: Tool; +}; + +/** Full directAgentTools map, with the opt-in bashTool appended. */ +export type AllToolsMap = { + getCurrentTime: Tool; + calculateMath: Tool; + readFile: Tool; + listDirectory: Tool; + writeFile: Tool; + executeBashCommand?: Tool; +}; + /** * Tool registry entry */ diff --git a/src/lib/types/tts.ts b/src/lib/types/tts.ts index b072cb525..ee4c3eb47 100644 --- a/src/lib/types/tts.ts +++ b/src/lib/types/tts.ts @@ -223,3 +223,10 @@ export type TTSChunk = { /** Sample rate in Hz */ sampleRate?: number; }; + +/** Message envelope received from the Cartesia TTS WebSocket. */ +export type CartesiaMessage = { + data?: string; + done?: boolean; + error?: string; +}; diff --git a/src/lib/types/utilities.ts b/src/lib/types/utilities.ts index c85b4b740..47786a0c8 100644 --- a/src/lib/types/utilities.ts +++ b/src/lib/types/utilities.ts @@ -281,3 +281,35 @@ export type ImageCacheStats = { /** Cache hit rate as percentage */ hitRate: number; }; + +// ============================================================================= +// RATE LIMITER (from utils/rateLimiter.ts) +// ============================================================================= + +/** + * Pending request held by TokenBucketRateLimiter's queue. + * Named RateLimiterPendingRequest to disambiguate from the MCP + * PendingRequest in mcp.ts (Rule 9). + */ +export type RateLimiterPendingRequest = { + resolve: () => void; + reject: (error: Error) => void; + timestamp: number; + timeoutTimer?: ReturnType; +}; + +// ============================================================================= +// TOOL END EMITTER (from utils/toolEndEmitter.ts) +// ============================================================================= + +/** + * Shape of a completed tool result as returned by the AI SDK in + * `onStepFinish`. Both `output` (AI SDK v4) and `result` (older shape) + * are supported so the helper works across SDK versions. + */ +export type StepToolResult = { + toolName: string; + output?: unknown; + result?: unknown; + error?: string; +}; diff --git a/src/lib/types/workflow.ts b/src/lib/types/workflow.ts index 5b1c66a23..53b6deea9 100644 --- a/src/lib/types/workflow.ts +++ b/src/lib/types/workflow.ts @@ -5,6 +5,7 @@ * Testing Phase: Focuses on original output + evaluation metrics for AB testing */ +import type { z } from "zod"; import type { AnalyticsData } from "./analytics.js"; import { AIProviderName } from "../constants/enums.js"; import type { JsonValue } from "./common.js"; @@ -790,3 +791,22 @@ export type RunWorkflowOptions = { /** Enable progressive streaming (yield preliminary response) */ streaming?: boolean; }; + +/** + * Generic workflow validation result — replaces three near-identical types + * (WorkflowConfigValidationResult, ModelConfigValidationResult, + * JudgeConfigValidationResult). Named with `Workflow*` prefix to avoid + * collision with `tools.ts#ValidationResult` (Rule 9). + */ +export type WorkflowValidation = { + success: boolean; + data?: T; + error?: z.ZodError; +}; + +/** Progressive workflow response chunk streamed by runWorkflow(). */ +export type WorkflowStreamChunk = { + type: "preliminary" | "final"; + content: string; + partialResult?: Partial; +}; diff --git a/src/lib/utils/async/retry.ts b/src/lib/utils/async/retry.ts index 534b05d5d..45b6018e8 100644 --- a/src/lib/utils/async/retry.ts +++ b/src/lib/utils/async/retry.ts @@ -5,21 +5,20 @@ */ import { delay } from "./delay.js"; -import type { AsyncRetryOptions } from "../../types/index.js"; - -/** - * Local alias: the canonical type was renamed to AsyncRetryOptions to avoid - * collision with other RetryOptions types in the codebase. - */ -type RetryOptions = AsyncRetryOptions; +import type { RetryOptions } from "../../types/index.js"; /** * Default retry configuration. */ -export const DEFAULT_RETRY_OPTIONS: RetryOptions = { - maxRetries: 3, - baseDelayMs: 1000, - maxDelayMs: 30000, +export const DEFAULT_RETRY_OPTIONS: Required< + Pick< + RetryOptions, + "maxAttempts" | "initialDelay" | "maxDelay" | "backoffMultiplier" + > +> = { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 30000, backoffMultiplier: 2, }; @@ -128,25 +127,25 @@ export async function retry( fn: () => Promise, options: Partial = {}, ): Promise { - const config: RetryOptions = { + const config = { ...DEFAULT_RETRY_OPTIONS, ...options, }; const { - maxRetries, - baseDelayMs, - maxDelayMs, - backoffMultiplier = 2, - shouldRetry = () => true, + maxAttempts, + initialDelay, + maxDelay, + backoffMultiplier, + retryCondition = () => true, onRetry, } = config; let lastError: Error = new Error("Retry failed"); - let currentDelay = baseDelayMs; + let currentDelay = initialDelay; // Total attempts = initial attempt + retries - const totalAttempts = maxRetries + 1; + const totalAttempts = maxAttempts + 1; for (let attempt = 1; attempt <= totalAttempts; attempt++) { try { @@ -165,16 +164,16 @@ export async function retry( } // Check if we should retry this error - if (!shouldRetry(err, attempt)) { + if (!retryCondition(err)) { throw err; } // Calculate delay with exponential backoff (capped at maxDelay) - const delayMs = Math.min(currentDelay, maxDelayMs); + const delayMs = Math.min(currentDelay, maxDelay); // Notify about retry if (onRetry) { - onRetry(err, attempt, delayMs); + onRetry(attempt, err); } // Wait before next attempt diff --git a/src/lib/utils/fileDetector.ts b/src/lib/utils/fileDetector.ts index 652d8558b..c21842917 100644 --- a/src/lib/utils/fileDetector.ts +++ b/src/lib/utils/fileDetector.ts @@ -11,6 +11,7 @@ import { audioProcessor } from "../processors/media/AudioProcessor.js"; import { videoProcessor } from "../processors/media/VideoProcessor.js"; import type { CSVProcessorOptions, + DetectionStrategy, FileDetectionResult, FileDetectorOptions, FileInput, @@ -198,13 +199,6 @@ function formatFileSize(bytes: number): string { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } -/** - * Detection strategy interface - */ -type DetectionStrategy = { - detect(input: FileInput): Promise; -}; - /** * Centralized file type detection and processing * diff --git a/src/lib/utils/imageCompressor.ts b/src/lib/utils/imageCompressor.ts index b898da05e..b0daf0b38 100644 --- a/src/lib/utils/imageCompressor.ts +++ b/src/lib/utils/imageCompressor.ts @@ -1,9 +1,17 @@ import sharp from "sharp"; import { withTimeout } from "./async/index.js"; -import type { ProviderName } from "../types/index.js"; - -const SUPPORTED_FORMATS = ["jpeg", "png", "webp"] as const; -type SupportedFormat = (typeof SUPPORTED_FORMATS)[number]; +import type { + CompressionOptions, + CompressionResult, + ProviderName, + SupportedFormat, +} from "../types/index.js"; + +const SUPPORTED_FORMATS: readonly SupportedFormat[] = [ + "jpeg", + "png", + "webp", +] as const; const IMAGE_COMPRESSION_TIMEOUT_MS = 30_000; @@ -27,25 +35,6 @@ export const PROVIDER_IMAGE_LIMITS: Record = { auto: 5 * 1024 * 1024, // 5MB (conservative fallback) }; -type CompressionOptions = { - provider: ProviderName; - quality?: number; // 1-100, default 80 - maxDimension?: number; // Max width/height in pixels - format?: SupportedFormat; -}; - -type CompressionResult = { - buffer: Buffer; - originalSize: number; - compressedSize: number; - compressionRatio: number; - metadata: { - width: number; - height: number; - format: string; - }; -}; - /** * Compress an image to meet provider-specific size limits * @param imageBuffer - Input image buffer diff --git a/src/lib/utils/messageBuilder.ts b/src/lib/utils/messageBuilder.ts index e3649c48b..e332d67ac 100644 --- a/src/lib/utils/messageBuilder.ts +++ b/src/lib/utils/messageBuilder.ts @@ -32,14 +32,15 @@ import type { FileReferenceRegistry } from "../files/fileReferenceRegistry.js"; import { SIZE_TIER_THRESHOLDS } from "../types/index.js"; import type { ChatMessage, - MessageContent, - MultimodalChatMessage, + Content, + FileInput, FileWithMetadata, GenerateOptions, - TextGenerationOptions, - Content, ImageWithAltText, + MessageContent, + MultimodalChatMessage, StreamOptions, + TextGenerationOptions, } from "../types/index.js"; import { tracers, ATTR, withSpan } from "../telemetry/index.js"; import { FileDetector } from "./fileDetector.js"; @@ -842,7 +843,7 @@ function appendDetectedFileResult( metadata?: Record; images?: Array; }, - file: AnyFileInput, + file: FileInput, options: GenerateOptions, ): void { const filename = extractFilename(file); @@ -2015,13 +2016,10 @@ async function convertMultimodalToProviderFormat( return content; } -/** Union type for file inputs: raw Buffer, path/URL string, or object with metadata */ -type AnyFileInput = Buffer | string | FileWithMetadata; - /** * Type guard for FileWithMetadata objects. */ -function isFileWithMetadata(file: AnyFileInput): file is FileWithMetadata { +function isFileWithMetadata(file: FileInput): file is FileWithMetadata { return ( typeof file === "object" && !Buffer.isBuffer(file) && @@ -2034,7 +2032,7 @@ function isFileWithMetadata(file: AnyFileInput): file is FileWithMetadata { * Extract filename from file input. * Supports Buffers (generic name), strings (path/URL), and FileWithMetadata objects. */ -function extractFilename(file: AnyFileInput, index: number = 0): string { +function extractFilename(file: FileInput, index: number = 0): string { if (isFileWithMetadata(file)) { return file.filename; } @@ -2061,7 +2059,7 @@ function extractFilename(file: AnyFileInput, index: number = 0): string { * For strings that are file paths: returns the stat size. * For URLs/data URIs: returns a rough estimate from string length. */ -function getFileSize(file: AnyFileInput): number { +function getFileSize(file: FileInput): number { if (isFileWithMetadata(file)) { return file.buffer.length; } @@ -2086,7 +2084,7 @@ function getFileSize(file: AnyFileInput): number { * For file paths: reads the file. * For URLs/data URIs: returns null (not supported for lazy registration). */ -async function getFileBuffer(file: AnyFileInput): Promise { +async function getFileBuffer(file: FileInput): Promise { if (isFileWithMetadata(file)) { return file.buffer; } @@ -2107,9 +2105,7 @@ async function getFileBuffer(file: AnyFileInput): Promise { /** * Determine the source type of a file input. */ -function getFileSource( - file: AnyFileInput, -): "buffer" | "path" | "url" | "datauri" { +function getFileSource(file: FileInput): "buffer" | "path" | "url" | "datauri" { if (isFileWithMetadata(file)) { return "buffer"; } @@ -2136,7 +2132,7 @@ function getFileSource( * fall through to full processing). */ async function tryRegisterFileReference( - file: AnyFileInput, + file: FileInput, fileSize: number, registry: FileReferenceRegistry, index: number = 0, diff --git a/src/lib/utils/rateLimiter.ts b/src/lib/utils/rateLimiter.ts index 5ebd00da0..88a600374 100644 --- a/src/lib/utils/rateLimiter.ts +++ b/src/lib/utils/rateLimiter.ts @@ -9,7 +9,10 @@ import { logger } from "./logger.js"; import { ErrorFactory } from "./errorHandling.js"; -import type { RateLimiterConfig } from "../types/index.js"; +import type { + RateLimiterConfig, + RateLimiterPendingRequest, +} from "../types/index.js"; /** * Default configuration: 10 downloads per second @@ -22,16 +25,6 @@ const DEFAULT_CONFIG: RateLimiterConfig = { queueTimeoutMs: 30000, }; -/** - * Pending request in the queue - */ -type PendingRequest = { - resolve: () => void; - reject: (error: Error) => void; - timestamp: number; - timeoutTimer?: ReturnType; -}; - /** * Token Bucket Rate Limiter * @@ -43,7 +36,7 @@ type PendingRequest = { export class TokenBucketRateLimiter { private tokens: number; private config: RateLimiterConfig; - private queue: PendingRequest[] = []; + private queue: RateLimiterPendingRequest[] = []; private refillTimer: ReturnType | null = null; private lastRefillTime: number; diff --git a/src/lib/utils/redis.ts b/src/lib/utils/redis.ts index 0feed3ee5..b99d10af1 100644 --- a/src/lib/utils/redis.ts +++ b/src/lib/utils/redis.ts @@ -6,14 +6,12 @@ import { createClient, type RedisClientOptions } from "redis"; import type { ChatMessage, + RedisClient, RedisConversationObject, RedisStorageConfig, } from "../types/index.js"; import { logger } from "./logger.js"; -// Redis client type -type RedisClient = ReturnType; - const SESSION_ONLY_PREFIX = "session-only:"; // Connection pool - keyed by host:port:db diff --git a/src/lib/utils/toolEndEmitter.ts b/src/lib/utils/toolEndEmitter.ts new file mode 100644 index 000000000..9a867b422 --- /dev/null +++ b/src/lib/utils/toolEndEmitter.ts @@ -0,0 +1,79 @@ +/** + * toolEndEmitter — shared helper for emitting `tool:end` events from + * AI-SDK `onStepFinish` callbacks. + * + * Pipeline B (metrics aggregator) listens for `tool:end` on the NeuroLink + * EventEmitter. When tools are executed by the AI SDK internally (via + * `generateText` / `streamText`) the SDK calls `onStepFinish` with the + * completed tool results. Without this helper those results are silently + * stored but never surfaced as `tool:end` events, leaving Pipeline B with + * zero tool spans for AI-SDK-driven tool calls (gaps G5 and S2). + * + * @module utils/toolEndEmitter + */ + +import { createToolEventPayload } from "../core/toolEvents.js"; +import type { + NeuroLinkEvents, + StepToolResult, + TypedEventEmitter, +} from "../types/index.js"; + +/** + * Emit a `tool:end` event for every completed tool result in an + * `onStepFinish` callback. + * + * @param emitter - The NeuroLink event emitter (obtained via + * `neurolink.getEventEmitter()`). When `undefined` the function is a + * no-op so callers need not guard every call site. + * @param toolResults - The `toolResults` array from `onStepFinish`. When + * `undefined` or empty the function is a no-op. + */ +export function emitToolEndFromStepFinish( + emitter: TypedEventEmitter | undefined, + toolResults: StepToolResult[] | undefined, +): void { + if (!emitter || !toolResults || toolResults.length === 0) { + return; + } + + for (const tr of toolResults) { + const output = tr.output ?? tr.result; + const isError = + !!tr.error || + (output !== null && + output !== undefined && + typeof output === "object" && + "isError" in output && + (output as Record).isError === true); + + let errorMessage: string | undefined; + if (isError) { + if (tr.error) { + errorMessage = tr.error; + } else if (output && typeof output === "object") { + const content = (output as Record).content; + if (Array.isArray(content)) { + const texts = (content as Array<{ type?: string; text?: string }>) + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text as string); + errorMessage = + texts.length > 0 ? texts.join(" ") : "Tool returned isError: true"; + } else { + errorMessage = "Tool returned isError: true"; + } + } + } + + emitter.emit( + "tool:end", + createToolEventPayload(tr.toolName, { + responseTime: 0, + success: !isError, + timestamp: Date.now(), + result: output, + error: errorMessage, + }), + ); + } +} diff --git a/src/lib/workflow/config.ts b/src/lib/workflow/config.ts index d3156a5d3..5841bf795 100644 --- a/src/lib/workflow/config.ts +++ b/src/lib/workflow/config.ts @@ -15,6 +15,7 @@ import type { WorkflowModelConfig, ModelGroup, WorkflowConfig, + WorkflowValidation, } from "../types/index.js"; // ============================================================================ // CONSTANTS @@ -184,10 +185,8 @@ const WorkflowConfigSchemaBase = z.object({ updatedAt: z.string().optional(), }); -type WorkflowConfigSchemaType = z.infer; - export const WorkflowConfigSchema = WorkflowConfigSchemaBase.refine( - (data: WorkflowConfigSchemaType) => { + (data) => { // Cannot have both judge and judges if (data.judge && data.judges) { return false; @@ -198,7 +197,7 @@ export const WorkflowConfigSchema = WorkflowConfigSchemaBase.refine( message: 'Cannot specify both "judge" and "judges" - use one or the other', }, ).refine( - (data: WorkflowConfigSchemaType) => { + (data) => { // Ensemble and adaptive need at least 2 models // Check flat models array if modelGroups not provided if (data.type === "ensemble" || data.type === "adaptive") { @@ -350,15 +349,6 @@ export function mergeWithDefaults(config: WorkflowConfig): WorkflowConfig { }; } -/** - * Validation result for workflow configuration - */ -type WorkflowConfigValidationResult = { - success: boolean; - data?: WorkflowConfig; - error?: z.ZodError; -}; - /** * Validate workflow configuration * @param config - Partial workflow configuration to validate @@ -366,7 +356,7 @@ type WorkflowConfigValidationResult = { */ export function validateWorkflowConfig( config: Partial, -): WorkflowConfigValidationResult { +): WorkflowValidation { const result = WorkflowConfigSchema.safeParse(config); if (result.success) { @@ -402,15 +392,6 @@ export function createWorkflowConfig( return mergeWithDefaults(base); } -/** - * Validation result for model configuration - */ -type ModelConfigValidationResult = { - success: boolean; - data?: WorkflowModelConfig; - error?: z.ZodError; -}; - /** * Validate model configuration * @param config - Partial model configuration to validate @@ -418,7 +399,7 @@ type ModelConfigValidationResult = { */ export function validateModelConfig( config: Partial, -): ModelConfigValidationResult { +): WorkflowValidation { const result = ModelConfigSchema.safeParse(config); if (result.success) { @@ -427,15 +408,6 @@ export function validateModelConfig( return { success: false, error: result.error }; } -/** - * Validation result for judge configuration - */ -type JudgeConfigValidationResult = { - success: boolean; - data?: JudgeConfig; - error?: z.ZodError; -}; - /** * Validate judge configuration * @param config - Partial judge configuration to validate @@ -443,7 +415,7 @@ type JudgeConfigValidationResult = { */ export function validateJudgeConfig( config: Partial, -): JudgeConfigValidationResult { +): WorkflowValidation { const result = JudgeConfigSchema.safeParse(config); if (result.success) { diff --git a/src/lib/workflow/core/ensembleExecutor.ts b/src/lib/workflow/core/ensembleExecutor.ts index cefa84a20..9940e267f 100644 --- a/src/lib/workflow/core/ensembleExecutor.ts +++ b/src/lib/workflow/core/ensembleExecutor.ts @@ -25,6 +25,8 @@ import { getMetricsAggregator, } from "../../observability/index.js"; import { WorkflowError } from "../../types/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; const functionTag = "EnsembleExecutor"; // ============================================================================ @@ -38,6 +40,23 @@ const functionTag = "EnsembleExecutor"; */ export async function executeEnsemble( options: ExecuteEnsembleOptions, +): Promise { + return withSpan( + { + name: "neurolink.workflow.ensemble.execute", + tracer: tracers.workflow, + attributes: { + "workflow.model_count": options.models.length, + "workflow.parallelism": options.executionConfig?.parallelism ?? 10, + }, + }, + async (otelSpan) => executeEnsembleInner(options, otelSpan), + ); +} + +async function executeEnsembleInner( + options: ExecuteEnsembleOptions, + otelSpan: import("@opentelemetry/api").Span, ): Promise { const startTime = Date.now(); const { prompt, models, executionConfig, systemPrompt, workflowDefaults } = @@ -126,6 +145,10 @@ export async function executeEnsemble( ); getMetricsAggregator().recordSpan(endedSpan); + otelSpan.setAttribute("workflow.success_count", successCount); + otelSpan.setAttribute("workflow.failure_count", failureCount); + otelSpan.setAttribute("workflow.total_time_ms", totalTime); + return { responses, totalTime, @@ -370,6 +393,38 @@ export async function executeModelGroups( _executionConfig?: ExecutionConfig, systemPrompt?: string, workflowDefaultSystemPrompt?: string, +): Promise { + return withSpan( + { + name: "neurolink.workflow.layers.execute", + tracer: tracers.workflow, + attributes: { + "workflow.group_count": groups.length, + "workflow.total_models": groups.reduce( + (n, g) => n + g.models.length, + 0, + ), + }, + }, + async (otelSpan) => + executeModelGroupsInner( + groups, + prompt, + _executionConfig, + systemPrompt, + workflowDefaultSystemPrompt, + otelSpan, + ), + ); +} + +async function executeModelGroupsInner( + groups: ModelGroup[], + prompt: string, + _executionConfig: ExecutionConfig | undefined, + systemPrompt: string | undefined, + workflowDefaultSystemPrompt: string | undefined, + otelSpan: import("@opentelemetry/api").Span, ): Promise { const startTime = Date.now(); const allResponses: EnsembleResponse[] = []; @@ -441,6 +496,10 @@ export async function executeModelGroups( failureCount: totalFailureCount, }); + otelSpan.setAttribute("workflow.success_count", totalSuccessCount); + otelSpan.setAttribute("workflow.failure_count", totalFailureCount); + otelSpan.setAttribute("workflow.total_time_ms", totalTime); + return { responses: allResponses, totalTime, diff --git a/src/lib/workflow/core/judgeScorer.ts b/src/lib/workflow/core/judgeScorer.ts index 418dec4f0..98cbecb8d 100644 --- a/src/lib/workflow/core/judgeScorer.ts +++ b/src/lib/workflow/core/judgeScorer.ts @@ -3,6 +3,7 @@ * Judge-based scoring system for ensemble response evaluation */ +import { SpanStatusCode } from "@opentelemetry/api"; import { AIProviderFactory } from "../../core/factory.js"; import { logger } from "../../utils/logger.js"; import { @@ -11,6 +12,8 @@ import { SpanStatus, getMetricsAggregator, } from "../../observability/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import { MAX_REASONING_LENGTH } from "../config.js"; import type { EnsembleResponse, @@ -35,6 +38,25 @@ const functionTag = "JudgeScorer"; */ export async function scoreEnsemble( options: ScoreOptions, +): Promise { + return withSpan( + { + name: "neurolink.workflow.judge.score", + tracer: tracers.workflow, + attributes: { + "workflow.judges_count": options.judges.length, + "workflow.responses_count": options.responses.length, + "workflow.pattern": + options.judges.length > 1 ? "multi-judge" : "single-judge", + }, + }, + async (otelSpan) => scoreEnsembleInner(options, otelSpan), + ); +} + +async function scoreEnsembleInner( + options: ScoreOptions, + otelSpan: import("@opentelemetry/api").Span, ): Promise { const startTime = Date.now(); const { @@ -87,6 +109,7 @@ export async function scoreEnsemble( span.durationMs = judgeTime; const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); getMetricsAggregator().recordSpan(endedSpan); + otelSpan.setAttribute("workflow.judge_time_ms", judgeTime); return { scores: judgeResult, @@ -107,6 +130,8 @@ export async function scoreEnsemble( span.durationMs = judgeTime; const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); getMetricsAggregator().recordSpan(endedSpan); + otelSpan.setAttribute("workflow.judge_time_ms", judgeTime); + otelSpan.setAttribute("workflow.judges_completed", judges.length); return { scores: multiJudgeResult, @@ -127,6 +152,13 @@ export async function scoreEnsemble( ); getMetricsAggregator().recordSpan(endedSpan); + // Mark the outer OTel span as ERROR since we return instead of rethrowing + otelSpan.recordException(err); + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: err.message, + }); + const workflowError = error instanceof WorkflowError ? error diff --git a/src/lib/workflow/core/responseConditioner.ts b/src/lib/workflow/core/responseConditioner.ts index aec95b7d7..71b99e7f0 100644 --- a/src/lib/workflow/core/responseConditioner.ts +++ b/src/lib/workflow/core/responseConditioner.ts @@ -8,6 +8,8 @@ import { logger } from "../../utils/logger.js"; import { AIProviderFactory } from "../../core/factory.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import type { ConditioningConfig, ConditionOptions, @@ -30,6 +32,28 @@ const functionTag = "ResponseConditioner"; */ export async function conditionResponse( options: ConditionOptions, +): Promise { + return withSpan( + { + name: "neurolink.workflow.response.condition", + tracer: tracers.workflow, + attributes: { + "workflow.conditioning.enabled": Boolean(options.config?.useConfidence), + "workflow.conditioning.has_synthesis_model": Boolean( + options.config?.synthesisModel, + ), + "workflow.conditioning.original_length": options.content.length, + "workflow.conditioning.responses_count": + options.allResponses?.length ?? 0, + }, + }, + async (otelSpan) => conditionResponseInner(options, otelSpan), + ); +} + +async function conditionResponseInner( + options: ConditionOptions, + otelSpan: import("@opentelemetry/api").Span, ): Promise { const startTime = Date.now(); const { @@ -96,6 +120,13 @@ export async function conditionResponse( improvement: synthesizedContent.length - content.length, }); + otelSpan.setAttribute("workflow.conditioning.applied", true); + otelSpan.setAttribute("workflow.conditioning.synthesis", true); + otelSpan.setAttribute( + "workflow.conditioning.final_length", + synthesizedContent.length, + ); + return { content: synthesizedContent, conditioningTime, diff --git a/src/lib/workflow/core/workflowRunner.ts b/src/lib/workflow/core/workflowRunner.ts index 28d0adf98..6f1f82d8a 100644 --- a/src/lib/workflow/core/workflowRunner.ts +++ b/src/lib/workflow/core/workflowRunner.ts @@ -12,6 +12,7 @@ * @module workflow/core/workflowRunner */ +import { SpanStatusCode, type Span } from "@opentelemetry/api"; import { logger } from "../../utils/logger.js"; import { SpanSerializer, @@ -19,16 +20,19 @@ import { SpanStatus, getMetricsAggregator, } from "../../observability/index.js"; +import { withSpan } from "../../telemetry/withSpan.js"; +import { tracers } from "../../telemetry/tracers.js"; import type { EnsembleExecutionResult, - JudgeScoreResult, EnsembleResponse, ExecutionConfig, + JudgeScoreResult, JudgeScores, MultiJudgeScores, RunWorkflowOptions, WorkflowConfig, WorkflowResult, + WorkflowStreamChunk, } from "../../types/index.js"; import { getModelGroups, @@ -41,26 +45,6 @@ import { executeEnsemble, executeModelGroups } from "./ensembleExecutor.js"; import { scoreEnsemble } from "./judgeScorer.js"; import { conditionResponse } from "./responseConditioner.js"; -/** - * Progressive workflow response for streaming - */ -type WorkflowStreamChunk = { - /** - * Type of response chunk - */ - type: "preliminary" | "final"; - - /** - * Response content - */ - content: string; - - /** - * Partial workflow result (only ensemble data for preliminary) - */ - partialResult?: Partial; -}; - /** * Execute a complete workflow * @@ -91,222 +75,251 @@ export async function runWorkflow( config: WorkflowConfig, options: RunWorkflowOptions, ): Promise { - const startTime = Date.now(); - const span = SpanSerializer.createSpan(SpanType.WORKFLOW, "workflow.run", { - "workflow.operation": "run", - "workflow.name": config.name, - "workflow.type": config.type, - "workflow.id": config.id, - }); - - // Validate configuration - const validation = validateWorkflow(config); - if (!validation.valid) { - span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan( - span, - SpanStatus.ERROR, - `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`, - ); - getMetricsAggregator().recordSpan(endedSpan); - throw new Error( - `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`, - ); - } - - if (options.verbose) { - logger.debug(`[WorkflowRunner] Starting workflow: ${config.name}`); - logger.debug(`[WorkflowRunner] Type: ${config.type}`); - logger.debug( - `[WorkflowRunner] Uses layer-based execution: ${usesModelGroups(config)}`, - ); - } - - try { - // Step 1: Execute models (layer-based or flat) - const ensembleResult = await executeModels(config, options); - - if (options.verbose) { - logger.debug( - `[WorkflowRunner] Received ${ensembleResult.responses.length} model responses`, - ); - logger.debug( - `[WorkflowRunner] Successful: ${ensembleResult.successCount}`, + return withSpan( + { + name: "neurolink.workflow.run", + tracer: tracers.sdk, + attributes: { + "workflow.name": config.name, + "workflow.type": config.type, + "workflow.id": config.id ?? "unknown", + }, + }, + async (otelSpan) => { + const startTime = Date.now(); + const span = SpanSerializer.createSpan( + SpanType.WORKFLOW, + "workflow.run", + { + "workflow.operation": "run", + "workflow.name": config.name, + "workflow.type": config.type, + "workflow.id": config.id, + }, ); - } - - // Step 2: Score responses with judge(s) - const scoreResult = await scoreResponses( - config, - ensembleResult.responses, - options, - ); - - if (options.verbose) { - logger.debug(`[WorkflowRunner] Scoring complete`); - logger.debug(`[WorkflowRunner] Scores:`, scoreResult.scores); - } - // Step 3: Select best response - const bestResponse = selectBestResponse( - ensembleResult.responses, - scoreResult.scores, - ); + // Validate configuration + const validation = validateWorkflow(config); + if (!validation.valid) { + span.durationMs = Date.now() - startTime; + const endedSpan = SpanSerializer.endSpan( + span, + SpanStatus.ERROR, + `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`, + ); + getMetricsAggregator().recordSpan(endedSpan); + throw new Error( + `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`, + ); + } - if (options.verbose) { - logger.debug(`[WorkflowRunner] Best response: ${bestResponse.model}`); - const bestScore = extractScore( - scoreResult.scores, - bestResponse, - ensembleResult.responses, - ); - logger.debug(`[WorkflowRunner] Best score: ${bestScore}`); - } + if (options.verbose) { + logger.debug(`[WorkflowRunner] Starting workflow: ${config.name}`); + logger.debug(`[WorkflowRunner] Type: ${config.type}`); + logger.debug( + `[WorkflowRunner] Uses layer-based execution: ${usesModelGroups(config)}`, + ); + } - // CRITICAL: Store original content BEFORE any processing - const originalContent = bestResponse.content; + try { + // Step 1: Execute models (layer-based or flat) + const ensembleResult = await executeModels(config, options); - // Step 4: Get processed content - // Priority: Judge-synthesized > Separate conditioning > Original - let processedContent: string; - let conditioningTime = 0; - - const judgeScores = isJudgeScores(scoreResult.scores) - ? scoreResult.scores - : convertToJudgeScores(scoreResult.scores); + if (options.verbose) { + logger.debug( + `[WorkflowRunner] Received ${ensembleResult.responses.length} model responses`, + ); + logger.debug( + `[WorkflowRunner] Successful: ${ensembleResult.successCount}`, + ); + } - if (judgeScores.synthesizedResponse) { - // Judge already synthesized improved response - processedContent = judgeScores.synthesizedResponse; - logger.debug(`[WorkflowRunner] Using judge-synthesized response`); - } else if (config.conditioning) { - // Fall back to separate conditioning if configured - const conditionedContent = await conditionFinalResponse( - bestResponse, - scoreResult.scores, - config, - options, - ensembleResult.responses, - ); - processedContent = conditionedContent.content; - conditioningTime = conditionedContent.conditioningTime; - logger.debug(`[WorkflowRunner] Using separate conditioning`); - } else { - // No processing, use original - processedContent = originalContent; - logger.debug(`[WorkflowRunner] No conditioning applied`); - } + // Step 2: Score responses with judge(s) + const scoreResult = await scoreResponses( + config, + ensembleResult.responses, + options, + ); - // Step 5: Calculate execution metrics - const executionTime = Date.now() - startTime; - const ensembleTime = ensembleResult.totalTime; - const judgeTime = scoreResult.judgeTime; + if (options.verbose) { + logger.debug(`[WorkflowRunner] Scoring complete`); + logger.debug(`[WorkflowRunner] Scores:`, scoreResult.scores); + } - // Step 6: Assemble complete result - const result: WorkflowResult = { - // Primary output (processed version) - content: processedContent, + // Step 3: Select best response + const bestResponse = selectBestResponse( + ensembleResult.responses, + scoreResult.scores, + ); - // IMPORTANT: Store original unmodified response separately - originalContent: originalContent, + if (options.verbose) { + logger.debug(`[WorkflowRunner] Best response: ${bestResponse.model}`); + const bestScore = extractScore( + scoreResult.scores, + bestResponse, + ensembleResult.responses, + ); + logger.debug(`[WorkflowRunner] Best score: ${bestScore}`); + } - // Evaluation metrics (0-100 scale) - score: extractScore( - scoreResult.scores, - bestResponse, - ensembleResult.responses, - ), - reasoning: extractReasoning(scoreResult.scores), - - // Ensemble data - ensembleResponses: ensembleResult.responses, - - // Judge data - judgeScores: judgeScores, - selectedResponse: bestResponse, - - // Quality metrics - confidence: extractConfidence(scoreResult.scores), - consensus: extractConsensus(scoreResult.scores), - - // Performance metrics - totalTime: executionTime, - ensembleTime, - judgeTime, - conditioningTime: conditioningTime, - - // Workflow metadata - workflow: config.id, - workflowName: config.name, - workflowVersion: config.version, - - // Resource usage - usage: { - totalInputTokens: calculateInputTokens(ensembleResult.responses), - totalOutputTokens: calculateOutputTokens(ensembleResult.responses), - totalTokens: calculateTotalTokens(ensembleResult.responses), - byModel: [], // TODO: Populate per-model breakdown - }, + // CRITICAL: Store original content BEFORE any processing + const originalContent = bestResponse.content; + + // Step 4: Get processed content + // Priority: Judge-synthesized > Separate conditioning > Original + let processedContent: string; + let conditioningTime = 0; + + const judgeScores = isJudgeScores(scoreResult.scores) + ? scoreResult.scores + : convertToJudgeScores(scoreResult.scores); + + if (judgeScores.synthesizedResponse) { + // Judge already synthesized improved response + processedContent = judgeScores.synthesizedResponse; + logger.debug(`[WorkflowRunner] Using judge-synthesized response`); + } else if (config.conditioning) { + // Fall back to separate conditioning if configured + const conditionedContent = await conditionFinalResponse( + bestResponse, + scoreResult.scores, + config, + options, + ensembleResult.responses, + ); + processedContent = conditionedContent.content; + conditioningTime = conditionedContent.conditioningTime; + logger.debug(`[WorkflowRunner] Using separate conditioning`); + } else { + // No processing, use original + processedContent = originalContent; + logger.debug(`[WorkflowRunner] No conditioning applied`); + } - // Additional metadata - metadata: options.metadata, - timestamp: new Date().toISOString(), - }; + // Step 5: Calculate execution metrics + const executionTime = Date.now() - startTime; + const ensembleTime = ensembleResult.totalTime; + const judgeTime = scoreResult.judgeTime; + + // Step 6: Assemble complete result + const result: WorkflowResult = { + // Primary output (processed version) + content: processedContent, + + // IMPORTANT: Store original unmodified response separately + originalContent: originalContent, + + // Evaluation metrics (0-100 scale) + score: extractScore( + scoreResult.scores, + bestResponse, + ensembleResult.responses, + ), + reasoning: extractReasoning(scoreResult.scores), + + // Ensemble data + ensembleResponses: ensembleResult.responses, + + // Judge data + judgeScores: judgeScores, + selectedResponse: bestResponse, + + // Quality metrics + confidence: extractConfidence(scoreResult.scores), + consensus: extractConsensus(scoreResult.scores), + + // Performance metrics + totalTime: executionTime, + ensembleTime, + judgeTime, + conditioningTime: conditioningTime, + + // Workflow metadata + workflow: config.id, + workflowName: config.name, + workflowVersion: config.version, + + // Resource usage + usage: { + totalInputTokens: calculateInputTokens(ensembleResult.responses), + totalOutputTokens: calculateOutputTokens(ensembleResult.responses), + totalTokens: calculateTotalTokens(ensembleResult.responses), + byModel: [], // TODO: Populate per-model breakdown + }, - if (options.verbose) { - logger.debug(`[WorkflowRunner] Workflow complete in ${executionTime}ms`); - logger.debug( - `[WorkflowRunner] Total tokens: ${result.usage?.totalTokens || 0}`, - ); - } + // Additional metadata + metadata: options.metadata, + timestamp: new Date().toISOString(), + }; - span.durationMs = executionTime; - const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); - getMetricsAggregator().recordSpan(endedSpan); + if (options.verbose) { + logger.debug( + `[WorkflowRunner] Workflow complete in ${executionTime}ms`, + ); + logger.debug( + `[WorkflowRunner] Total tokens: ${result.usage?.totalTokens || 0}`, + ); + } - return result; - } catch (error) { - const executionTime = Date.now() - startTime; - const errorMessage = error instanceof Error ? error.message : String(error); + span.durationMs = executionTime; + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); + getMetricsAggregator().recordSpan(endedSpan); - if (options.verbose) { - logger.error(`[WorkflowRunner] Workflow failed:`, errorMessage); - } + return result; + } catch (error) { + const executionTime = Date.now() - startTime; + const errorMessage = + error instanceof Error ? error.message : String(error); - span.durationMs = executionTime; - const endedSpan = SpanSerializer.endSpan( - span, - SpanStatus.ERROR, - errorMessage, - ); - getMetricsAggregator().recordSpan(endedSpan); + if (options.verbose) { + logger.error(`[WorkflowRunner] Workflow failed:`, errorMessage); + } - // Return error result with dummy data - const dummyResponse: EnsembleResponse = { - provider: PLACEHOLDER_PROVIDER, - model: PLACEHOLDER_MODEL, - content: "", - responseTime: 0, - status: "failure", - error: errorMessage, - timestamp: new Date().toISOString(), - }; + span.durationMs = executionTime; + const endedSpan = SpanSerializer.endSpan( + span, + SpanStatus.ERROR, + errorMessage, + ); + getMetricsAggregator().recordSpan(endedSpan); + + // Mark outer OTel span as ERROR since we return instead of rethrowing + otelSpan.recordException( + error instanceof Error ? error : new Error(errorMessage), + ); + otelSpan.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage, + }); + + // Return error result with dummy data + const dummyResponse: EnsembleResponse = { + provider: PLACEHOLDER_PROVIDER, + model: PLACEHOLDER_MODEL, + content: "", + responseTime: 0, + status: "failure", + error: errorMessage, + timestamp: new Date().toISOString(), + }; - return { - content: "", - score: 0, - reasoning: `Workflow execution failed: ${errorMessage}`, - ensembleResponses: [dummyResponse], - confidence: 0, - totalTime: executionTime, - ensembleTime: 0, - workflow: config.id, - workflowName: config.name, - workflowVersion: config.version, - metadata: options.metadata, - timestamp: new Date().toISOString(), - }; - } + return { + content: "", + score: 0, + reasoning: `Workflow execution failed: ${errorMessage}`, + ensembleResponses: [dummyResponse], + confidence: 0, + totalTime: executionTime, + ensembleTime: 0, + workflow: config.id, + workflowName: config.name, + workflowVersion: config.version, + metadata: options.metadata, + timestamp: new Date().toISOString(), + }; + } + }, + ); // end withSpan } /** @@ -651,6 +664,27 @@ function calculateTotalTokens(responses: EnsembleResponse[]): number { export async function* runWorkflowWithStreaming( config: WorkflowConfig, options: RunWorkflowOptions, +): AsyncGenerator { + // Wrap the generator in an active OTel span so Pipeline A captures the + // streaming workflow end-to-end and Pipeline B (below) inherits its traceId. + const generator = tracers.workflow.startActiveSpan( + "neurolink.workflow.run.streaming", + { + attributes: { + "workflow.name": config.name, + "workflow.type": config.type, + "workflow.id": config.id ?? "unknown", + }, + }, + (otelSpan) => runWorkflowStreamingInner(config, options, otelSpan), + ); + yield* generator; +} + +async function* runWorkflowStreamingInner( + config: WorkflowConfig, + options: RunWorkflowOptions, + otelSpan: Span, ): AsyncGenerator { const startTime = Date.now(); const span = SpanSerializer.createSpan( @@ -667,16 +701,15 @@ export async function* runWorkflowWithStreaming( // Validate configuration const validation = validateWorkflow(config); if (!validation.valid) { + const errMsg = `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`; span.durationMs = Date.now() - startTime; - const endedSpan = SpanSerializer.endSpan( - span, - SpanStatus.ERROR, - `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`, - ); + const endedSpan = SpanSerializer.endSpan(span, SpanStatus.ERROR, errMsg); getMetricsAggregator().recordSpan(endedSpan); - throw new Error( - `Invalid workflow configuration: ${validation.errors.map((err) => err.message).join(", ")}`, - ); + const err = new Error(errMsg); + otelSpan.recordException(err); + otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: errMsg }); + otelSpan.end(); + throw err; } if (options.verbose) { @@ -685,6 +718,8 @@ export async function* runWorkflowWithStreaming( ); } + // eslint-disable-next-line no-useless-assignment -- read in finally block + let spanEnded = false; try { // Step 1: Execute models const ensembleResult = await executeModels(config, options); @@ -815,7 +850,11 @@ export async function* runWorkflowWithStreaming( span.durationMs = executionTime; const endedSpan = SpanSerializer.endSpan(span, SpanStatus.OK); getMetricsAggregator().recordSpan(endedSpan); + spanEnded = true; + otelSpan.setStatus({ code: SpanStatusCode.OK }); + otelSpan.end(); } catch (error) { + spanEnded = true; const executionTime = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : String(error); @@ -827,9 +866,22 @@ export async function* runWorkflowWithStreaming( ); getMetricsAggregator().recordSpan(endedSpan); + if (error instanceof Error) { + otelSpan.recordException(error); + } + otelSpan.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage }); + otelSpan.end(); + logger.error(`[WorkflowRunner] Streaming workflow failed`, { error: errorMessage, }); throw error; + } finally { + // Guard against span leak when the consumer breaks out of the async + // generator early (neither try-success nor catch fires in that case). + if (!spanEnded) { + otelSpan.setStatus({ code: SpanStatusCode.OK }); + otelSpan.end(); + } } }