toad-eye 2.4.4 → 2.5.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 +1 -1
- package/dist/agent.js +6 -2
- 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 +2 -0
- package/dist/context/index.js +2 -0
- package/dist/core/metrics.d.ts +4 -1
- package/dist/core/metrics.js +32 -2
- package/dist/core/pricing.d.ts +2 -0
- package/dist/core/pricing.js +51 -15
- package/dist/core/spans.js +27 -2
- package/dist/core/tracer.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/instrumentations/anthropic.js +25 -2
- package/dist/instrumentations/create.js +61 -3
- package/dist/instrumentations/gemini.js +12 -0
- package/dist/instrumentations/openai.js +20 -3
- package/dist/instrumentations/types.d.ts +9 -0
- package/dist/types/attributes.d.ts +1 -0
- package/dist/types/attributes.js +1 -0
- package/dist/types/config.d.ts +7 -0
- package/dist/types/metrics.d.ts +3 -0
- package/dist/types/metrics.js +4 -0
- package/dist/types/spans.d.ts +4 -0
- package/package.json +1 -1
- 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/model-comparison.json +119 -26
- package/templates/grafana/dashboards/provider-health.json +137 -31
package/README.md
CHANGED
|
@@ -385,7 +385,7 @@ import { ToadEyeAISpanProcessor, withToadEye } from "toad-eye/vercel"; // Vercel
|
|
|
385
385
|
|
|
386
386
|
## Architecture
|
|
387
387
|
|
|
388
|
-

|
|
389
389
|
|
|
390
390
|
## What it tracks
|
|
391
391
|
|
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();
|
|
@@ -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
|
+
}
|
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,6 +80,12 @@ 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(). */
|
|
@@ -105,9 +118,15 @@ export function recordError(provider, model, attrs) {
|
|
|
105
118
|
export function recordAgentSteps(stepCount) {
|
|
106
119
|
agentStepsPerQuery.record(stepCount);
|
|
107
120
|
}
|
|
108
|
-
export function recordAgentToolUsage(toolName) {
|
|
121
|
+
export function recordAgentToolUsage(toolName, status = "success") {
|
|
109
122
|
agentToolUsage.add(1, {
|
|
110
123
|
[GEN_AI_ATTRS.TOOL_NAME]: toolName,
|
|
124
|
+
"tool.status": status,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
export function recordAgentToolDuration(toolName, durationMs) {
|
|
128
|
+
agentToolDuration.record(durationMs, {
|
|
129
|
+
[GEN_AI_ATTRS.TOOL_NAME]: toolName,
|
|
111
130
|
});
|
|
112
131
|
}
|
|
113
132
|
export function recordGuardEvaluation(ruleName) {
|
|
@@ -147,3 +166,14 @@ export function recordResponseEmpty(provider, model, attrs) {
|
|
|
147
166
|
export function recordResponseLatencyPerToken(msPerToken, provider, model, attrs) {
|
|
148
167
|
responseLatencyPerToken.record(msPerToken, baseLabels(provider, model, attrs));
|
|
149
168
|
}
|
|
169
|
+
export function recordContextUtilization(utilization, provider, model) {
|
|
170
|
+
contextUtilization.record(utilization, {
|
|
171
|
+
[GEN_AI_ATTRS.PROVIDER]: provider,
|
|
172
|
+
[GEN_AI_ATTRS.REQUEST_MODEL]: model,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export function recordContextBlocked(model) {
|
|
176
|
+
contextBlocked.add(1, {
|
|
177
|
+
[GEN_AI_ATTRS.REQUEST_MODEL]: model,
|
|
178
|
+
});
|
|
179
|
+
}
|
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,23 +5,59 @@
|
|
|
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 = {};
|
|
27
63
|
/**
|
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;
|
|
@@ -173,6 +173,31 @@ export async function traceLLMCall(input, fn) {
|
|
|
173
173
|
if (output.outputTokens > 0) {
|
|
174
174
|
recordResponseLatencyPerToken(duration / output.outputTokens, effectiveInput.provider, effectiveInput.model, attrs);
|
|
175
175
|
}
|
|
176
|
+
// Context window utilization — ratio of input tokens to model's max context
|
|
177
|
+
const pricing = getModelPricing(effectiveInput.model);
|
|
178
|
+
if (pricing?.maxContextTokens && output.inputTokens > 0) {
|
|
179
|
+
const utilization = output.inputTokens / pricing.maxContextTokens;
|
|
180
|
+
span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
|
|
181
|
+
recordContextUtilization(utilization, effectiveInput.provider, effectiveInput.model);
|
|
182
|
+
// Context guard — post-call warning when approaching limit.
|
|
183
|
+
// This warns about the CURRENT call's context usage so agents
|
|
184
|
+
// can compress context before the NEXT call.
|
|
185
|
+
const guard = getConfig()?.contextGuard;
|
|
186
|
+
if (guard) {
|
|
187
|
+
if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
|
|
188
|
+
recordContextBlocked(effectiveInput.model);
|
|
189
|
+
span.addEvent("gen_ai.context.limit_exceeded", {
|
|
190
|
+
"gen_ai.toad_eye.context_utilization": utilization,
|
|
191
|
+
"gen_ai.toad_eye.context.threshold": guard.blockAt,
|
|
192
|
+
});
|
|
193
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} — exceeds blockAt threshold ${(guard.blockAt * 100).toFixed(0)}%. Compress context before next call.`);
|
|
194
|
+
}
|
|
195
|
+
else if (guard.warnAt !== undefined &&
|
|
196
|
+
utilization >= guard.warnAt) {
|
|
197
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveInput.model} (${output.inputTokens}/${pricing.maxContextTokens} tokens)`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
176
201
|
// Budget recording AFTER the call — releases the cost reservation
|
|
177
202
|
if (budget) {
|
|
178
203
|
const userId = effectiveInput.attributes?.[GEN_AI_ATTRS.USER_ID];
|
package/dist/core/tracer.js
CHANGED
|
@@ -7,6 +7,7 @@ import { TraceIdRatioBasedSampler, ParentBasedSampler, BatchSpanProcessor, } fro
|
|
|
7
7
|
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
8
8
|
import { initMetrics, resetMetrics } from "./metrics.js";
|
|
9
9
|
import { resetCustomPricing } from "./pricing.js";
|
|
10
|
+
import { resetContextGuardState } from "../context/guard.js";
|
|
10
11
|
import { enableAll, disableAll } from "../instrumentations/registry.js";
|
|
11
12
|
import { BudgetTracker } from "../budget/index.js";
|
|
12
13
|
import { ToadEyeAISpanProcessor } from "../vercel.js";
|
|
@@ -137,4 +138,5 @@ export async function shutdown() {
|
|
|
137
138
|
budgetTracker = null;
|
|
138
139
|
resetMetrics();
|
|
139
140
|
resetCustomPricing();
|
|
141
|
+
resetContextGuardState();
|
|
140
142
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
|
|
2
2
|
export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
|
|
3
|
+
export { ToadContextExceededError } from "./context/index.js";
|
|
3
4
|
export type { BudgetConfig, BudgetExceededMode, BudgetExceededInfo, DowngradeCallback, } from "./budget/index.js";
|
|
4
5
|
export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
|
|
5
6
|
export type { AlertsConfig, AlertRule, AlertChannelConfig, FiredAlert, } from "./alerts/index.js";
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { initObservability, shutdown, getConfig, getBudgetTracker, } from "./core/tracer.js";
|
|
2
2
|
export { BudgetTracker, ToadBudgetExceededError } from "./budget/index.js";
|
|
3
|
+
export { ToadContextExceededError } from "./context/index.js";
|
|
3
4
|
export { AlertManager, startAlertsFromFile, parseCondition, } from "./alerts/index.js";
|
|
4
5
|
export { traceLLMCall } from "./core/spans.js";
|
|
5
6
|
export { traceAgentStep, traceAgentQuery } from "./agent.js";
|
|
@@ -47,8 +47,31 @@ 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
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Tool use: content_block_start with type "tool_use"
|
|
60
|
+
if (event.type === "content_block_start" &&
|
|
61
|
+
event.content_block?.type === "tool_use") {
|
|
62
|
+
acc.toolCalls.push({
|
|
63
|
+
name: event.content_block.name ?? "",
|
|
64
|
+
arguments: "",
|
|
65
|
+
id: event.content_block.id,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Tool use: input_json_delta accumulates arguments
|
|
69
|
+
if (event.type === "content_block_delta" &&
|
|
70
|
+
event.delta?.type === "input_json_delta" &&
|
|
71
|
+
event.delta.partial_json) {
|
|
72
|
+
const last = acc.toolCalls[acc.toolCalls.length - 1];
|
|
73
|
+
if (last)
|
|
74
|
+
last.arguments += event.delta.partial_json;
|
|
52
75
|
}
|
|
53
76
|
if (event.type === "message_start" && event.message?.usage) {
|
|
54
77
|
acc.inputTokens = event.message.usage.input_tokens ?? 0;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { trace, context, diag, SpanStatusCode, } from "@opentelemetry/api";
|
|
3
3
|
import { traceLLMCall, processContent } from "../core/spans.js";
|
|
4
|
-
import { calculateCost } from "../core/pricing.js";
|
|
5
|
-
import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
|
|
4
|
+
import { calculateCost, getModelPricing } from "../core/pricing.js";
|
|
5
|
+
import { recordRequestDuration, recordRequestCost, recordTokens, recordRequest, recordError, recordTimeToFirstToken, recordResponseEmpty, recordResponseLatencyPerToken, recordContextUtilization, recordContextBlocked, recordBudgetExceeded, recordBudgetDowngraded, } from "../core/metrics.js";
|
|
6
6
|
import { getConfig, getBudgetTracker } from "../core/tracer.js";
|
|
7
7
|
import { ToadBudgetExceededError } from "../budget/index.js";
|
|
8
8
|
import { GEN_AI_ATTRS, INSTRUMENTATION_NAME } from "../types/index.js";
|
|
@@ -31,8 +31,10 @@ function loadModule(moduleName) {
|
|
|
31
31
|
async function* wrapAsyncIterable(stream, accumulate, onFirstChunk, onComplete, onError) {
|
|
32
32
|
const acc = {
|
|
33
33
|
completion: "",
|
|
34
|
+
thinkingContent: "",
|
|
34
35
|
inputTokens: 0,
|
|
35
36
|
outputTokens: 0,
|
|
37
|
+
toolCalls: [],
|
|
36
38
|
};
|
|
37
39
|
let firstChunk = true;
|
|
38
40
|
let completed = false;
|
|
@@ -66,6 +68,14 @@ function createStreamingHandler(
|
|
|
66
68
|
original, patch, providerName) {
|
|
67
69
|
const tracer = trace.getTracer(INSTRUMENTATION_NAME);
|
|
68
70
|
return async function handleStreaming(thisArg, body, rest) {
|
|
71
|
+
// OpenAI does not send usage in streaming chunks by default.
|
|
72
|
+
// Auto-inject stream_options to get token counts in the final chunk.
|
|
73
|
+
if (providerName === "openai") {
|
|
74
|
+
const b = body;
|
|
75
|
+
if (!b["stream_options"]) {
|
|
76
|
+
b["stream_options"] = { include_usage: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
69
79
|
// Pass thisArg so extractRequest can access instance properties (e.g., Gemini model name)
|
|
70
80
|
const req = patch.extractRequest(body, thisArg);
|
|
71
81
|
const start = performance.now();
|
|
@@ -87,6 +97,7 @@ original, patch, providerName) {
|
|
|
87
97
|
const span = tracer.startSpan(`chat ${effectiveModel}`);
|
|
88
98
|
const ctx = trace.setSpan(context.active(), span);
|
|
89
99
|
const sessionId = config?.sessionExtractor?.() ?? config?.sessionId;
|
|
100
|
+
let ttftMs = 0;
|
|
90
101
|
span.setAttributes({
|
|
91
102
|
[GEN_AI_ATTRS.PROVIDER]: effectiveProvider,
|
|
92
103
|
[GEN_AI_ATTRS.REQUEST_MODEL]: effectiveModel,
|
|
@@ -127,11 +138,27 @@ original, patch, providerName) {
|
|
|
127
138
|
? resp["stream"]
|
|
128
139
|
: response;
|
|
129
140
|
const wrapped = context.bind(ctx, wrapAsyncIterable(streamIterable, (acc, chunk) => patch.accumulateChunk(acc, chunk), () => {
|
|
130
|
-
|
|
141
|
+
const ttft = performance.now() - start;
|
|
142
|
+
ttftMs = ttft;
|
|
143
|
+
recordTimeToFirstToken(ttft, effectiveProvider, effectiveModel);
|
|
144
|
+
// Span event for per-trace TTFT debugging in Jaeger
|
|
145
|
+
span.addEvent("gen_ai.content.first_token", {
|
|
146
|
+
"gen_ai.response.time_to_first_token_ms": ttft,
|
|
147
|
+
});
|
|
148
|
+
span.setAttribute("gen_ai.response.time_to_first_token_ms", ttft);
|
|
131
149
|
}, (acc) => {
|
|
132
150
|
const duration = performance.now() - start;
|
|
133
151
|
const cost = calculateCost(effectiveModel, acc.inputTokens, acc.outputTokens);
|
|
134
152
|
const processedCompletion = processContent(acc.completion);
|
|
153
|
+
// Anthropic extended thinking — track separately from completion
|
|
154
|
+
if (acc.thinkingContent.length > 0) {
|
|
155
|
+
span.setAttribute("gen_ai.toad_eye.thinking.content_length", acc.thinkingContent.length);
|
|
156
|
+
}
|
|
157
|
+
// Record tool calls from streaming chunks
|
|
158
|
+
if (acc.toolCalls.length > 0) {
|
|
159
|
+
span.setAttribute(GEN_AI_ATTRS.TOOL_NAME, acc.toolCalls.map((t) => t.name).join(", "));
|
|
160
|
+
span.setAttribute("gen_ai.tool.call.count", acc.toolCalls.length);
|
|
161
|
+
}
|
|
135
162
|
span.setAttributes({
|
|
136
163
|
...(processedCompletion !== undefined && {
|
|
137
164
|
[GEN_AI_ATTRS.COMPLETION]: processedCompletion,
|
|
@@ -156,6 +183,37 @@ original, patch, providerName) {
|
|
|
156
183
|
if (acc.outputTokens > 0) {
|
|
157
184
|
recordResponseLatencyPerToken(duration / acc.outputTokens, effectiveProvider, effectiveModel);
|
|
158
185
|
}
|
|
186
|
+
// Context window utilization — ratio of input tokens to model's max context
|
|
187
|
+
const pricing = getModelPricing(effectiveModel);
|
|
188
|
+
if (pricing?.maxContextTokens && acc.inputTokens > 0) {
|
|
189
|
+
const utilization = acc.inputTokens / pricing.maxContextTokens;
|
|
190
|
+
span.setAttribute(GEN_AI_ATTRS.CONTEXT_UTILIZATION, utilization);
|
|
191
|
+
recordContextUtilization(utilization, effectiveProvider, effectiveModel);
|
|
192
|
+
// Context guard — post-stream warning
|
|
193
|
+
const guard = config?.contextGuard;
|
|
194
|
+
if (guard) {
|
|
195
|
+
if (guard.blockAt !== undefined && utilization >= guard.blockAt) {
|
|
196
|
+
recordContextBlocked(effectiveModel);
|
|
197
|
+
span.addEvent("gen_ai.context.limit_exceeded", {
|
|
198
|
+
"gen_ai.toad_eye.context_utilization": utilization,
|
|
199
|
+
"gen_ai.toad_eye.context.threshold": guard.blockAt,
|
|
200
|
+
});
|
|
201
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} — exceeds blockAt threshold ${(guard.blockAt * 100).toFixed(0)}%. Compress context before next call.`);
|
|
202
|
+
}
|
|
203
|
+
else if (guard.warnAt !== undefined &&
|
|
204
|
+
utilization >= guard.warnAt) {
|
|
205
|
+
console.warn(`toad-eye: context window ${(utilization * 100).toFixed(0)}% full for ${effectiveModel} (${acc.inputTokens}/${pricing.maxContextTokens} tokens)`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Prefill/decode latency split — TTFT = prefill, rest = decode
|
|
210
|
+
if (ttftMs > 0) {
|
|
211
|
+
const decodeMs = duration - ttftMs;
|
|
212
|
+
span.setAttribute("gen_ai.toad_eye.latency.decode_ms", decodeMs);
|
|
213
|
+
if (acc.outputTokens > 0 && decodeMs > 0) {
|
|
214
|
+
span.setAttribute("gen_ai.toad_eye.throughput.tokens_per_second", acc.outputTokens / (decodeMs / 1000));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
159
217
|
// Budget recording — releases the reservation made in checkBefore
|
|
160
218
|
if (budget) {
|
|
161
219
|
const exceeded = budget.recordCost(cost, effectiveModel, userId, estimatedCost);
|
|
@@ -66,6 +66,18 @@ const generateContentStream = {
|
|
|
66
66
|
catch {
|
|
67
67
|
// text() may throw if content is blocked
|
|
68
68
|
}
|
|
69
|
+
// Gemini function calls in streaming
|
|
70
|
+
const parts = c?.candidates?.[0]?.content?.parts;
|
|
71
|
+
if (parts) {
|
|
72
|
+
for (const part of parts) {
|
|
73
|
+
if (part.functionCall) {
|
|
74
|
+
acc.toolCalls.push({
|
|
75
|
+
name: part.functionCall.name,
|
|
76
|
+
arguments: JSON.stringify(part.functionCall.args ?? {}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
69
81
|
if (c?.usageMetadata) {
|
|
70
82
|
acc.inputTokens = c.usageMetadata.promptTokenCount ?? acc.inputTokens;
|
|
71
83
|
acc.outputTokens =
|
|
@@ -47,9 +47,26 @@ const chatCompletions = {
|
|
|
47
47
|
isStreaming: (body) => !!body?.stream,
|
|
48
48
|
accumulateChunk: (acc, chunk) => {
|
|
49
49
|
const c = chunk;
|
|
50
|
-
const delta = c?.choices?.[0]?.delta
|
|
51
|
-
if (delta)
|
|
52
|
-
acc.completion += delta;
|
|
50
|
+
const delta = c?.choices?.[0]?.delta;
|
|
51
|
+
if (delta?.content)
|
|
52
|
+
acc.completion += delta.content;
|
|
53
|
+
// Accumulate tool calls from streaming chunks
|
|
54
|
+
if (delta?.tool_calls) {
|
|
55
|
+
for (const tc of delta.tool_calls) {
|
|
56
|
+
const existing = acc.toolCalls[tc.index];
|
|
57
|
+
if (existing) {
|
|
58
|
+
if (tc.function?.arguments)
|
|
59
|
+
existing.arguments += tc.function.arguments;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
acc.toolCalls[tc.index] = {
|
|
63
|
+
name: tc.function?.name ?? "",
|
|
64
|
+
arguments: tc.function?.arguments ?? "",
|
|
65
|
+
id: tc.id,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
53
70
|
if (c?.usage) {
|
|
54
71
|
acc.inputTokens = c.usage.prompt_tokens ?? acc.inputTokens;
|
|
55
72
|
acc.outputTokens = c.usage.completion_tokens ?? acc.outputTokens;
|
|
@@ -6,11 +6,20 @@ export interface Instrumentation {
|
|
|
6
6
|
/** Restore original methods. */
|
|
7
7
|
disable(): void;
|
|
8
8
|
}
|
|
9
|
+
/** Tool call accumulated from streaming chunks. */
|
|
10
|
+
export interface AccumulatedToolCall {
|
|
11
|
+
name: string;
|
|
12
|
+
arguments: string;
|
|
13
|
+
id?: string | undefined;
|
|
14
|
+
}
|
|
9
15
|
/** Accumulated stream data — only primitives, no raw SDK objects. */
|
|
10
16
|
export interface StreamAccumulator {
|
|
11
17
|
completion: string;
|
|
18
|
+
/** Anthropic extended thinking content (tracked separately from completion). */
|
|
19
|
+
thinkingContent: string;
|
|
12
20
|
inputTokens: number;
|
|
13
21
|
outputTokens: number;
|
|
22
|
+
toolCalls: AccumulatedToolCall[];
|
|
14
23
|
}
|
|
15
24
|
/** Describes a single method to monkey-patch on an SDK prototype. */
|
|
16
25
|
export interface PatchTarget {
|
|
@@ -47,6 +47,7 @@ export declare const GEN_AI_ATTRS: {
|
|
|
47
47
|
readonly COMPLETION: "gen_ai.toad_eye.completion";
|
|
48
48
|
readonly COST: "gen_ai.toad_eye.cost";
|
|
49
49
|
readonly STATUS: "gen_ai.toad_eye.status";
|
|
50
|
+
readonly CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization";
|
|
50
51
|
readonly SESSION_ID: "session.id";
|
|
51
52
|
readonly TEAM: "toad_eye.team";
|
|
52
53
|
readonly USER_ID: "toad_eye.user_id";
|
package/dist/types/attributes.js
CHANGED
|
@@ -53,6 +53,7 @@ export const GEN_AI_ATTRS = {
|
|
|
53
53
|
COMPLETION: "gen_ai.toad_eye.completion",
|
|
54
54
|
COST: "gen_ai.toad_eye.cost",
|
|
55
55
|
STATUS: "gen_ai.toad_eye.status",
|
|
56
|
+
CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization",
|
|
56
57
|
SESSION_ID: "session.id",
|
|
57
58
|
// FinOps attribution
|
|
58
59
|
TEAM: "toad_eye.team",
|
package/dist/types/config.d.ts
CHANGED
|
@@ -47,6 +47,13 @@ export interface ToadEyeConfig {
|
|
|
47
47
|
readonly onBudgetExceeded?: BudgetExceededMode | undefined;
|
|
48
48
|
/** Callback for 'downgrade' mode — receives original provider/model, returns replacement. */
|
|
49
49
|
readonly downgradeCallback?: DowngradeCallback | undefined;
|
|
50
|
+
/** Warn or block when context window utilization exceeds thresholds. */
|
|
51
|
+
readonly contextGuard?: {
|
|
52
|
+
/** Warn when utilization exceeds this ratio (0.0–1.0). Default: none. */
|
|
53
|
+
readonly warnAt?: number | undefined;
|
|
54
|
+
/** Block (throw ToadContextExceededError) when utilization exceeds this ratio. Default: none. */
|
|
55
|
+
readonly blockAt?: number | undefined;
|
|
56
|
+
} | undefined;
|
|
50
57
|
/** Static session ID — all spans will carry this value as `session.id`. */
|
|
51
58
|
readonly sessionId?: string | undefined;
|
|
52
59
|
/** Dynamic session ID extractor — called per traceLLMCall to resolve session ID. */
|
package/dist/types/metrics.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const GEN_AI_METRICS: {
|
|
|
14
14
|
readonly ERRORS: "gen_ai.client.errors";
|
|
15
15
|
readonly AGENT_STEPS_PER_QUERY: "gen_ai.agent.steps_per_query";
|
|
16
16
|
readonly AGENT_TOOL_USAGE: "gen_ai.agent.tool_usage";
|
|
17
|
+
readonly AGENT_TOOL_DURATION: "gen_ai.agent.tool_duration";
|
|
17
18
|
readonly GUARD_EVALUATIONS: "gen_ai.toad_eye.guard.evaluations";
|
|
18
19
|
readonly GUARD_WOULD_BLOCK: "gen_ai.toad_eye.guard.would_block";
|
|
19
20
|
readonly SEMANTIC_DRIFT: "gen_ai.toad_eye.semantic_drift";
|
|
@@ -23,6 +24,8 @@ export declare const GEN_AI_METRICS: {
|
|
|
23
24
|
readonly BUDGET_DOWNGRADED: "gen_ai.toad_eye.budget.downgraded";
|
|
24
25
|
readonly RESPONSE_EMPTY: "gen_ai.toad_eye.response.empty";
|
|
25
26
|
readonly RESPONSE_LATENCY_PER_TOKEN: "gen_ai.toad_eye.response.latency_per_token";
|
|
27
|
+
readonly CONTEXT_UTILIZATION: "gen_ai.toad_eye.context_utilization";
|
|
28
|
+
readonly CONTEXT_BLOCKED: "gen_ai.toad_eye.context.blocked";
|
|
26
29
|
};
|
|
27
30
|
/** @deprecated Use GEN_AI_METRICS instead. Kept for backward compatibility. */
|
|
28
31
|
export declare const LLM_METRICS: {
|