toad-eye 1.1.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +132 -87
  2. package/dist/agent.d.ts +41 -0
  3. package/dist/agent.js +91 -0
  4. package/dist/alerts/channels.d.ts +2 -0
  5. package/dist/alerts/channels.js +81 -0
  6. package/dist/alerts/conditions.d.ts +29 -0
  7. package/dist/alerts/conditions.js +147 -0
  8. package/dist/alerts/grafana.d.ts +5 -0
  9. package/dist/alerts/grafana.js +19 -0
  10. package/dist/alerts/index.d.ts +5 -0
  11. package/dist/alerts/index.js +12 -0
  12. package/dist/alerts/manager.d.ts +13 -0
  13. package/dist/alerts/manager.js +87 -0
  14. package/dist/alerts/types.d.ts +46 -0
  15. package/dist/alerts/types.js +1 -0
  16. package/dist/budget/error.d.ts +10 -0
  17. package/dist/budget/error.js +22 -0
  18. package/dist/budget/index.d.ts +3 -0
  19. package/dist/budget/index.js +2 -0
  20. package/dist/budget/tracker.d.ts +35 -0
  21. package/dist/budget/tracker.js +121 -0
  22. package/dist/budget/types.d.ts +34 -0
  23. package/dist/budget/types.js +1 -0
  24. package/dist/cli.js +127 -18
  25. package/dist/core/metrics.d.ts +14 -0
  26. package/dist/core/metrics.js +119 -0
  27. package/dist/core/pricing.d.ts +24 -0
  28. package/dist/core/pricing.js +52 -0
  29. package/dist/{spans.d.ts → core/spans.d.ts} +3 -1
  30. package/dist/core/spans.js +143 -0
  31. package/dist/core/tracer.d.ts +9 -0
  32. package/dist/core/tracer.js +93 -0
  33. package/dist/drift/baseline.d.ts +16 -0
  34. package/dist/drift/baseline.js +16 -0
  35. package/dist/drift/cosine.d.ts +13 -0
  36. package/dist/drift/cosine.js +39 -0
  37. package/dist/drift/embedding-provider.d.ts +21 -0
  38. package/dist/drift/embedding-provider.js +8 -0
  39. package/dist/drift/index.d.ts +9 -0
  40. package/dist/drift/index.js +8 -0
  41. package/dist/drift/monitor.d.ts +29 -0
  42. package/dist/drift/monitor.js +47 -0
  43. package/dist/drift/openai-embeddings.d.ts +6 -0
  44. package/dist/drift/openai-embeddings.js +26 -0
  45. package/dist/export.d.ts +46 -0
  46. package/dist/export.js +108 -0
  47. package/dist/guard.d.ts +26 -0
  48. package/dist/guard.js +45 -0
  49. package/dist/index.d.ts +17 -5
  50. package/dist/index.js +10 -3
  51. package/dist/instrumentations/anthropic.d.ts +1 -0
  52. package/dist/instrumentations/anthropic.js +73 -0
  53. package/dist/instrumentations/create.d.ts +11 -0
  54. package/dist/instrumentations/create.js +165 -0
  55. package/dist/instrumentations/gemini.d.ts +1 -0
  56. package/dist/instrumentations/gemini.js +48 -0
  57. package/dist/instrumentations/openai.d.ts +1 -0
  58. package/dist/instrumentations/openai.js +94 -0
  59. package/dist/instrumentations/registry.d.ts +5 -0
  60. package/dist/instrumentations/registry.js +31 -0
  61. package/dist/instrumentations/types.d.ts +37 -0
  62. package/dist/instrumentations/types.js +1 -0
  63. package/dist/types/attributes.d.ts +48 -0
  64. package/dist/types/attributes.js +53 -0
  65. package/dist/types/config.d.ts +34 -0
  66. package/dist/types/config.js +1 -0
  67. package/dist/types/index.d.ts +17 -0
  68. package/dist/types/index.js +13 -0
  69. package/dist/types/metrics.d.ts +33 -0
  70. package/dist/types/metrics.js +35 -0
  71. package/dist/types/providers.d.ts +5 -0
  72. package/dist/types/providers.js +1 -0
  73. package/dist/types/spans.d.ts +40 -0
  74. package/dist/types/spans.js +1 -0
  75. package/package.json +38 -4
  76. package/templates/alerts.example.yml +54 -0
  77. package/templates/docker-compose.yml +1 -2
  78. package/templates/grafana/dashboards/cost-breakdown.json +208 -0
  79. package/templates/grafana/dashboards/error-drilldown.json +188 -0
  80. package/templates/grafana/dashboards/finops-attribution.json +278 -0
  81. package/templates/grafana/dashboards/latency-analysis.json +134 -0
  82. package/templates/grafana/dashboards/llm-overview.json +161 -30
  83. package/templates/grafana/dashboards/model-comparison.json +143 -0
  84. package/dist/metrics.d.ts +0 -6
  85. package/dist/metrics.js +0 -46
  86. package/dist/spans.js +0 -61
  87. package/dist/tracer.d.ts +0 -4
  88. package/dist/tracer.js +0 -46
  89. package/dist/types.d.ts +0 -69
  90. package/dist/types.js +0 -32
@@ -0,0 +1,119 @@
1
+ import { metrics } from "@opentelemetry/api";
2
+ import { GEN_AI_ATTRS, GEN_AI_METRICS, INSTRUMENTATION_NAME, } from "../types/index.js";
3
+ let requestDuration;
4
+ let requestCost;
5
+ let tokenUsage;
6
+ let requestsTotal;
7
+ let errorsTotal;
8
+ let agentStepsPerQuery;
9
+ let agentToolUsage;
10
+ let guardEvaluations;
11
+ let guardWouldBlock;
12
+ let semanticDrift;
13
+ let budgetExceeded;
14
+ let budgetBlocked;
15
+ let budgetDowngraded;
16
+ let initialized = false;
17
+ export function initMetrics() {
18
+ if (initialized)
19
+ return;
20
+ const meter = metrics.getMeter(INSTRUMENTATION_NAME);
21
+ requestDuration = meter.createHistogram(GEN_AI_METRICS.REQUEST_DURATION, {
22
+ description: "GenAI operation duration in milliseconds",
23
+ unit: "ms",
24
+ });
25
+ requestCost = meter.createHistogram(GEN_AI_METRICS.REQUEST_COST, {
26
+ description: "GenAI request cost in USD",
27
+ unit: "USD",
28
+ });
29
+ tokenUsage = meter.createCounter(GEN_AI_METRICS.TOKEN_USAGE, {
30
+ description: "Total tokens consumed",
31
+ });
32
+ requestsTotal = meter.createCounter(GEN_AI_METRICS.REQUESTS, {
33
+ description: "Total GenAI requests",
34
+ });
35
+ errorsTotal = meter.createCounter(GEN_AI_METRICS.ERRORS, {
36
+ description: "Total failed GenAI requests",
37
+ });
38
+ agentStepsPerQuery = meter.createHistogram(GEN_AI_METRICS.AGENT_STEPS_PER_QUERY, {
39
+ description: "Number of agent steps per query",
40
+ });
41
+ agentToolUsage = meter.createCounter(GEN_AI_METRICS.AGENT_TOOL_USAGE, {
42
+ description: "Agent tool invocations by tool name",
43
+ });
44
+ guardEvaluations = meter.createCounter(GEN_AI_METRICS.GUARD_EVALUATIONS, {
45
+ description: "Total guard evaluations (shadow + enforce)",
46
+ });
47
+ guardWouldBlock = meter.createCounter(GEN_AI_METRICS.GUARD_WOULD_BLOCK, {
48
+ description: "Guard evaluations that would have blocked the response",
49
+ });
50
+ semanticDrift = meter.createHistogram(GEN_AI_METRICS.SEMANTIC_DRIFT, {
51
+ description: "Semantic drift from baseline (0 = identical, 1 = completely different)",
52
+ });
53
+ budgetExceeded = meter.createCounter(GEN_AI_METRICS.BUDGET_EXCEEDED, {
54
+ description: "Number of times a budget limit was exceeded",
55
+ });
56
+ budgetBlocked = meter.createCounter(GEN_AI_METRICS.BUDGET_BLOCKED, {
57
+ description: "Number of LLM calls blocked due to budget limits",
58
+ });
59
+ budgetDowngraded = meter.createCounter(GEN_AI_METRICS.BUDGET_DOWNGRADED, {
60
+ description: "Number of LLM calls downgraded due to budget limits",
61
+ });
62
+ initialized = true;
63
+ }
64
+ /** Build metric labels: provider + model + optional FinOps attributes. */
65
+ function baseLabels(provider, model, attrs) {
66
+ return {
67
+ [GEN_AI_ATTRS.PROVIDER]: provider,
68
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
69
+ ...attrs,
70
+ };
71
+ }
72
+ export function recordRequestDuration(ms, provider, model, attrs) {
73
+ requestDuration.record(ms, baseLabels(provider, model, attrs));
74
+ }
75
+ export function recordRequestCost(usd, provider, model, attrs) {
76
+ requestCost.record(usd, baseLabels(provider, model, attrs));
77
+ }
78
+ export function recordTokens(count, provider, model, attrs) {
79
+ tokenUsage.add(count, baseLabels(provider, model, attrs));
80
+ }
81
+ export function recordRequest(provider, model, attrs) {
82
+ requestsTotal.add(1, baseLabels(provider, model, attrs));
83
+ }
84
+ export function recordError(provider, model, attrs) {
85
+ errorsTotal.add(1, baseLabels(provider, model, attrs));
86
+ }
87
+ export function recordAgentSteps(stepCount) {
88
+ agentStepsPerQuery.record(stepCount);
89
+ }
90
+ export function recordAgentToolUsage(toolName) {
91
+ agentToolUsage.add(1, {
92
+ [GEN_AI_ATTRS.AGENT_TOOL_NAME]: toolName,
93
+ });
94
+ }
95
+ export function recordGuardEvaluation(ruleName) {
96
+ guardEvaluations.add(1, {
97
+ [GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
98
+ });
99
+ }
100
+ export function recordGuardWouldBlock(ruleName) {
101
+ guardWouldBlock.add(1, {
102
+ [GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
103
+ });
104
+ }
105
+ export function recordSemanticDrift(drift, provider, model) {
106
+ semanticDrift.record(drift, {
107
+ [GEN_AI_ATTRS.PROVIDER]: provider,
108
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
109
+ });
110
+ }
111
+ export function recordBudgetExceeded(budgetType) {
112
+ budgetExceeded.add(1, { budget_type: budgetType });
113
+ }
114
+ export function recordBudgetBlocked(budgetType) {
115
+ budgetBlocked.add(1, { budget_type: budgetType });
116
+ }
117
+ export function recordBudgetDowngraded(budgetType) {
118
+ budgetDowngraded.add(1, { budget_type: budgetType });
119
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Built-in pricing table for major LLM models.
3
+ * Prices in USD per 1M tokens (input / output).
4
+ * Updated: March 2026.
5
+ */
6
+ export interface ModelPricing {
7
+ readonly inputPer1M: number;
8
+ readonly outputPer1M: number;
9
+ }
10
+ /**
11
+ * Set custom pricing for models (overrides built-in prices).
12
+ * Useful for enterprise contracts or custom/fine-tuned models.
13
+ */
14
+ export declare function setCustomPricing(pricing: Record<string, ModelPricing>): void;
15
+ /**
16
+ * Get pricing for a model. Custom pricing takes precedence.
17
+ * Returns undefined if model is not in any pricing table.
18
+ */
19
+ export declare function getModelPricing(model: string): ModelPricing | undefined;
20
+ /**
21
+ * Calculate cost for a given model and token counts.
22
+ * Returns 0 if model pricing is not found.
23
+ */
24
+ export declare function calculateCost(model: string, inputTokens: number, outputTokens: number): number;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Built-in pricing table for major LLM models.
3
+ * Prices in USD per 1M tokens (input / output).
4
+ * Updated: March 2026.
5
+ */
6
+ const BUILT_IN_PRICING = {
7
+ // OpenAI
8
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10 },
9
+ "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6 },
10
+ "gpt-4-turbo": { inputPer1M: 10, outputPer1M: 30 },
11
+ "gpt-4.1": { inputPer1M: 2, outputPer1M: 8 },
12
+ "gpt-4.1-mini": { inputPer1M: 0.4, outputPer1M: 1.6 },
13
+ "gpt-4.1-nano": { inputPer1M: 0.1, outputPer1M: 0.4 },
14
+ o3: { inputPer1M: 10, outputPer1M: 40 },
15
+ "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
16
+ "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4 },
17
+ // Anthropic
18
+ "claude-opus-4-20250514": { inputPer1M: 15, outputPer1M: 75 },
19
+ "claude-sonnet-4-20250514": { inputPer1M: 3, outputPer1M: 15 },
20
+ "claude-haiku-3-5-20241022": { inputPer1M: 0.8, outputPer1M: 4 },
21
+ // Google Gemini
22
+ "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10 },
23
+ "gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.6 },
24
+ "gemini-2.0-flash": { inputPer1M: 0.1, outputPer1M: 0.4 },
25
+ };
26
+ let customPricing = {};
27
+ /**
28
+ * Set custom pricing for models (overrides built-in prices).
29
+ * Useful for enterprise contracts or custom/fine-tuned models.
30
+ */
31
+ export function setCustomPricing(pricing) {
32
+ customPricing = { ...pricing };
33
+ }
34
+ /**
35
+ * Get pricing for a model. Custom pricing takes precedence.
36
+ * Returns undefined if model is not in any pricing table.
37
+ */
38
+ export function getModelPricing(model) {
39
+ return customPricing[model] ?? BUILT_IN_PRICING[model];
40
+ }
41
+ /**
42
+ * Calculate cost for a given model and token counts.
43
+ * Returns 0 if model pricing is not found.
44
+ */
45
+ export function calculateCost(model, inputTokens, outputTokens) {
46
+ const pricing = getModelPricing(model);
47
+ if (!pricing)
48
+ return 0;
49
+ const cost = (inputTokens / 1_000_000) * pricing.inputPer1M +
50
+ (outputTokens / 1_000_000) * pricing.outputPer1M;
51
+ return Math.round(cost * 1_000_000) / 1_000_000;
52
+ }
@@ -1,10 +1,12 @@
1
- import type { LLMSpanAttributes } from "./types.js";
1
+ import type { LLMSpanAttributes } from "../types/index.js";
2
2
  /** Input for traceLLMCall — what the user knows before calling the LLM */
3
3
  export interface LLMCallInput {
4
4
  readonly provider: LLMSpanAttributes["provider"];
5
5
  readonly model: string;
6
6
  readonly prompt: string;
7
7
  readonly temperature?: number | undefined;
8
+ /** Per-request FinOps attributes (team, userId, feature, etc). Merged with global attributes. */
9
+ readonly attributes?: Readonly<Record<string, string>> | undefined;
8
10
  }
9
11
  /** Output from the LLM call — what comes back */
10
12
  export interface LLMCallOutput {
@@ -0,0 +1,143 @@
1
+ import { createHash } from "node:crypto";
2
+ import { trace, SpanStatusCode } from "@opentelemetry/api";
3
+ import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
4
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, } from "./metrics.js";
5
+ import { getConfig, getBudgetTracker } from "./tracer.js";
6
+ import { GEN_AI_ATTRS as ATTRS } from "../types/index.js";
7
+ const tracer = trace.getTracer(INSTRUMENTATION_NAME);
8
+ function sha256(text) {
9
+ const salt = getConfig()?.salt ?? "";
10
+ return createHash("sha256")
11
+ .update(salt + text)
12
+ .digest("hex");
13
+ }
14
+ function processContent(text) {
15
+ const config = getConfig();
16
+ if (config?.recordContent === false)
17
+ return undefined;
18
+ let processed = text;
19
+ if (config?.redactPatterns?.length) {
20
+ for (const pattern of config.redactPatterns) {
21
+ processed = processed.replace(pattern, "[REDACTED]");
22
+ }
23
+ }
24
+ if (config?.hashContent) {
25
+ return `sha256:${sha256(processed)}`;
26
+ }
27
+ return processed;
28
+ }
29
+ function resolveSessionId() {
30
+ const config = getConfig();
31
+ return config?.sessionExtractor?.() ?? config?.sessionId;
32
+ }
33
+ /** Merge global config attributes with per-request attributes (per-request wins). */
34
+ function resolveAttributes(input) {
35
+ const global = getConfig()?.attributes;
36
+ const local = input.attributes;
37
+ if (!global && !local)
38
+ return undefined;
39
+ return { ...global, ...local };
40
+ }
41
+ function setBaseAttributes(span, input) {
42
+ const prompt = processContent(input.prompt);
43
+ const sessionId = resolveSessionId();
44
+ const attrs = resolveAttributes(input);
45
+ span.setAttributes({
46
+ [GEN_AI_ATTRS.PROVIDER]: input.provider,
47
+ [GEN_AI_ATTRS.REQUEST_MODEL]: input.model,
48
+ [GEN_AI_ATTRS.TEMPERATURE]: input.temperature ?? 1.0,
49
+ [GEN_AI_ATTRS.OPERATION]: "chat",
50
+ ...(prompt !== undefined && { [GEN_AI_ATTRS.PROMPT]: prompt }),
51
+ ...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
52
+ ...attrs,
53
+ });
54
+ }
55
+ function recordBaseMetrics(duration, provider, model, attrs) {
56
+ recordRequest(provider, model, attrs);
57
+ recordRequestDuration(duration, provider, model, attrs);
58
+ }
59
+ function setSuccessAttributes(span, input, output) {
60
+ const completion = processContent(output.completion);
61
+ span.setAttributes({
62
+ ...(completion !== undefined && { [GEN_AI_ATTRS.COMPLETION]: completion }),
63
+ [GEN_AI_ATTRS.RESPONSE_MODEL]: input.model,
64
+ [GEN_AI_ATTRS.INPUT_TOKENS]: output.inputTokens,
65
+ [GEN_AI_ATTRS.OUTPUT_TOKENS]: output.outputTokens,
66
+ [GEN_AI_ATTRS.COST]: output.cost,
67
+ [GEN_AI_ATTRS.STATUS]: "success",
68
+ [GEN_AI_ATTRS.FINISH_REASONS]: ["stop"],
69
+ });
70
+ span.setStatus({ code: SpanStatusCode.OK });
71
+ }
72
+ function setErrorAttributes(span, message) {
73
+ const sanitized = processContent(message);
74
+ span.setAttributes({
75
+ [GEN_AI_ATTRS.INPUT_TOKENS]: 0,
76
+ [GEN_AI_ATTRS.OUTPUT_TOKENS]: 0,
77
+ [GEN_AI_ATTRS.COST]: 0,
78
+ [GEN_AI_ATTRS.STATUS]: "error",
79
+ ...(sanitized !== undefined && { [GEN_AI_ATTRS.ERROR]: sanitized }),
80
+ });
81
+ span.setStatus({
82
+ code: SpanStatusCode.ERROR,
83
+ ...(sanitized !== undefined && { message: sanitized }),
84
+ });
85
+ }
86
+ export async function traceLLMCall(input, fn) {
87
+ const budget = getBudgetTracker();
88
+ let effectiveInput = input;
89
+ // Budget check BEFORE the LLM call
90
+ if (budget) {
91
+ const userId = input.attributes?.[ATTRS.USER_ID];
92
+ const override = budget.checkBefore(input.provider, input.model, userId);
93
+ if (override) {
94
+ // Downgrade mode — use modified provider/model
95
+ effectiveInput = {
96
+ ...input,
97
+ provider: override.provider,
98
+ model: override.model,
99
+ };
100
+ recordBudgetDowngraded("daily");
101
+ }
102
+ }
103
+ return tracer.startActiveSpan(`gen_ai.${effectiveInput.provider}.${effectiveInput.model}`, async (span) => {
104
+ const start = performance.now();
105
+ setBaseAttributes(span, effectiveInput);
106
+ try {
107
+ const output = await fn();
108
+ const duration = performance.now() - start;
109
+ const attrs = resolveAttributes(effectiveInput);
110
+ setSuccessAttributes(span, effectiveInput, output);
111
+ recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
112
+ recordRequestCost(output.cost, effectiveInput.provider, effectiveInput.model, attrs);
113
+ recordTokens(output.inputTokens + output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
114
+ // Budget recording AFTER the call
115
+ if (budget) {
116
+ const userId = effectiveInput.attributes?.[ATTRS.USER_ID];
117
+ const exceeded = budget.recordCost(output.cost, effectiveInput.model, userId);
118
+ if (exceeded) {
119
+ recordBudgetExceeded(exceeded.budget);
120
+ console.warn(`toad-eye: ${exceeded.budget} budget exceeded — limit $${exceeded.limit}, current $${exceeded.current.toFixed(2)}`);
121
+ }
122
+ }
123
+ return output;
124
+ }
125
+ catch (error) {
126
+ const duration = performance.now() - start;
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ const attrs = resolveAttributes(effectiveInput);
129
+ setErrorAttributes(span, message);
130
+ recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
131
+ recordError(effectiveInput.provider, effectiveInput.model, attrs);
132
+ // If this was a budget block, record the metric
133
+ if (error instanceof Error &&
134
+ error.name === "ToadBudgetExceededError") {
135
+ recordBudgetBlocked("daily");
136
+ }
137
+ throw error;
138
+ }
139
+ finally {
140
+ span.end();
141
+ }
142
+ });
143
+ }
@@ -0,0 +1,9 @@
1
+ import type { ToadEyeConfig } from "../types/index.js";
2
+ import { BudgetTracker } from "../budget/index.js";
3
+ import "../instrumentations/openai.js";
4
+ import "../instrumentations/anthropic.js";
5
+ import "../instrumentations/gemini.js";
6
+ export declare function getConfig(): ToadEyeConfig | null;
7
+ export declare function getBudgetTracker(): BudgetTracker | null;
8
+ export declare function initObservability(config: ToadEyeConfig): void;
9
+ export declare function shutdown(): Promise<void>;
@@ -0,0 +1,93 @@
1
+ import { NodeSDK } from "@opentelemetry/sdk-node";
2
+ import { resourceFromAttributes } from "@opentelemetry/resources";
3
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
4
+ import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
5
+ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
6
+ import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
7
+ import { initMetrics } from "./metrics.js";
8
+ import { enableAll, disableAll } from "../instrumentations/registry.js";
9
+ import { BudgetTracker } from "../budget/index.js";
10
+ // Side-effect imports: register provider instrumentations
11
+ import "../instrumentations/openai.js";
12
+ import "../instrumentations/anthropic.js";
13
+ import "../instrumentations/gemini.js";
14
+ const DEFAULT_ENDPOINT = "http://localhost:4318";
15
+ const DEFAULT_CLOUD_ENDPOINT = "https://cloud.toad-eye.dev";
16
+ let sdk = null;
17
+ let currentConfig = null;
18
+ let budgetTracker = null;
19
+ export function getConfig() {
20
+ return currentConfig;
21
+ }
22
+ export function getBudgetTracker() {
23
+ return budgetTracker;
24
+ }
25
+ function validateConfig(config) {
26
+ if (!config.serviceName?.trim()) {
27
+ throw new Error("toad-eye: serviceName is required and cannot be empty");
28
+ }
29
+ if (config.endpoint !== undefined && !config.endpoint.startsWith("http")) {
30
+ throw new Error(`toad-eye: endpoint must be a valid URL, got "${config.endpoint}"`);
31
+ }
32
+ if (config.apiKey !== undefined && !config.apiKey.startsWith("toad_")) {
33
+ throw new Error('toad-eye: apiKey must start with "toad_" prefix');
34
+ }
35
+ }
36
+ /** Resolve endpoint and auth headers based on config mode (self-hosted vs cloud). */
37
+ function resolveTransport(config) {
38
+ const isCloudMode = config.apiKey !== undefined;
39
+ const endpoint = isCloudMode
40
+ ? (config.cloudEndpoint ?? DEFAULT_CLOUD_ENDPOINT)
41
+ : (config.endpoint ?? DEFAULT_ENDPOINT);
42
+ const headers = isCloudMode
43
+ ? { Authorization: `Bearer ${config.apiKey}` }
44
+ : {};
45
+ return { endpoint, headers, isCloudMode };
46
+ }
47
+ export function initObservability(config) {
48
+ if (sdk)
49
+ return;
50
+ validateConfig(config);
51
+ const { endpoint, headers, isCloudMode } = resolveTransport(config);
52
+ const resource = resourceFromAttributes({
53
+ [ATTR_SERVICE_NAME]: config.serviceName,
54
+ });
55
+ const traceExporter = new OTLPTraceExporter({
56
+ url: `${endpoint}/v1/traces`,
57
+ headers,
58
+ });
59
+ const metricExporter = new OTLPMetricExporter({
60
+ url: `${endpoint}/v1/metrics`,
61
+ headers,
62
+ });
63
+ const metricReader = new PeriodicExportingMetricReader({
64
+ exporter: metricExporter,
65
+ exportIntervalMillis: isCloudMode ? 10_000 : 5_000,
66
+ });
67
+ sdk = new NodeSDK({
68
+ resource,
69
+ traceExporter,
70
+ metricReader,
71
+ });
72
+ sdk.start();
73
+ currentConfig = config;
74
+ initMetrics();
75
+ if (isCloudMode) {
76
+ console.log(`toad-eye: cloud mode enabled → sending telemetry to ${endpoint}`);
77
+ }
78
+ if (config.budgets) {
79
+ budgetTracker = new BudgetTracker(config.budgets, config.onBudgetExceeded ?? "warn", config.downgradeCallback);
80
+ }
81
+ if (config.instrument?.length) {
82
+ enableAll(config.instrument);
83
+ }
84
+ }
85
+ export async function shutdown() {
86
+ if (!sdk)
87
+ return;
88
+ disableAll();
89
+ await sdk.shutdown();
90
+ sdk = null;
91
+ currentConfig = null;
92
+ budgetTracker = null;
93
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Baseline storage — save and load embedding baselines as JSON files.
3
+ *
4
+ * Baseline files contain embeddings of "known good" responses.
5
+ * Drift monitoring compares current responses against these baselines.
6
+ */
7
+ export interface DriftBaseline {
8
+ readonly model: string;
9
+ readonly provider: string;
10
+ readonly embeddingModel: string;
11
+ readonly embeddings: readonly (readonly number[])[];
12
+ readonly sampleCount: number;
13
+ readonly createdAt: string;
14
+ }
15
+ export declare function saveBaseline(filepath: string, baseline: DriftBaseline): void;
16
+ export declare function loadBaseline(filepath: string): DriftBaseline | undefined;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Baseline storage — save and load embedding baselines as JSON files.
3
+ *
4
+ * Baseline files contain embeddings of "known good" responses.
5
+ * Drift monitoring compares current responses against these baselines.
6
+ */
7
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
8
+ export function saveBaseline(filepath, baseline) {
9
+ writeFileSync(filepath, JSON.stringify(baseline, null, 2), "utf-8");
10
+ }
11
+ export function loadBaseline(filepath) {
12
+ if (!existsSync(filepath))
13
+ return undefined;
14
+ const raw = readFileSync(filepath, "utf-8");
15
+ return JSON.parse(raw);
16
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Cosine similarity and drift calculation for embedding vectors.
3
+ *
4
+ * drift = 1 - cosine_similarity(current, baseline)
5
+ * - drift = 0 → identical to baseline
6
+ * - drift > 0.3 → significant deviation (default alert threshold)
7
+ */
8
+ export declare function cosineSimilarity(a: readonly number[], b: readonly number[]): number;
9
+ /**
10
+ * Calculate drift as distance from the nearest baseline embedding.
11
+ * Returns the minimum drift value (closest match).
12
+ */
13
+ export declare function cosineDrift(current: readonly number[], baselines: readonly (readonly number[])[]): number;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cosine similarity and drift calculation for embedding vectors.
3
+ *
4
+ * drift = 1 - cosine_similarity(current, baseline)
5
+ * - drift = 0 → identical to baseline
6
+ * - drift > 0.3 → significant deviation (default alert threshold)
7
+ */
8
+ export function cosineSimilarity(a, b) {
9
+ let dot = 0;
10
+ let normA = 0;
11
+ let normB = 0;
12
+ for (let i = 0; i < a.length; i++) {
13
+ const ai = a[i];
14
+ const bi = b[i];
15
+ dot += ai * bi;
16
+ normA += ai * ai;
17
+ normB += bi * bi;
18
+ }
19
+ const denominator = Math.sqrt(normA) * Math.sqrt(normB);
20
+ if (denominator === 0)
21
+ return 0;
22
+ return dot / denominator;
23
+ }
24
+ /**
25
+ * Calculate drift as distance from the nearest baseline embedding.
26
+ * Returns the minimum drift value (closest match).
27
+ */
28
+ export function cosineDrift(current, baselines) {
29
+ if (baselines.length === 0)
30
+ return 1;
31
+ let maxSimilarity = -1;
32
+ for (const baseline of baselines) {
33
+ const similarity = cosineSimilarity(current, baseline);
34
+ if (similarity > maxSimilarity) {
35
+ maxSimilarity = similarity;
36
+ }
37
+ }
38
+ return 1 - maxSimilarity;
39
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Embedding provider abstraction for semantic drift monitoring.
3
+ *
4
+ * Implementations convert text into float vectors for cosine similarity comparison.
5
+ * Default: OpenAI text-embedding-3-small (cheap, fast).
6
+ * Users can provide a custom implementation via the EmbeddingProvider interface.
7
+ */
8
+ export interface EmbeddingProvider {
9
+ readonly name: string;
10
+ embed(text: string): Promise<readonly number[]>;
11
+ }
12
+ export interface OpenAIEmbeddingConfig {
13
+ readonly provider: "openai";
14
+ readonly apiKey: string;
15
+ readonly model?: string | undefined;
16
+ }
17
+ export interface CustomEmbeddingConfig {
18
+ readonly provider: "custom";
19
+ readonly embed: (text: string) => Promise<readonly number[]>;
20
+ }
21
+ export type EmbeddingConfig = OpenAIEmbeddingConfig | CustomEmbeddingConfig;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Embedding provider abstraction for semantic drift monitoring.
3
+ *
4
+ * Implementations convert text into float vectors for cosine similarity comparison.
5
+ * Default: OpenAI text-embedding-3-small (cheap, fast).
6
+ * Users can provide a custom implementation via the EmbeddingProvider interface.
7
+ */
8
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Semantic Drift Monitoring — detect silent LLM quality degradation
3
+ * by comparing current responses to a saved baseline via embeddings.
4
+ */
5
+ export type { EmbeddingProvider, EmbeddingConfig, OpenAIEmbeddingConfig, CustomEmbeddingConfig, } from "./embedding-provider.js";
6
+ export { createOpenAIEmbeddingProvider } from "./openai-embeddings.js";
7
+ export { cosineSimilarity, cosineDrift } from "./cosine.js";
8
+ export { saveBaseline, loadBaseline, type DriftBaseline } from "./baseline.js";
9
+ export { createDriftMonitor, type DriftMonitor, type DriftMonitorConfig, } from "./monitor.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Semantic Drift Monitoring — detect silent LLM quality degradation
3
+ * by comparing current responses to a saved baseline via embeddings.
4
+ */
5
+ export { createOpenAIEmbeddingProvider } from "./openai-embeddings.js";
6
+ export { cosineSimilarity, cosineDrift } from "./cosine.js";
7
+ export { saveBaseline, loadBaseline } from "./baseline.js";
8
+ export { createDriftMonitor, } from "./monitor.js";
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Drift monitor — runtime semantic drift detection with sampling.
3
+ *
4
+ * Not every response is checked (embedding API calls cost money).
5
+ * By default, every 10th response is sampled. The drift value is
6
+ * recorded as a Prometheus histogram metric with provider/model labels.
7
+ */
8
+ import type { EmbeddingConfig } from "./embedding-provider.js";
9
+ export interface DriftMonitorConfig {
10
+ readonly embedding: EmbeddingConfig;
11
+ readonly baselinePath: string;
12
+ readonly sampleRate?: number | undefined;
13
+ }
14
+ export interface DriftMonitor {
15
+ /**
16
+ * Check a response for semantic drift. Respects sampling rate —
17
+ * only every Nth call actually computes embeddings.
18
+ * Returns the drift value if checked, undefined if skipped.
19
+ */
20
+ check(response: string, provider: string, model: string): Promise<number | undefined>;
21
+ /**
22
+ * Fire-and-forget drift check — does NOT block the caller.
23
+ * Errors are logged to console.warn, never thrown.
24
+ * Use this in hot paths (e.g. inside traceLLMCall) to avoid
25
+ * blocking the LLM response delivery.
26
+ */
27
+ checkInBackground(response: string, provider: string, model: string): void;
28
+ }
29
+ export declare function createDriftMonitor(config: DriftMonitorConfig): DriftMonitor;