@intentius/chant-lexicon-prometheus 0.99.0 → 0.101.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 +4 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/genai.d.ts +224 -0
- package/dist/composites/genai.d.ts.map +1 -0
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +11 -2
- package/dist/init-templates.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rule-eval.d.ts +10 -4
- package/dist/rule-eval.d.ts.map +1 -1
- package/dist/skills/chant-prometheus.md +22 -0
- package/package.json +3 -2
- package/src/codegen/docs.ts +5 -0
- package/src/composites/catalog.test.ts +1 -1
- package/src/composites/catalog.ts +70 -0
- package/src/composites/genai.test.ts +307 -0
- package/src/composites/genai.ts +678 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/slo-burn.test.ts +25 -1
- package/src/index.ts +14 -0
- package/src/init-templates.test.ts +37 -11
- package/src/init-templates.ts +75 -6
- package/src/plugin.ts +6 -2
- package/src/rule-eval.ts +72 -7
- package/src/skills/chant-prometheus.md +22 -0
- package/src/typecheck.test.ts +88 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GenAiRules` over the otel preset's metrics, for both the preset's own
|
|
3
|
+
* `genai.*` names and the conventions' client metrics (#3041).
|
|
4
|
+
*
|
|
5
|
+
* The cost, ratio and latency rules are evaluated over fixed series with the
|
|
6
|
+
* small evaluator in `rule-eval.ts`. `promtool check rules` runs over the
|
|
7
|
+
* built file when promtool is on PATH (or named by $PROMTOOL).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { genAiMetrics, type GenAiMetrics } from "@intentius/chant-lexicon-otel/genai";
|
|
11
|
+
import { GenAiRules, genAiRuleMetrics, type GenAiPrice, type GenAiRulesProps } from "./genai";
|
|
12
|
+
import { ruleGroupConfig } from "../rules";
|
|
13
|
+
import { emitYaml } from "../build";
|
|
14
|
+
import { RuleEvaluator, type Labels, type FiringAlert } from "../rule-eval";
|
|
15
|
+
import { validateRuleFile } from "../validate-config";
|
|
16
|
+
import { hasTool, promtoolCheckRules } from "../tools";
|
|
17
|
+
import { isAlertingRuleConfig, isRecordingRuleConfig, type RuleGroupConfig } from "../model";
|
|
18
|
+
|
|
19
|
+
const PROMTOOL = process.env.PROMTOOL ?? "promtool";
|
|
20
|
+
const hasPromtool = hasTool(PROMTOOL);
|
|
21
|
+
const MIN = 60_000;
|
|
22
|
+
|
|
23
|
+
const PRICES: GenAiPrice[] = [
|
|
24
|
+
{ provider: "anthropic", model: "m1", inputPerMTok: 3, outputPerMTok: 15, currency: "USD", source: "https://example.com/pricing", asOf: "2026-09-29" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const SOURCES = {
|
|
28
|
+
spans: genAiMetrics(),
|
|
29
|
+
client: genAiMetrics({ clientMetrics: "derive" }),
|
|
30
|
+
} as const satisfies Record<string, GenAiMetrics>;
|
|
31
|
+
|
|
32
|
+
function build(genAi: GenAiMetrics, extra: Partial<GenAiRulesProps> = {}) {
|
|
33
|
+
const rules = GenAiRules({ genAi, prices: PRICES, ...extra });
|
|
34
|
+
return { rules, m: genAiRuleMetrics(rules), group: ruleGroupConfig(rules.rules) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function exprs(group: RuleGroupConfig): string {
|
|
38
|
+
return group.rules.map((r) => r.expr).join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-minute increments of every source series. m1 (anthropic) takes 100
|
|
43
|
+
* requests a minute, 10 of them failing with `timeout`; m2 (openai, not in
|
|
44
|
+
* the price table) takes 60 and never fails. A tool, `search`, is called 10
|
|
45
|
+
* times a minute and fails twice. m1's latency: half its requests under 1s,
|
|
46
|
+
* the rest under 2s.
|
|
47
|
+
*/
|
|
48
|
+
function feed(genAi: GenAiMetrics, source: "spans" | "client"): (ev: RuleEvaluator, minute: number) => void {
|
|
49
|
+
const series: Array<{ labels: Labels; perMin: number }> = [];
|
|
50
|
+
const add = (labels: Labels, perMin: number) => series.push({ labels, perMin });
|
|
51
|
+
const calls = genAi.calls.prometheus;
|
|
52
|
+
const spanBuckets = `${genAi.duration.prometheus}_bucket`;
|
|
53
|
+
|
|
54
|
+
const span = (model: string, provider: string, extra: Labels = {}) => ({
|
|
55
|
+
service_name: "agent",
|
|
56
|
+
span_name: "chat",
|
|
57
|
+
span_kind: "SPAN_KIND_CLIENT",
|
|
58
|
+
status_code: "STATUS_CODE_UNSET",
|
|
59
|
+
gen_ai_operation_name: "chat",
|
|
60
|
+
gen_ai_request_model: model,
|
|
61
|
+
...(provider ? { gen_ai_provider_name: provider } : {}),
|
|
62
|
+
...extra,
|
|
63
|
+
});
|
|
64
|
+
const err = { status_code: "STATUS_CODE_ERROR", error_type: "timeout" };
|
|
65
|
+
// Tool spans come from the span metrics in both modes.
|
|
66
|
+
const tool = { service_name: "agent", span_name: "execute_tool search", span_kind: "SPAN_KIND_INTERNAL", gen_ai_operation_name: "execute_tool", gen_ai_tool_name: "search" };
|
|
67
|
+
add({ __name__: calls, ...tool, status_code: "STATUS_CODE_UNSET" }, 8);
|
|
68
|
+
add({ __name__: calls, ...tool, status_code: "STATUS_CODE_ERROR", error_type: "tool_error" }, 2);
|
|
69
|
+
for (const [le, n] of [["1", 0], ["+Inf", 10]] as const) add({ __name__: spanBuckets, ...tool, status_code: "STATUS_CODE_UNSET", le }, n);
|
|
70
|
+
|
|
71
|
+
if (source === "spans") {
|
|
72
|
+
add({ __name__: calls, ...span("m1", "") }, 90);
|
|
73
|
+
add({ __name__: calls, ...span("m1", "", err) }, 10);
|
|
74
|
+
add({ __name__: calls, ...span("m2", "") }, 60);
|
|
75
|
+
for (const [le, n] of [["1", 50], ["2", 100], ["+Inf", 100]] as const) add({ __name__: spanBuckets, ...span("m1", ""), le }, n);
|
|
76
|
+
add({ __name__: genAi.inputTokens.prometheus, gen_ai_request_model: "m1" }, 6000);
|
|
77
|
+
add({ __name__: genAi.outputTokens.prometheus, gen_ai_request_model: "m1" }, 1200);
|
|
78
|
+
add({ __name__: genAi.inputTokens.prometheus, gen_ai_request_model: "m2" }, 3000);
|
|
79
|
+
add({ __name__: genAi.outputTokens.prometheus, gen_ai_request_model: "m2" }, 600);
|
|
80
|
+
} else {
|
|
81
|
+
const c = genAi.client!;
|
|
82
|
+
const op = (model: string, provider: string, extra: Labels = {}) => ({ gen_ai_operation_name: "chat", gen_ai_provider_name: provider, gen_ai_request_model: model, ...extra });
|
|
83
|
+
add({ __name__: `${c.operationDuration.prometheus}_count`, ...op("m1", "anthropic") }, 90);
|
|
84
|
+
add({ __name__: `${c.operationDuration.prometheus}_count`, ...op("m1", "anthropic", { error_type: "timeout" }) }, 10);
|
|
85
|
+
add({ __name__: `${c.operationDuration.prometheus}_count`, ...op("m2", "openai") }, 60);
|
|
86
|
+
for (const [le, n] of [["1", 50], ["2", 100], ["+Inf", 100]] as const) {
|
|
87
|
+
add({ __name__: `${c.operationDuration.prometheus}_bucket`, ...op("m1", "anthropic"), le }, n);
|
|
88
|
+
}
|
|
89
|
+
const tokens = `${c.tokenUsage.prometheus}_sum`;
|
|
90
|
+
add({ __name__: tokens, ...op("m1", "anthropic"), gen_ai_token_type: "input" }, 6000);
|
|
91
|
+
add({ __name__: tokens, ...op("m1", "anthropic"), gen_ai_token_type: "output" }, 1200);
|
|
92
|
+
add({ __name__: tokens, ...op("m2", "openai"), gen_ai_token_type: "input" }, 3000);
|
|
93
|
+
add({ __name__: tokens, ...op("m2", "openai"), gen_ai_token_type: "output" }, 600);
|
|
94
|
+
}
|
|
95
|
+
return (ev, minute) => {
|
|
96
|
+
for (const s of series) ev.add(s.labels, minute * MIN, s.perMin * minute);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function run(group: RuleGroupConfig, genAi: GenAiMetrics, source: "spans" | "client", minutes = 10) {
|
|
101
|
+
const ev = new RuleEvaluator([group]);
|
|
102
|
+
const push = feed(genAi, source);
|
|
103
|
+
const firing = new Map<number, FiringAlert[]>();
|
|
104
|
+
for (let minute = 0; minute <= minutes; minute++) {
|
|
105
|
+
push(ev, minute);
|
|
106
|
+
firing.set(minute, ev.step(minute * MIN));
|
|
107
|
+
}
|
|
108
|
+
const at = minutes * MIN;
|
|
109
|
+
const one = (expr: string) => {
|
|
110
|
+
const r = ev.query(expr, at);
|
|
111
|
+
expect(r, expr).toHaveLength(1);
|
|
112
|
+
return r[0].value;
|
|
113
|
+
};
|
|
114
|
+
return { ev, at, one, firing };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
describe.each(["spans", "client"] as const)("GenAiRules from the %s metrics", (source) => {
|
|
118
|
+
const genAi = SOURCES[source];
|
|
119
|
+
const { m, group } = build(genAi);
|
|
120
|
+
|
|
121
|
+
test("reads every metric name from GenAiMetrics", () => {
|
|
122
|
+
expect(m.source).toBe(source);
|
|
123
|
+
const all = exprs(group);
|
|
124
|
+
if (source === "client") {
|
|
125
|
+
expect(all).toContain(`${genAi.client!.operationDuration.prometheus}_count`);
|
|
126
|
+
expect(all).toContain(`${genAi.client!.operationDuration.prometheus}_bucket`);
|
|
127
|
+
expect(all).toContain(`${genAi.client!.tokenUsage.prometheus}_sum`);
|
|
128
|
+
expect(all).not.toContain(genAi.inputTokens.prometheus);
|
|
129
|
+
} else {
|
|
130
|
+
expect(all).toContain(genAi.calls.prometheus);
|
|
131
|
+
expect(all).toContain(`${genAi.duration.prometheus}_bucket`);
|
|
132
|
+
expect(all).toContain(genAi.inputTokens.prometheus);
|
|
133
|
+
expect(all).toContain(genAi.outputTokens.prometheus);
|
|
134
|
+
}
|
|
135
|
+
// The tool rules read the span metrics either way.
|
|
136
|
+
expect(all).toContain(`rate(${genAi.calls.prometheus}{gen_ai_tool_name!=""}`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a collector namespace moves the span-metric names", () => {
|
|
140
|
+
const other = genAiMetrics({ namespace: "agents", ...(source === "client" ? { clientMetrics: "derive" as const } : {}) });
|
|
141
|
+
const all = exprs(build(other).group);
|
|
142
|
+
expect(all).toContain("agents_calls_total");
|
|
143
|
+
expect(all).not.toContain("genai_calls_total");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("builds no alerts unless asked", () => {
|
|
147
|
+
expect(group.rules.every((r) => isRecordingRuleConfig(r))).toBe(true);
|
|
148
|
+
expect(m.alerts).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the rule file passes the lexicon's own checks", () => {
|
|
152
|
+
const { group: withAlerts } = build(genAi, {
|
|
153
|
+
alerts: { errorRatio: true, latency: true, toolErrorRatio: true, budgets: [{ amount: 10, currency: "USD", per: "hour" }, { amount: 100, currency: "USD", per: "day" }] },
|
|
154
|
+
prices: [...PRICES, { provider: "anthropic", model: "m3", inputPerMTok: 1, outputPerMTok: 5, currency: "USD", source: "https://example.com/pricing" }],
|
|
155
|
+
});
|
|
156
|
+
expect(validateRuleFile({ groups: [withAlerts] })).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test.skipIf(!hasPromtool)("promtool check rules passes", () => {
|
|
160
|
+
const { group: withAlerts } = build(genAi, { alerts: { errorRatio: true, latency: true, toolErrorRatio: true, budgets: [{ amount: 10, currency: "USD", per: "day" }] } });
|
|
161
|
+
const r = promtoolCheckRules(emitYaml({ groups: [withAlerts] }), PROMTOOL);
|
|
162
|
+
expect(r.ran).toBe(true);
|
|
163
|
+
expect(r.ok, r.output).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("ratio, latency, token and cost rules over fixed series (rule-eval)", () => {
|
|
167
|
+
const { one, ev, at } = run(group, genAi, source);
|
|
168
|
+
const p = source === "client" ? 'gen_ai_provider_name="anthropic", ' : "";
|
|
169
|
+
expect(one(`${m.requests}{${p}gen_ai_request_model="m1"}`)).toBeCloseTo(100 / 60, 9);
|
|
170
|
+
expect(one(`${m.errorRatio}{gen_ai_request_model="m1"}`)).toBeCloseTo(0.1, 9);
|
|
171
|
+
// No errors is a ratio of 0, not a missing series.
|
|
172
|
+
expect(one(`${m.errorRatio}{gen_ai_request_model="m2"}`)).toBe(0);
|
|
173
|
+
expect(one(`${m.errorRatioByType}{gen_ai_request_model="m1", error_type="timeout"}`)).toBeCloseTo(0.1, 9);
|
|
174
|
+
expect(one(`${m.latency.record}{gen_ai_request_model="m1", quantile="0.5"}`)).toBeCloseTo(1, 9);
|
|
175
|
+
expect(one(`${m.latency.record}{gen_ai_request_model="m1", quantile="0.95"}`)).toBeCloseTo(1.9, 9);
|
|
176
|
+
// Tool spans have no model, so they are not model requests.
|
|
177
|
+
expect(ev.query(`${m.requests}{gen_ai_operation_name="execute_tool"}`, at)).toEqual([]);
|
|
178
|
+
expect(one(`${m.tokens}{gen_ai_request_model="m1", gen_ai_token_type="input"}`)).toBeCloseTo(100, 9);
|
|
179
|
+
expect(one(`${m.tokens}{gen_ai_request_model="m1", gen_ai_token_type="output"}`)).toBeCloseTo(20, 9);
|
|
180
|
+
// 100 input tokens/s at 3 per million, 20 output tokens/s at 15 per million.
|
|
181
|
+
expect(one(`${m.cost}{gen_ai_token_type="input"}`)).toBeCloseTo(0.0003, 12);
|
|
182
|
+
expect(one(`${m.cost}{gen_ai_token_type="output"}`)).toBeCloseTo(0.0003, 12);
|
|
183
|
+
const cost = ev.query(m.cost!, at);
|
|
184
|
+
expect(cost.map((e) => e.labels)).toEqual([
|
|
185
|
+
expect.objectContaining({ gen_ai_provider_name: "anthropic", gen_ai_request_model: "m1", currency: "USD", gen_ai_token_type: "input" }),
|
|
186
|
+
expect.objectContaining({ gen_ai_provider_name: "anthropic", gen_ai_request_model: "m1", currency: "USD", gen_ai_token_type: "output" }),
|
|
187
|
+
]);
|
|
188
|
+
expect(one(`${m.tool!.calls}{gen_ai_tool_name="search"}`)).toBeCloseTo(10 / 60, 9);
|
|
189
|
+
expect(one(`${m.tool!.errorRatio}{gen_ai_tool_name="search"}`)).toBeCloseTo(0.2, 9);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a model missing from the price table gets no cost series, not a cost of zero", () => {
|
|
193
|
+
const { ev, at } = run(group, genAi, source);
|
|
194
|
+
// m2 has tokens...
|
|
195
|
+
expect(ev.query(`${m.tokens}{gen_ai_request_model="m2"}`, at)).toHaveLength(2);
|
|
196
|
+
// ...and no cost.
|
|
197
|
+
expect(ev.query(`${m.cost}{gen_ai_request_model="m2"}`, at)).toEqual([]);
|
|
198
|
+
expect(group.rules.filter((r) => isRecordingRuleConfig(r) && r.record === m.cost)).toHaveLength(1);
|
|
199
|
+
// No prices at all: no cost rule.
|
|
200
|
+
const bare = build(genAi, { prices: [] });
|
|
201
|
+
expect(bare.m.cost).toBeUndefined();
|
|
202
|
+
expect(exprs(bare.group)).not.toContain(":cost:");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("alerts fire over their thresholds and not under them (rule-eval)", () => {
|
|
206
|
+
const { group: g } = build(genAi, {
|
|
207
|
+
alerts: {
|
|
208
|
+
errorRatio: { threshold: 0.05, for: "2m" },
|
|
209
|
+
latency: { thresholdSeconds: 1.5, for: "2m" },
|
|
210
|
+
toolErrorRatio: { threshold: 0.25 },
|
|
211
|
+
// m1 costs 0.0006 a second: 2.16 an hour, 51.84 a day at that rate.
|
|
212
|
+
budgets: [
|
|
213
|
+
{ amount: 2, currency: "USD", per: "hour", severity: "page" },
|
|
214
|
+
{ amount: 60, currency: "USD", per: "day" },
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
const { firing } = run(g, genAi, source);
|
|
219
|
+
const last = firing.get(10)!;
|
|
220
|
+
const names = (xs: FiringAlert[]) => xs.map((a) => `${a.labels.alertname}${a.labels.budget ? `/${a.labels.budget}` : ""}${a.labels.gen_ai_request_model ? `/${a.labels.gen_ai_request_model}` : ""}`).sort();
|
|
221
|
+
expect(names(last)).toEqual(["GenAiErrorRatioHigh/m1", "GenAiLatencyHigh/m1", "GenAiSpendOverBudget/hour"]);
|
|
222
|
+
const spend = last.find((a) => a.labels.alertname === "GenAiSpendOverBudget")!;
|
|
223
|
+
expect(spend.value).toBeCloseTo(2.16, 9);
|
|
224
|
+
expect(spend.labels).toMatchObject({ severity: "page", currency: "USD", budget: "hour" });
|
|
225
|
+
// Not before `for` has passed.
|
|
226
|
+
expect(names(firing.get(2)!)).not.toContain("GenAiErrorRatioHigh/m1");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("GenAiRules options", () => {
|
|
231
|
+
test("source defaults to client when the metrics have it, and spans can be chosen", () => {
|
|
232
|
+
expect(build(SOURCES.client).m.source).toBe("client");
|
|
233
|
+
expect(build(SOURCES.client, { source: "spans" }).m.source).toBe("spans");
|
|
234
|
+
expect(() => build(SOURCES.spans, { source: "client" })).toThrow(/clientMetrics/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("span metrics without provider: cost series take the provider from the price", () => {
|
|
238
|
+
const { m, group } = build(SOURCES.spans);
|
|
239
|
+
expect(m.labels.provider).toBeUndefined();
|
|
240
|
+
const cost = group.rules.find((r) => isRecordingRuleConfig(r) && r.record === m.cost)!;
|
|
241
|
+
expect(cost.labels).toEqual({ gen_ai_provider_name: "anthropic", gen_ai_request_model: "m1", currency: "USD" });
|
|
242
|
+
expect(cost.expr).not.toContain("gen_ai_provider_name");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("providerDimensions puts the provider on the model series", () => {
|
|
246
|
+
const { m } = build(genAiMetrics({ providerDimensions: true }));
|
|
247
|
+
expect(m.modelLabels).toEqual(["gen_ai_provider_name", "gen_ai_request_model", "gen_ai_operation_name"]);
|
|
248
|
+
// The token sums still carry the model alone.
|
|
249
|
+
expect(m.tokenLabels).toEqual(["gen_ai_request_model"]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("prefix, window, groupBy, name, labels and interval", () => {
|
|
253
|
+
const { m, group } = build(SOURCES.client, { prefix: "llm", rateWindow: "2m", groupBy: ["job"], name: "llm-rules", labels: { team: "ai" }, interval: "1m" });
|
|
254
|
+
expect(m.requests).toBe("llm:requests:rate2m");
|
|
255
|
+
expect(m.modelLabels.at(-1)).toBe("job");
|
|
256
|
+
expect(group.name).toBe("llm-rules");
|
|
257
|
+
expect(group.labels).toEqual({ team: "ai" });
|
|
258
|
+
expect(group.interval).toBe("1m");
|
|
259
|
+
expect(exprs(group)).toContain("[2m]");
|
|
260
|
+
expect(exprs(group)).toContain("sum by (gen_ai_tool_name, job)");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("genAiRuleMetrics reads the instance, its group or the props alike", () => {
|
|
264
|
+
const props: GenAiRulesProps = { genAi: SOURCES.client, prices: PRICES, alerts: { errorRatio: true } };
|
|
265
|
+
const inst = GenAiRules(props);
|
|
266
|
+
expect(genAiRuleMetrics(inst.rules)).toEqual(genAiRuleMetrics(inst));
|
|
267
|
+
expect(genAiRuleMetrics(props)).toEqual(genAiRuleMetrics(inst));
|
|
268
|
+
expect(genAiRuleMetrics(inst).alerts).toEqual([{ alert: "GenAiErrorRatioHigh", kind: "errorRatio", severity: "warning", threshold: 0.05 }]);
|
|
269
|
+
expect(genAiRuleMetrics(inst).prices).toEqual([{ provider: "anthropic", model: "m1", currency: "USD", source: "https://example.com/pricing", asOf: "2026-09-29" }]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("genAiComponents()-style input ({ metrics }) is accepted", () => {
|
|
273
|
+
expect(build({ metrics: SOURCES.spans } as unknown as GenAiMetrics).m.source).toBe("spans");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test.each<[string, Partial<GenAiRulesProps>, RegExp]>([
|
|
277
|
+
["currency missing", { prices: [{ ...PRICES[0], currency: "" }] }, /prices\[0\]\.currency is required/],
|
|
278
|
+
["source missing", { prices: [{ ...PRICES[0], source: undefined as unknown as string }] }, /prices\[0\]\.source is required/],
|
|
279
|
+
["negative price", { prices: [{ ...PRICES[0], inputPerMTok: -1 }] }, /inputPerMTok/],
|
|
280
|
+
["bad asOf", { prices: [{ ...PRICES[0], asOf: "Sept 29" }] }, /asOf/],
|
|
281
|
+
["same model twice", { prices: [PRICES[0], { ...PRICES[0] }] }, /again/],
|
|
282
|
+
["budget in a currency nothing is priced in", { alerts: { budgets: [{ amount: 1, currency: "EUR", per: "day" }] } }, /currency of no price/],
|
|
283
|
+
["two budgets for one window and currency", { alerts: { budgets: [{ amount: 1, currency: "USD", per: "day" }, { amount: 2, currency: "USD", per: "day" }] } }, /second budget/],
|
|
284
|
+
["ratio threshold of 1", { alerts: { errorRatio: { threshold: 1 } } }, /threshold/],
|
|
285
|
+
["bad for", { alerts: { latency: { for: "ten minutes" } } }, /for/],
|
|
286
|
+
["bad prefix", { prefix: "gen-ai" }, /prefix/],
|
|
287
|
+
["bad window", { rateWindow: "5 minutes" }, /rateWindow/],
|
|
288
|
+
])("throws on %s", (_, extra, message) => {
|
|
289
|
+
expect(() => build(SOURCES.client, extra)).toThrow(message);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("span metrics price one model per provider only when they carry no provider", () => {
|
|
293
|
+
const twice: GenAiPrice[] = [PRICES[0], { ...PRICES[0], provider: "bedrock" }];
|
|
294
|
+
expect(() => build(SOURCES.spans, { prices: twice })).toThrow(/no provider to tell them apart/);
|
|
295
|
+
expect(() => build(SOURCES.client, { prices: twice })).not.toThrow();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("every alert carries a severity and a summary", () => {
|
|
299
|
+
const { group } = build(SOURCES.client, { alerts: { errorRatio: true, latency: true, toolErrorRatio: true, budgets: [{ amount: 1, currency: "USD", per: "day" }] } });
|
|
300
|
+
const alerts = group.rules.filter(isAlertingRuleConfig);
|
|
301
|
+
expect(alerts).toHaveLength(4);
|
|
302
|
+
for (const a of alerts) {
|
|
303
|
+
expect(a.labels?.severity).toBeTruthy();
|
|
304
|
+
expect(a.annotations?.summary).toBeTruthy();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|