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.
- package/README.md +34 -1
- package/dist/agent.js +6 -2
- package/dist/cli.js +8 -1
- package/dist/context/error.d.ts +15 -0
- package/dist/context/error.js +17 -0
- package/dist/context/guard.d.ts +8 -0
- package/dist/context/guard.js +41 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +4 -0
- package/dist/core/metrics.d.ts +4 -1
- package/dist/core/metrics.js +74 -2
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +61 -16
- package/dist/core/spans.d.ts +2 -0
- package/dist/core/spans.js +48 -9
- package/dist/core/tracer.js +7 -1
- package/dist/instrumentations/anthropic.js +8 -2
- package/dist/instrumentations/create.js +67 -8
- package/dist/instrumentations/gemini.js +7 -0
- package/dist/instrumentations/openai.js +5 -0
- package/dist/instrumentations/types.d.ts +8 -0
- package/dist/mcp/context.d.ts +12 -0
- package/dist/mcp/context.js +22 -0
- package/dist/mcp/index.d.ts +3 -0
- package/dist/mcp/index.js +1 -0
- package/dist/mcp/metrics.d.ts +11 -0
- package/dist/mcp/metrics.js +58 -0
- package/dist/mcp/middleware.d.ts +48 -0
- package/dist/mcp/middleware.js +253 -0
- package/dist/mcp/spans.d.ts +28 -0
- package/dist/mcp/spans.js +67 -0
- package/dist/mcp/types.d.ts +34 -0
- package/dist/mcp/types.js +1 -0
- package/dist/types/attributes.d.ts +1 -0
- package/dist/types/attributes.js +1 -0
- package/dist/types/config.d.ts +9 -0
- package/dist/types/metrics.d.ts +8 -0
- package/dist/types/metrics.js +10 -0
- package/dist/types/spans.d.ts +4 -0
- package/package.json +11 -2
- package/templates/docker-compose.yml +4 -4
- package/templates/grafana/dashboards/agent-workflow.json +154 -31
- package/templates/grafana/dashboards/cost-breakdown.json +168 -40
- package/templates/grafana/dashboards/error-drilldown.json +155 -37
- package/templates/grafana/dashboards/finops-attribution.json +192 -43
- package/templates/grafana/dashboards/latency-analysis.json +103 -24
- package/templates/grafana/dashboards/llm-overview.json +268 -66
- package/templates/grafana/dashboards/mcp-server.json +519 -0
- package/templates/grafana/dashboards/model-comparison.json +119 -26
- 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
|
-

|
|
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
|
-
|
|
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 {};
|
package/dist/core/metrics.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/metrics.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/core/pricing.d.ts
CHANGED
|
@@ -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).
|
package/dist/core/pricing.js
CHANGED
|
@@ -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": {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
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": {
|
|
19
|
-
|
|
20
|
-
|
|
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": {
|
|
23
|
-
|
|
24
|
-
|
|
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;
|
package/dist/core/spans.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/core/spans.js
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/core/tracer.js
CHANGED
|
@@ -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"
|
|
51
|
-
|
|
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" &&
|