toad-eye 2.4.5 → 2.6.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 (50) hide show
  1. package/README.md +34 -1
  2. package/dist/agent.js +6 -2
  3. package/dist/cli.js +8 -1
  4. package/dist/context/error.d.ts +15 -0
  5. package/dist/context/error.js +17 -0
  6. package/dist/context/guard.d.ts +8 -0
  7. package/dist/context/guard.js +41 -0
  8. package/dist/context/index.d.ts +1 -0
  9. package/dist/context/index.js +4 -0
  10. package/dist/core/metrics.d.ts +4 -1
  11. package/dist/core/metrics.js +74 -2
  12. package/dist/core/pricing.d.ts +2 -0
  13. package/dist/core/pricing.js +61 -16
  14. package/dist/core/spans.d.ts +2 -0
  15. package/dist/core/spans.js +48 -9
  16. package/dist/core/tracer.js +7 -1
  17. package/dist/instrumentations/anthropic.js +8 -2
  18. package/dist/instrumentations/create.js +67 -8
  19. package/dist/instrumentations/gemini.js +7 -0
  20. package/dist/instrumentations/openai.js +5 -0
  21. package/dist/instrumentations/types.d.ts +8 -0
  22. package/dist/mcp/context.d.ts +12 -0
  23. package/dist/mcp/context.js +22 -0
  24. package/dist/mcp/index.d.ts +3 -0
  25. package/dist/mcp/index.js +1 -0
  26. package/dist/mcp/metrics.d.ts +11 -0
  27. package/dist/mcp/metrics.js +58 -0
  28. package/dist/mcp/middleware.d.ts +48 -0
  29. package/dist/mcp/middleware.js +253 -0
  30. package/dist/mcp/spans.d.ts +28 -0
  31. package/dist/mcp/spans.js +67 -0
  32. package/dist/mcp/types.d.ts +34 -0
  33. package/dist/mcp/types.js +1 -0
  34. package/dist/types/attributes.d.ts +1 -0
  35. package/dist/types/attributes.js +1 -0
  36. package/dist/types/config.d.ts +9 -0
  37. package/dist/types/metrics.d.ts +8 -0
  38. package/dist/types/metrics.js +10 -0
  39. package/dist/types/spans.d.ts +4 -0
  40. package/package.json +11 -2
  41. package/templates/docker-compose.yml +4 -4
  42. package/templates/grafana/dashboards/agent-workflow.json +154 -31
  43. package/templates/grafana/dashboards/cost-breakdown.json +168 -40
  44. package/templates/grafana/dashboards/error-drilldown.json +155 -37
  45. package/templates/grafana/dashboards/finops-attribution.json +192 -43
  46. package/templates/grafana/dashboards/latency-analysis.json +103 -24
  47. package/templates/grafana/dashboards/llm-overview.json +268 -66
  48. package/templates/grafana/dashboards/mcp-server.json +519 -0
  49. package/templates/grafana/dashboards/model-comparison.json +119 -26
  50. package/templates/grafana/dashboards/provider-health.json +137 -31
package/README.md CHANGED
@@ -124,6 +124,39 @@ const result = await traceLLMCall(
124
124
 
125
125
  </details>
126
126
 
127
+ <details>
128
+ <summary>MCP Server observability</summary>
129
+
130
+ Drop-in middleware for MCP servers — one line for full tracing of every tool call, resource read, and prompt request.
131
+
132
+ ```typescript
133
+ import { McpServer } from "@modelcontextprotocol/sdk/server/index.js";
134
+ import { toadEyeMiddleware } from "toad-eye/mcp";
135
+
136
+ const server = new McpServer({ name: "my-server", version: "1.0.0" });
137
+ toadEyeMiddleware(server);
138
+
139
+ // All handlers are now instrumented — spans appear in Jaeger
140
+ server.tool("calculate", { expression: z.string() }, async ({ expression }) => {
141
+ return { content: [{ type: "text", text: String(eval(expression)) }] };
142
+ });
143
+ ```
144
+
145
+ Spans follow OTel GenAI semconv: `execute_tool calculate`, `retrieval file:///docs`, `prompt greeting`.
146
+
147
+ Privacy by default — tool arguments and results are NOT recorded unless you opt in:
148
+
149
+ ```typescript
150
+ toadEyeMiddleware(server, {
151
+ recordInputs: true,
152
+ redactKeys: ["apiKey", "token"],
153
+ });
154
+ ```
155
+
156
+ > Safe for stdio transport — OTel diagnostics are redirected to stderr.
157
+
158
+ </details>
159
+
127
160
  ## Budget guards
128
161
 
129
162
  Prevent cost overruns at runtime. Three modes: warn, block, or auto-downgrade to a cheaper model.
@@ -385,7 +418,7 @@ import { ToadEyeAISpanProcessor, withToadEye } from "toad-eye/vercel"; // Vercel
385
418
 
386
419
  ## Architecture
387
420
 
388
- ![toad-eye architecture](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/toad_eye_architecture.svg)
421
+ ![toad-eye architecture](https://raw.githubusercontent.com/vola-trebla/toad-eye/main/demo/toad_eye_architecture_v2.svg)
389
422
 
390
423
  ## What it tracks
391
424
 
package/dist/agent.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { trace, SpanStatusCode } from "@opentelemetry/api";
2
2
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "./types/index.js";
3
- import { recordAgentSteps, recordAgentToolUsage } from "./core/metrics.js";
3
+ import { recordAgentSteps, recordAgentToolUsage, recordAgentToolDuration, } from "./core/metrics.js";
4
4
  import { getConfig, shouldEmitDeprecatedAttrs } from "./core/tracer.js";
5
5
  const tracer = trace.getTracer(INSTRUMENTATION_NAME);
6
6
  const DEFAULT_MAX_STEPS = 25;
@@ -58,7 +58,11 @@ function traceAgentStep(input) {
58
58
  }),
59
59
  });
60
60
  if (input.type === "act" && input.toolName !== undefined) {
61
- recordAgentToolUsage(input.toolName);
61
+ recordAgentToolUsage(input.toolName, input.toolStatus ?? "success");
62
+ if (input.toolDurationMs !== undefined) {
63
+ recordAgentToolDuration(input.toolName, input.toolDurationMs);
64
+ span.setAttribute("gen_ai.tool.duration_ms", input.toolDurationMs);
65
+ }
62
66
  }
63
67
  span.setStatus({ code: SpanStatusCode.OK });
64
68
  span.end();
package/dist/cli.js CHANGED
@@ -207,7 +207,14 @@ async function exportTraceCommand() {
207
207
  i++;
208
208
  }
209
209
  else if (args[i] === "--jaeger-url" && args[i + 1]) {
210
- jaegerUrl = args[i + 1];
210
+ try {
211
+ new URL(args[i + 1]);
212
+ jaegerUrl = args[i + 1];
213
+ }
214
+ catch {
215
+ console.error(`❌ Invalid --jaeger-url: ${args[i + 1]}`);
216
+ process.exit(1);
217
+ }
211
218
  i++;
212
219
  }
213
220
  }
@@ -0,0 +1,15 @@
1
+ /** Thrown when context guard blockAt threshold is exceeded. */
2
+ export declare class ToadContextExceededError extends Error {
3
+ readonly utilization: number;
4
+ readonly threshold: number;
5
+ readonly model: string;
6
+ readonly maxContextTokens: number;
7
+ readonly inputTokens: number;
8
+ constructor(info: {
9
+ utilization: number;
10
+ threshold: number;
11
+ model: string;
12
+ maxContextTokens: number;
13
+ inputTokens: number;
14
+ });
15
+ }
@@ -0,0 +1,17 @@
1
+ /** Thrown when context guard blockAt threshold is exceeded. */
2
+ export class ToadContextExceededError extends Error {
3
+ utilization;
4
+ threshold;
5
+ model;
6
+ maxContextTokens;
7
+ inputTokens;
8
+ constructor(info) {
9
+ super(`toad-eye: context window ${(info.utilization * 100).toFixed(0)}% full (${info.inputTokens}/${info.maxContextTokens} tokens) for model ${info.model} — blocked at ${(info.threshold * 100).toFixed(0)}%`);
10
+ this.name = "ToadContextExceededError";
11
+ this.utilization = info.utilization;
12
+ this.threshold = info.threshold;
13
+ this.model = info.model;
14
+ this.maxContextTokens = info.maxContextTokens;
15
+ this.inputTokens = info.inputTokens;
16
+ }
17
+ }
@@ -0,0 +1,8 @@
1
+ export declare function resetContextGuardState(): void;
2
+ /**
3
+ * Check context utilization before an LLM call.
4
+ * - If utilization > blockAt → throw ToadContextExceededError
5
+ * - If utilization > warnAt → console.warn (once per model per session)
6
+ * - If model unknown or no guard configured → skip silently
7
+ */
8
+ export declare function checkContextGuard(model: string, inputTokens: number): void;
@@ -0,0 +1,41 @@
1
+ import { getModelPricing } from "../core/pricing.js";
2
+ import { getConfig } from "../core/tracer.js";
3
+ import { ToadContextExceededError } from "./error.js";
4
+ let warnedModels = new Set();
5
+ export function resetContextGuardState() {
6
+ warnedModels = new Set();
7
+ }
8
+ /**
9
+ * Check context utilization before an LLM call.
10
+ * - If utilization > blockAt → throw ToadContextExceededError
11
+ * - If utilization > warnAt → console.warn (once per model per session)
12
+ * - If model unknown or no guard configured → skip silently
13
+ */
14
+ export function checkContextGuard(model, inputTokens) {
15
+ if (inputTokens <= 0)
16
+ return;
17
+ const config = getConfig();
18
+ const guard = config?.contextGuard;
19
+ if (!guard)
20
+ return;
21
+ const pricing = getModelPricing(model);
22
+ if (!pricing?.maxContextTokens)
23
+ return;
24
+ const utilization = inputTokens / pricing.maxContextTokens;
25
+ if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
26
+ throw new ToadContextExceededError({
27
+ utilization,
28
+ threshold: guard.blockAt,
29
+ model,
30
+ maxContextTokens: pricing.maxContextTokens,
31
+ inputTokens,
32
+ });
33
+ }
34
+ if (guard.warnAt !== undefined && utilization >= guard.warnAt) {
35
+ const key = `${model}:${Math.floor(utilization * 10)}`;
36
+ if (!warnedModels.has(key)) {
37
+ warnedModels.add(key);
38
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${model} (${inputTokens}/${pricing.maxContextTokens} tokens)`);
39
+ }
40
+ }
41
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export {};
2
+ // Context module — post-call utilization monitoring only.
3
+ // Pre-call blocking was removed (see #198) — API providers handle
4
+ // context overflow natively, and pre-call token estimation is unreliable.
@@ -7,7 +7,8 @@ export declare function recordTokens(count: number, provider: string, model: str
7
7
  export declare function recordRequest(provider: string, model: string, attrs?: Record<string, string>): void;
8
8
  export declare function recordError(provider: string, model: string, attrs?: Record<string, string>): void;
9
9
  export declare function recordAgentSteps(stepCount: number): void;
10
- export declare function recordAgentToolUsage(toolName: string): void;
10
+ export declare function recordAgentToolUsage(toolName: string, status?: "success" | "error"): void;
11
+ export declare function recordAgentToolDuration(toolName: string, durationMs: number): void;
11
12
  export declare function recordGuardEvaluation(ruleName: string): void;
12
13
  export declare function recordGuardWouldBlock(ruleName: string): void;
13
14
  export declare function recordSemanticDrift(drift: number, provider: string, model: string): void;
@@ -17,3 +18,5 @@ export declare function recordBudgetBlocked(budgetType: string): void;
17
18
  export declare function recordBudgetDowngraded(budgetType: string): void;
18
19
  export declare function recordResponseEmpty(provider: string, model: string, attrs?: Record<string, string>): void;
19
20
  export declare function recordResponseLatencyPerToken(msPerToken: number, provider: string, model: string, attrs?: Record<string, string>): void;
21
+ export declare function recordContextUtilization(utilization: number, provider: string, model: string): void;
22
+ export declare function recordContextBlocked(model: string): void;
@@ -7,6 +7,7 @@ let requestsTotal;
7
7
  let errorsTotal;
8
8
  let agentStepsPerQuery;
9
9
  let agentToolUsage;
10
+ let agentToolDuration;
10
11
  let guardEvaluations;
11
12
  let guardWouldBlock;
12
13
  let semanticDrift;
@@ -16,6 +17,8 @@ let budgetBlocked;
16
17
  let budgetDowngraded;
17
18
  let responseEmpty;
18
19
  let responseLatencyPerToken;
20
+ let contextUtilization;
21
+ let contextBlocked;
19
22
  let initialized = false;
20
23
  export function initMetrics() {
21
24
  if (initialized)
@@ -42,7 +45,11 @@ export function initMetrics() {
42
45
  description: "Number of agent steps per query",
43
46
  });
44
47
  agentToolUsage = meter.createCounter(GEN_AI_METRICS.AGENT_TOOL_USAGE, {
45
- description: "Agent tool invocations by tool name",
48
+ description: "Agent tool invocations by tool name and status",
49
+ });
50
+ agentToolDuration = meter.createHistogram(GEN_AI_METRICS.AGENT_TOOL_DURATION, {
51
+ description: "Agent tool execution duration in milliseconds",
52
+ unit: "ms",
46
53
  });
47
54
  guardEvaluations = meter.createCounter(GEN_AI_METRICS.GUARD_EVALUATIONS, {
48
55
  description: "Total guard evaluations (shadow + enforce)",
@@ -73,12 +80,22 @@ export function initMetrics() {
73
80
  description: "Generation latency normalized per output token (ms/token). Higher values indicate slower generation speed.",
74
81
  unit: "ms",
75
82
  });
83
+ contextUtilization = meter.createHistogram(GEN_AI_METRICS.CONTEXT_UTILIZATION, {
84
+ description: "Ratio of input tokens to model context window (0.0–1.0+). Values above 0.8 indicate risk of context overflow.",
85
+ });
86
+ contextBlocked = meter.createCounter(GEN_AI_METRICS.CONTEXT_BLOCKED, {
87
+ description: "Number of LLM calls blocked by context guard",
88
+ });
76
89
  initialized = true;
77
90
  }
78
91
  /** Reset metrics state so the next initMetrics() creates fresh instruments. Called from shutdown(). */
79
92
  export function resetMetrics() {
80
93
  initialized = false;
81
94
  }
95
+ /** Returns true if metrics are safe to record. Prevents crash if called before initMetrics(). */
96
+ function isReady() {
97
+ return initialized;
98
+ }
82
99
  /** Build metric labels: provider + model + optional FinOps attributes. */
83
100
  function baseLabels(provider, model, attrs) {
84
101
  return {
@@ -88,62 +105,117 @@ function baseLabels(provider, model, attrs) {
88
105
  };
89
106
  }
90
107
  export function recordRequestDuration(ms, provider, model, attrs) {
108
+ if (!isReady())
109
+ return;
91
110
  requestDuration.record(ms, baseLabels(provider, model, attrs));
92
111
  }
93
112
  export function recordRequestCost(usd, provider, model, attrs) {
113
+ if (!isReady())
114
+ return;
94
115
  requestCost.record(usd, baseLabels(provider, model, attrs));
95
116
  }
96
117
  export function recordTokens(count, provider, model, attrs) {
118
+ if (!isReady())
119
+ return;
97
120
  tokenUsage.add(count, baseLabels(provider, model, attrs));
98
121
  }
99
122
  export function recordRequest(provider, model, attrs) {
123
+ if (!isReady())
124
+ return;
100
125
  requestsTotal.add(1, baseLabels(provider, model, attrs));
101
126
  }
102
127
  export function recordError(provider, model, attrs) {
128
+ if (!isReady())
129
+ return;
103
130
  errorsTotal.add(1, baseLabels(provider, model, attrs));
104
131
  }
105
132
  export function recordAgentSteps(stepCount) {
133
+ if (!isReady())
134
+ return;
106
135
  agentStepsPerQuery.record(stepCount);
107
136
  }
108
- export function recordAgentToolUsage(toolName) {
137
+ export function recordAgentToolUsage(toolName, status = "success") {
138
+ if (!isReady())
139
+ return;
109
140
  agentToolUsage.add(1, {
110
141
  [GEN_AI_ATTRS.TOOL_NAME]: toolName,
142
+ "tool.status": status,
143
+ });
144
+ }
145
+ export function recordAgentToolDuration(toolName, durationMs) {
146
+ if (!isReady())
147
+ return;
148
+ agentToolDuration.record(durationMs, {
149
+ [GEN_AI_ATTRS.TOOL_NAME]: toolName,
111
150
  });
112
151
  }
113
152
  export function recordGuardEvaluation(ruleName) {
153
+ if (!isReady())
154
+ return;
114
155
  guardEvaluations.add(1, {
115
156
  [GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
116
157
  });
117
158
  }
118
159
  export function recordGuardWouldBlock(ruleName) {
160
+ if (!isReady())
161
+ return;
119
162
  guardWouldBlock.add(1, {
120
163
  [GEN_AI_ATTRS.GUARD_RULE_NAME]: ruleName,
121
164
  });
122
165
  }
123
166
  export function recordSemanticDrift(drift, provider, model) {
167
+ if (!isReady())
168
+ return;
124
169
  semanticDrift.record(drift, {
125
170
  [GEN_AI_ATTRS.PROVIDER]: provider,
126
171
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
127
172
  });
128
173
  }
129
174
  export function recordTimeToFirstToken(ms, provider, model) {
175
+ if (!isReady())
176
+ return;
130
177
  timeToFirstToken.record(ms, {
131
178
  [GEN_AI_ATTRS.PROVIDER]: provider,
132
179
  [GEN_AI_ATTRS.REQUEST_MODEL]: model,
133
180
  });
134
181
  }
135
182
  export function recordBudgetExceeded(budgetType) {
183
+ if (!isReady())
184
+ return;
136
185
  budgetExceeded.add(1, { budget_type: budgetType });
137
186
  }
138
187
  export function recordBudgetBlocked(budgetType) {
188
+ if (!isReady())
189
+ return;
139
190
  budgetBlocked.add(1, { budget_type: budgetType });
140
191
  }
141
192
  export function recordBudgetDowngraded(budgetType) {
193
+ if (!isReady())
194
+ return;
142
195
  budgetDowngraded.add(1, { budget_type: budgetType });
143
196
  }
144
197
  export function recordResponseEmpty(provider, model, attrs) {
198
+ if (!isReady())
199
+ return;
145
200
  responseEmpty.add(1, baseLabels(provider, model, attrs));
146
201
  }
147
202
  export function recordResponseLatencyPerToken(msPerToken, provider, model, attrs) {
203
+ if (!isReady())
204
+ return;
148
205
  responseLatencyPerToken.record(msPerToken, baseLabels(provider, model, attrs));
149
206
  }
207
+ export function recordContextUtilization(utilization, provider, model) {
208
+ if (!isReady())
209
+ return;
210
+ contextUtilization.record(utilization, {
211
+ [GEN_AI_ATTRS.PROVIDER]: provider,
212
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
213
+ });
214
+ }
215
+ export function recordContextBlocked(model) {
216
+ if (!isReady())
217
+ return;
218
+ contextBlocked.add(1, {
219
+ [GEN_AI_ATTRS.REQUEST_MODEL]: model,
220
+ });
221
+ }
@@ -6,6 +6,8 @@
6
6
  export interface ModelPricing {
7
7
  readonly inputPer1M: number;
8
8
  readonly outputPer1M: number;
9
+ /** Maximum context window size in tokens. Used to calculate context utilization ratio. */
10
+ readonly maxContextTokens?: number | undefined;
9
11
  }
10
12
  /**
11
13
  * Set custom pricing for models (overrides built-in prices).
@@ -5,25 +5,62 @@
5
5
  */
6
6
  const BUILT_IN_PRICING = {
7
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 },
8
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10, maxContextTokens: 128_000 },
9
+ "gpt-4o-mini": {
10
+ inputPer1M: 0.15,
11
+ outputPer1M: 0.6,
12
+ maxContextTokens: 128_000,
13
+ },
14
+ "gpt-4-turbo": { inputPer1M: 10, outputPer1M: 30, maxContextTokens: 128_000 },
15
+ "gpt-4.1": { inputPer1M: 2, outputPer1M: 8, maxContextTokens: 1_047_576 },
16
+ "gpt-4.1-mini": {
17
+ inputPer1M: 0.4,
18
+ outputPer1M: 1.6,
19
+ maxContextTokens: 1_047_576,
20
+ },
21
+ "gpt-4.1-nano": {
22
+ inputPer1M: 0.1,
23
+ outputPer1M: 0.4,
24
+ maxContextTokens: 1_047_576,
25
+ },
26
+ o3: { inputPer1M: 10, outputPer1M: 40, maxContextTokens: 200_000 },
27
+ "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, maxContextTokens: 200_000 },
28
+ "o4-mini": { inputPer1M: 1.1, outputPer1M: 4.4, maxContextTokens: 200_000 },
17
29
  // 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 },
30
+ "claude-opus-4-20250514": {
31
+ inputPer1M: 15,
32
+ outputPer1M: 75,
33
+ maxContextTokens: 200_000,
34
+ },
35
+ "claude-sonnet-4-20250514": {
36
+ inputPer1M: 3,
37
+ outputPer1M: 15,
38
+ maxContextTokens: 200_000,
39
+ },
40
+ "claude-haiku-3-5-20241022": {
41
+ inputPer1M: 0.8,
42
+ outputPer1M: 4,
43
+ maxContextTokens: 200_000,
44
+ },
21
45
  // 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 },
46
+ "gemini-2.5-pro": {
47
+ inputPer1M: 1.25,
48
+ outputPer1M: 10,
49
+ maxContextTokens: 1_048_576,
50
+ },
51
+ "gemini-2.5-flash": {
52
+ inputPer1M: 0.15,
53
+ outputPer1M: 0.6,
54
+ maxContextTokens: 1_048_576,
55
+ },
56
+ "gemini-2.0-flash": {
57
+ inputPer1M: 0.1,
58
+ outputPer1M: 0.4,
59
+ maxContextTokens: 1_048_576,
60
+ },
25
61
  };
26
62
  let customPricing = {};
63
+ const warnedModels = new Set();
27
64
  /**
28
65
  * Set custom pricing for models (overrides built-in prices).
29
66
  * Useful for enterprise contracts or custom/fine-tuned models.
@@ -34,6 +71,7 @@ export function setCustomPricing(pricing) {
34
71
  /** Reset custom pricing to empty. Called from shutdown() to avoid stale state across re-inits. */
35
72
  export function resetCustomPricing() {
36
73
  customPricing = {};
74
+ warnedModels.clear();
37
75
  }
38
76
  /**
39
77
  * Get pricing for a model. Custom pricing takes precedence.
@@ -50,8 +88,15 @@ export function calculateCost(model, inputTokens, outputTokens) {
50
88
  if (inputTokens < 0 || outputTokens < 0)
51
89
  return 0;
52
90
  const pricing = getModelPricing(model);
53
- if (!pricing)
91
+ if (!pricing) {
92
+ if (inputTokens + outputTokens > 0 && !warnedModels.has(model)) {
93
+ if (warnedModels.size >= 100)
94
+ warnedModels.clear();
95
+ warnedModels.add(model);
96
+ console.warn(`toad-eye: unknown pricing for "${model}" — cost reported as $0. Use setCustomPricing() to set rates.`);
97
+ }
54
98
  return 0;
99
+ }
55
100
  const cost = (inputTokens / 1_000_000) * pricing.inputPer1M +
56
101
  (outputTokens / 1_000_000) * pricing.outputPer1M;
57
102
  return Math.round(cost * 1_000_000) / 1_000_000;
@@ -5,6 +5,8 @@ export interface LLMCallInput {
5
5
  readonly model: string;
6
6
  readonly prompt: string;
7
7
  readonly temperature?: number | undefined;
8
+ /** OTel operation name for span naming. Default: "chat". */
9
+ readonly operationName?: string | undefined;
8
10
  /** Per-request FinOps attributes (team, userId, feature, etc). Merged with global attributes. */
9
11
  readonly attributes?: Readonly<Record<string, string>> | undefined;
10
12
  }
@@ -1,9 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { trace, SpanStatusCode } from "@opentelemetry/api";
3
3
  import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
4
- import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, } from "./metrics.js";
4
+ import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordBudgetExceeded, recordBudgetBlocked, recordBudgetDowngraded, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, } from "./metrics.js";
5
5
  import { getConfig, getBudgetTracker } from "./tracer.js";
6
- import { calculateCost } from "./pricing.js";
6
+ import { calculateCost, getModelPricing } from "./pricing.js";
7
7
  import { ToadBudgetExceededError } from "../budget/index.js";
8
8
  const tracer = trace.getTracer(INSTRUMENTATION_NAME);
9
9
  let saltWarningEmitted = false;
@@ -46,6 +46,11 @@ export function processContent(text) {
46
46
  const config = getConfig();
47
47
  if (config?.recordContent === false)
48
48
  return undefined;
49
+ // Content sampling — record only a fraction of calls
50
+ if (config?.contentSamplingRate !== undefined) {
51
+ if (Math.random() >= config.contentSamplingRate)
52
+ return undefined;
53
+ }
49
54
  let processed = text;
50
55
  let totalRedacted = 0;
51
56
  // Apply default PII patterns if enabled
@@ -59,9 +64,14 @@ export function processContent(text) {
59
64
  // Apply custom patterns
60
65
  if (config?.redactPatterns?.length) {
61
66
  for (const pattern of config.redactPatterns) {
62
- const [result, count] = applyRedaction(processed, pattern);
63
- processed = result;
64
- totalRedacted += count;
67
+ try {
68
+ const [result, count] = applyRedaction(processed, pattern);
69
+ processed = result;
70
+ totalRedacted += count;
71
+ }
72
+ catch {
73
+ console.warn(`toad-eye: invalid redact pattern skipped: ${pattern.source}`);
74
+ }
65
75
  }
66
76
  }
67
77
  // Audit mode — log a summary only, never the original content (would re-expose PII)
@@ -93,7 +103,7 @@ function setBaseAttributes(span, input) {
93
103
  [GEN_AI_ATTRS.PROVIDER]: input.provider,
94
104
  [GEN_AI_ATTRS.REQUEST_MODEL]: input.model,
95
105
  [GEN_AI_ATTRS.TEMPERATURE]: input.temperature ?? 1.0,
96
- [GEN_AI_ATTRS.OPERATION]: "chat",
106
+ [GEN_AI_ATTRS.OPERATION]: input.operationName ?? "chat",
97
107
  ...(prompt !== undefined && { [GEN_AI_ATTRS.PROMPT]: prompt }),
98
108
  ...(sessionId !== undefined && { [GEN_AI_ATTRS.SESSION_ID]: sessionId }),
99
109
  ...attrs,
@@ -153,7 +163,8 @@ export async function traceLLMCall(input, fn) {
153
163
  recordBudgetDowngraded(override.budget);
154
164
  }
155
165
  }
156
- return tracer.startActiveSpan(`chat ${effectiveInput.model}`, async (span) => {
166
+ const op = effectiveInput.operationName ?? "chat";
167
+ return tracer.startActiveSpan(`${op} ${effectiveInput.model}`, async (span) => {
157
168
  const start = performance.now();
158
169
  setBaseAttributes(span, effectiveInput);
159
170
  try {
@@ -173,6 +184,31 @@ export async function traceLLMCall(input, fn) {
173
184
  if (output.outputTokens > 0) {
174
185
  recordResponseLatencyPerToken(duration / output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
175
186
  }
187
+ // Context window utilization — ratio of input tokens to model's max context
188
+ const pricing = getModelPricing(effectiveInput.model);
189
+ if (pricing?.maxContextTokens && output.inputTokens > 0) {
190
+ const utilization = output.inputTokens / pricing.maxContextTokens;
191
+ span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
192
+ recordContextUtilization(utilization, effectiveInput.provider, effectiveInput.model);
193
+ // Context guard — post-call warning when approaching limit.
194
+ // This warns about the CURRENT call's context usage so agents
195
+ // can compress context before the NEXT call.
196
+ const guard = getConfig()?.contextGuard;
197
+ if (guard) {
198
+ if (guard.alertAt !== undefined && utilization >= guard.alertAt) {
199
+ recordContextBlocked(effectiveInput.model);
200
+ span.addEvent("gen_ai.context.limit_exceeded", {
201
+ "gen_ai.toad_eye.context_utilization": utilization,
202
+ "gen_ai.toad_eye.context.threshold": guard.alertAt,
203
+ });
204
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} — exceeds alertAt threshold ${(guard.alertAt * 100).toFixed(0)}%. Compress context before next call.`);
205
+ }
206
+ else if (guard.warnAt !== undefined &&
207
+ utilization >= guard.warnAt) {
208
+ console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} (${output.inputTokens}/${pricing.maxContextTokens} tokens)`);
209
+ }
210
+ }
211
+ }
176
212
  // Budget recording AFTER the call — releases the cost reservation
177
213
  if (budget) {
178
214
  const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
@@ -190,15 +226,18 @@ export async function traceLLMCall(input, fn) {
190
226
  const attrs = resolveAttributes(effectiveInput);
191
227
  setErrorAttributes(span, message);
192
228
  recordBaseMetrics(duration, effectiveInput.provider, effectiveInput.model, attrs);
193
- recordError(effectiveInput.provider, effectiveInput.model, attrs);
194
229
  // Release cost reservation on any error (no cost was incurred)
195
230
  if (budget) {
196
231
  budget.releaseReservation(estimatedCost);
197
232
  }
198
- // If this was a budget block, record the metric with the correct budget type
199
233
  if (error instanceof ToadBudgetExceededError) {
234
+ // Budget blocks are intentional guardrails, not errors — record
235
+ // only the budget-specific metric to avoid inflating error rate
200
236
  recordBudgetBlocked(error.budget);
201
237
  }
238
+ else {
239
+ recordError(effectiveInput.provider, effectiveInput.model, attrs);
240
+ }
202
241
  throw error;
203
242
  }
204
243
  finally {
@@ -40,6 +40,10 @@ function validateConfig(config) {
40
40
  if (config.apiKey !== undefined && !config.apiKey.startsWith("toad_")) {
41
41
  throw new Error('toad-eye: apiKey must start with "toad_" prefix');
42
42
  }
43
+ if (config.onBudgetExceeded === "downgrade" &&
44
+ config.downgradeCallback === undefined) {
45
+ throw new Error('toad-eye: downgradeCallback is required when onBudgetExceeded is "downgrade"');
46
+ }
43
47
  }
44
48
  /** Resolve endpoint and auth headers based on config mode (self-hosted vs cloud). */
45
49
  function resolveTransport(config) {
@@ -123,7 +127,9 @@ export function initObservability(config) {
123
127
  if (patchProviders.length > 0) {
124
128
  // enableAll is async (lazy-loads provider modules on first call).
125
129
  // Fire-and-forget: patching completes before any async SDK call.
126
- void enableAll(patchProviders);
130
+ void enableAll(patchProviders).catch((err) => {
131
+ console.warn(`toad-eye: auto-instrumentation failed: ${err instanceof Error ? err.message : err}`);
132
+ });
127
133
  }
128
134
  }
129
135
  }
@@ -47,8 +47,14 @@ const messagesCreate = {
47
47
  isStreaming: (body) => !!body?.stream,
48
48
  accumulateChunk: (acc, chunk) => {
49
49
  const event = chunk;
50
- if (event.type === "content_block_delta" && event.delta?.text) {
51
- acc.completion += event.delta.text;
50
+ if (event.type === "content_block_delta") {
51
+ if (event.delta?.type === "thinking_delta" && event.delta.thinking) {
52
+ // Extended thinking — track separately from completion
53
+ acc.thinkingContent += event.delta.thinking;
54
+ }
55
+ else if (event.delta?.text) {
56
+ acc.completion += event.delta.text;
57
+ }
52
58
  }
53
59
  // Tool use: content_block_start with type "tool_use"
54
60
  if (event.type === "content_block_start" &&