@oneuptime/common 12.0.24 → 12.0.25
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/Models/AnalyticsModels/Span.ts +101 -0
- package/Server/API/BaseAPI.ts +0 -24
- package/Server/API/SlackAPI.ts +0 -2
- package/Server/Middleware/SlackAuthorization.ts +96 -18
- package/Server/Utils/Telemetry/LlmMetricSpend.ts +56 -5
- package/Server/Utils/Telemetry/LlmSpan.ts +46 -0
- package/Server/Utils/Workspace/Slack/Actions/Auth.ts +0 -12
- package/Tests/App/Dashboard/LlmCallsTableIdentity.test.tsx +322 -0
- package/Tests/App/Dashboard/LlmOverview.test.tsx +335 -0
- package/Tests/App/Dashboard/LlmSpanDisplay.test.ts +391 -0
- package/Tests/App/Dashboard/LlmUsageBreakdown.test.tsx +1007 -0
- package/Tests/Server/API/BaseAPI.test.ts +41 -0
- package/Tests/Server/API/BaseAPIUpdatePayloadValidation.test.ts +9 -16
- package/Tests/Server/Middleware/SlackAuthorization.test.ts +262 -5
- package/Tests/Server/Utils/Telemetry/LlmCostBudgetEvaluator.test.ts +37 -18
- package/Tests/Server/Utils/Telemetry/LlmMetricSpend.test.ts +143 -2
- package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +804 -0
- package/Tests/Types/Telemetry/LlmMetricConventions.test.ts +391 -0
- package/Tests/Utils/Telemetry/LlmMetricQuery.test.ts +298 -0
- package/Types/Telemetry/LlmConventions.ts +255 -0
- package/Types/Telemetry/LlmMetricConventions.ts +212 -7
- package/Utils/Telemetry/LlmMetricQuery.ts +83 -0
- package/build/dist/Models/AnalyticsModels/Span.js +89 -0
- package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
- package/build/dist/Server/API/BaseAPI.js +3 -19
- package/build/dist/Server/API/BaseAPI.js.map +1 -1
- package/build/dist/Server/API/SlackAPI.js +0 -2
- package/build/dist/Server/API/SlackAPI.js.map +1 -1
- package/build/dist/Server/Middleware/SlackAuthorization.js +58 -7
- package/build/dist/Server/Middleware/SlackAuthorization.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js +52 -3
- package/build/dist/Server/Utils/Telemetry/LlmMetricSpend.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js +24 -1
- package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -1
- package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js +0 -10
- package/build/dist/Server/Utils/Workspace/Slack/Actions/Auth.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmConventions.js +236 -0
- package/build/dist/Types/Telemetry/LlmConventions.js.map +1 -1
- package/build/dist/Types/Telemetry/LlmMetricConventions.js +197 -5
- package/build/dist/Types/Telemetry/LlmMetricConventions.js.map +1 -1
- package/build/dist/Utils/Telemetry/LlmMetricQuery.js +57 -1
- package/build/dist/Utils/Telemetry/LlmMetricQuery.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import {
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
jest,
|
|
8
|
+
test,
|
|
9
|
+
} from "@jest/globals";
|
|
10
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import { MemoryRouter } from "react-router-dom";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* ---------------------------------------------------------------------------
|
|
16
|
+
* The LLM calls table's employee columns and filters
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
*
|
|
19
|
+
* "Which of our engineers spent this?" is the question this table exists to
|
|
20
|
+
* answer, and everything that answers it is configuration passed as props:
|
|
21
|
+
* the User column and its email→id fallback, the Team column, the three text
|
|
22
|
+
* filters, and the llmUserId entry in selectMoreFields. Dropping any of them
|
|
23
|
+
* type-checks and renders — the table simply stops answering the question, or
|
|
24
|
+
* answers it with an empty cell for half the fleet.
|
|
25
|
+
*
|
|
26
|
+
* So the table itself is mocked to capture its props, and the captured
|
|
27
|
+
* getElement closure is then rendered directly. That also keeps this test off
|
|
28
|
+
* the network: the real AnalyticsModelTable fetches on mount.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
type CapturedColumn = {
|
|
32
|
+
field: Record<string, boolean>;
|
|
33
|
+
title: string;
|
|
34
|
+
isHiddenByDefault?: boolean | undefined;
|
|
35
|
+
getElement?: ((item: Span) => React.ReactElement) | undefined;
|
|
36
|
+
getExportValue?: ((item: Span) => string) | undefined;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type CapturedFilter = {
|
|
40
|
+
field: Record<string, boolean>;
|
|
41
|
+
title: string;
|
|
42
|
+
type: unknown;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type CapturedTableProps = {
|
|
46
|
+
columns?: Array<CapturedColumn>;
|
|
47
|
+
filters?: Array<CapturedFilter>;
|
|
48
|
+
selectMoreFields?: Record<string, boolean>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let capturedTableProps: CapturedTableProps | null = null;
|
|
52
|
+
|
|
53
|
+
jest.mock("../../../UI/Components/ModelTable/AnalyticsModelTable", () => {
|
|
54
|
+
return {
|
|
55
|
+
__esModule: true,
|
|
56
|
+
default: (props: CapturedTableProps) => {
|
|
57
|
+
capturedTableProps = props;
|
|
58
|
+
return null;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
* The table loads the project's telemetry services on mount purely to colour
|
|
65
|
+
* the Service cell. Stubbed to an immediately-resolving empty list so no
|
|
66
|
+
* request escapes and no act() warning follows the resolve.
|
|
67
|
+
*/
|
|
68
|
+
jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
|
|
69
|
+
return {
|
|
70
|
+
__esModule: true,
|
|
71
|
+
default: {
|
|
72
|
+
getList: () => {
|
|
73
|
+
return Promise.resolve({ data: [], count: 0, skip: 0, limit: 0 });
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
import LlmCallsTable from "../../../../App/FeatureSet/Dashboard/src/Components/AI/LlmCallsTable";
|
|
80
|
+
import Span from "../../../Models/AnalyticsModels/Span";
|
|
81
|
+
import FieldType from "../../../UI/Components/Types/FieldType";
|
|
82
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
83
|
+
import ProjectUtil from "../../../UI/Utils/Project";
|
|
84
|
+
|
|
85
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
86
|
+
"11111111-1111-4111-8111-111111111111",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
type ColumnByTitleFunction = (title: string) => CapturedColumn;
|
|
90
|
+
|
|
91
|
+
const columnByTitle: ColumnByTitleFunction = (
|
|
92
|
+
title: string,
|
|
93
|
+
): CapturedColumn => {
|
|
94
|
+
const column: CapturedColumn | undefined = capturedTableProps?.columns?.find(
|
|
95
|
+
(candidate: CapturedColumn): boolean => {
|
|
96
|
+
return candidate.title === title;
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(column).toBeDefined();
|
|
101
|
+
|
|
102
|
+
return column!;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type FilterByTitleFunction = (title: string) => CapturedFilter;
|
|
106
|
+
|
|
107
|
+
const filterByTitle: FilterByTitleFunction = (
|
|
108
|
+
title: string,
|
|
109
|
+
): CapturedFilter => {
|
|
110
|
+
const filter: CapturedFilter | undefined = capturedTableProps?.filters?.find(
|
|
111
|
+
(candidate: CapturedFilter): boolean => {
|
|
112
|
+
return candidate.title === title;
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(filter).toBeDefined();
|
|
117
|
+
|
|
118
|
+
return filter!;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type MakeSpanFunction = (data: {
|
|
122
|
+
llmUserEmail?: string | undefined;
|
|
123
|
+
llmUserId?: string | undefined;
|
|
124
|
+
llmTeam?: string | undefined;
|
|
125
|
+
}) => Span;
|
|
126
|
+
|
|
127
|
+
const makeSpan: MakeSpanFunction = (data: {
|
|
128
|
+
llmUserEmail?: string | undefined;
|
|
129
|
+
llmUserId?: string | undefined;
|
|
130
|
+
llmTeam?: string | undefined;
|
|
131
|
+
}): Span => {
|
|
132
|
+
const span: Span = new Span();
|
|
133
|
+
|
|
134
|
+
span.llmUserEmail = data.llmUserEmail;
|
|
135
|
+
span.llmUserId = data.llmUserId;
|
|
136
|
+
span.llmTeam = data.llmTeam;
|
|
137
|
+
|
|
138
|
+
return span;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
type RenderTableFunction = () => Promise<void>;
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* Awaited inside act(): the component loads telemetry services on mount and
|
|
145
|
+
* setStates when that promise resolves, so an un-awaited render leaves a
|
|
146
|
+
* state update escaping the test.
|
|
147
|
+
*/
|
|
148
|
+
const renderTable: RenderTableFunction = async (): Promise<void> => {
|
|
149
|
+
await act(async (): Promise<void> => {
|
|
150
|
+
render(
|
|
151
|
+
<MemoryRouter>
|
|
152
|
+
<LlmCallsTable />
|
|
153
|
+
</MemoryRouter>,
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(capturedTableProps).not.toBeNull();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
describe("LlmCallsTable — employee columns", () => {
|
|
161
|
+
beforeEach(() => {
|
|
162
|
+
capturedTableProps = null;
|
|
163
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
cleanup();
|
|
168
|
+
jest.restoreAllMocks();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("User and Team are shown by default", async () => {
|
|
172
|
+
await renderTable();
|
|
173
|
+
|
|
174
|
+
expect(columnByTitle("User").isHiddenByDefault).toBeFalsy();
|
|
175
|
+
expect(columnByTitle("Team").isHiddenByDefault).toBeFalsy();
|
|
176
|
+
expect(columnByTitle("Team").field).toEqual({ llmTeam: true });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("the two identity columns did not widen the default table", async () => {
|
|
180
|
+
/*
|
|
181
|
+
* Provider and Operation moved into the column picker to pay for User and
|
|
182
|
+
* Team. Pinned because it is a deliberate trade rather than an accident:
|
|
183
|
+
* un-hiding them without hiding something else silently pushes cost and
|
|
184
|
+
* status off the side of a laptop screen.
|
|
185
|
+
*/
|
|
186
|
+
await renderTable();
|
|
187
|
+
|
|
188
|
+
expect(columnByTitle("Provider").isHiddenByDefault).toBe(true);
|
|
189
|
+
expect(columnByTitle("Operation").isHiddenByDefault).toBe(true);
|
|
190
|
+
|
|
191
|
+
const visibleTitles: Array<string> = (capturedTableProps?.columns || [])
|
|
192
|
+
.filter((column: CapturedColumn): boolean => {
|
|
193
|
+
return !column.isHiddenByDefault;
|
|
194
|
+
})
|
|
195
|
+
.map((column: CapturedColumn): string => {
|
|
196
|
+
return column.title;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(visibleTitles).toEqual([
|
|
200
|
+
"Seen At",
|
|
201
|
+
"Service",
|
|
202
|
+
"Model",
|
|
203
|
+
"User",
|
|
204
|
+
"Team",
|
|
205
|
+
"Tokens (in / out)",
|
|
206
|
+
"Cost",
|
|
207
|
+
"Status",
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("the User cell prefers the email", async () => {
|
|
212
|
+
await renderTable();
|
|
213
|
+
|
|
214
|
+
const column: CapturedColumn = columnByTitle("User");
|
|
215
|
+
|
|
216
|
+
render(
|
|
217
|
+
column.getElement!(
|
|
218
|
+
makeSpan({ llmUserEmail: "ada@example.com", llmUserId: "acct-9f2" }),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect(screen.getByText("ada@example.com")).toBeInTheDocument();
|
|
223
|
+
expect(screen.queryByText("acct-9f2")).not.toBeInTheDocument();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("the User cell falls back to the id when no email was reported", async () => {
|
|
227
|
+
/*
|
|
228
|
+
* The gateway population: LiteLLM stamps a key-owner id and no email. If
|
|
229
|
+
* the fallback were dropped, this whole class of emitter would render an
|
|
230
|
+
* empty User column while the table still filtered on it.
|
|
231
|
+
*/
|
|
232
|
+
await renderTable();
|
|
233
|
+
|
|
234
|
+
render(
|
|
235
|
+
columnByTitle("User").getElement!(makeSpan({ llmUserId: "acct-9f2" })),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(screen.getByText("acct-9f2")).toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("the User cell reads as absent, not as a nameless person", async () => {
|
|
242
|
+
await renderTable();
|
|
243
|
+
|
|
244
|
+
// Whitespace is what an unset environment variable produces at the emitter.
|
|
245
|
+
render(
|
|
246
|
+
columnByTitle("User").getElement!(makeSpan({ llmUserEmail: " " })),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(screen.getByText("—")).toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("the CSV export carries the same fallback the cell renders", async () => {
|
|
253
|
+
/*
|
|
254
|
+
* An Element column exports its declared field's raw value unless it says
|
|
255
|
+
* otherwise, so without getExportValue the export would drop exactly the
|
|
256
|
+
* rows whose identity came from the id.
|
|
257
|
+
*/
|
|
258
|
+
await renderTable();
|
|
259
|
+
|
|
260
|
+
const column: CapturedColumn = columnByTitle("User");
|
|
261
|
+
|
|
262
|
+
expect(column.getExportValue!(makeSpan({ llmUserId: "acct-9f2" }))).toBe(
|
|
263
|
+
"acct-9f2",
|
|
264
|
+
);
|
|
265
|
+
expect(
|
|
266
|
+
column.getExportValue!(makeSpan({ llmUserEmail: "ada@example.com" })),
|
|
267
|
+
).toBe("ada@example.com");
|
|
268
|
+
expect(column.getExportValue!(makeSpan({}))).toBe("");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("llmUserId is selected even though no column declares it", async () => {
|
|
272
|
+
/*
|
|
273
|
+
* The User column declares llmUserEmail; the fallback reads a field
|
|
274
|
+
* nothing else asks for, so it has to be requested explicitly.
|
|
275
|
+
*/
|
|
276
|
+
await renderTable();
|
|
277
|
+
|
|
278
|
+
expect(capturedTableProps?.selectMoreFields?.["llmUserId"]).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("LlmCallsTable — employee filters", () => {
|
|
283
|
+
beforeEach(() => {
|
|
284
|
+
capturedTableProps = null;
|
|
285
|
+
jest.spyOn(ProjectUtil, "getCurrentProjectId").mockReturnValue(PROJECT_ID);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
cleanup();
|
|
290
|
+
jest.restoreAllMocks();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("email, id and team are free-text filters", async () => {
|
|
294
|
+
/*
|
|
295
|
+
* Text rather than a dropdown: the set of people who have made an LLM
|
|
296
|
+
* call is not a bounded list the page can fetch upfront, unlike services.
|
|
297
|
+
*/
|
|
298
|
+
await renderTable();
|
|
299
|
+
|
|
300
|
+
expect(filterByTitle("User Email").field).toEqual({ llmUserEmail: true });
|
|
301
|
+
expect(filterByTitle("User Email").type).toBe(FieldType.Text);
|
|
302
|
+
|
|
303
|
+
expect(filterByTitle("User ID").field).toEqual({ llmUserId: true });
|
|
304
|
+
expect(filterByTitle("User ID").type).toBe(FieldType.Text);
|
|
305
|
+
|
|
306
|
+
expect(filterByTitle("Team").field).toEqual({ llmTeam: true });
|
|
307
|
+
expect(filterByTitle("Team").type).toBe(FieldType.Text);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("email and id are separate filters, not one merged input", async () => {
|
|
311
|
+
/*
|
|
312
|
+
* A filter narrows ONE stored column and most emitters populate exactly
|
|
313
|
+
* one of the two, so a single merged "User" input would silently return
|
|
314
|
+
* nothing for whichever half of the fleet reports the other spelling.
|
|
315
|
+
*/
|
|
316
|
+
await renderTable();
|
|
317
|
+
|
|
318
|
+
expect(filterByTitle("User Email").field).not.toEqual(
|
|
319
|
+
filterByTitle("User ID").field,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
jest,
|
|
7
|
+
test,
|
|
8
|
+
} from "@jest/globals";
|
|
9
|
+
import "@testing-library/jest-dom";
|
|
10
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
import { MemoryRouter } from "react-router-dom";
|
|
13
|
+
import getJestMockFunction, { MockFunction } from "../../MockType";
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* ---------------------------------------------------------------------------
|
|
17
|
+
* The AI / LLM Overview cost KPI
|
|
18
|
+
* ---------------------------------------------------------------------------
|
|
19
|
+
*
|
|
20
|
+
* The cost tile is the headline number on this page, and it has to reconcile
|
|
21
|
+
* two facts about the metric stream that nothing in a snapshot would reveal:
|
|
22
|
+
*
|
|
23
|
+
* - there are TWO cost streams, in two units. The vendor counters report
|
|
24
|
+
* USD; the OpenAI Codex CLI reports MILLIONTHS of a USD. They can never
|
|
25
|
+
* share a Sum — the unit is unrecoverable after the addition, and a $3
|
|
26
|
+
* turn folded into the USD list would surface as $3,000,000 — so the tile
|
|
27
|
+
* queries both lists and folds them through combineCostTotals, which
|
|
28
|
+
* applies the scale per list. Querying only the USD list (which this tile
|
|
29
|
+
* used to do) makes a Codex-only project read $0 on the Overview while the
|
|
30
|
+
* Usage tab and its cost budgets, which already query both, show the real
|
|
31
|
+
* figure.
|
|
32
|
+
* - spans are authoritative, and the two signals are NEVER summed. An
|
|
33
|
+
* emitter producing both would otherwise have every dollar counted twice.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const aggregateMock: MockFunction = getJestMockFunction();
|
|
37
|
+
const countMock: MockFunction = getJestMockFunction();
|
|
38
|
+
const getCurrentProjectIdMock: MockFunction = getJestMockFunction();
|
|
39
|
+
|
|
40
|
+
jest.mock("../../../UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI", () => {
|
|
41
|
+
return {
|
|
42
|
+
__esModule: true,
|
|
43
|
+
default: {
|
|
44
|
+
aggregate: (...args: Array<unknown>) => {
|
|
45
|
+
return aggregateMock(...args);
|
|
46
|
+
},
|
|
47
|
+
count: (...args: Array<unknown>) => {
|
|
48
|
+
return countMock(...args);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
jest.mock("../../../UI/Utils/Project", () => {
|
|
55
|
+
return {
|
|
56
|
+
__esModule: true,
|
|
57
|
+
default: {
|
|
58
|
+
getCurrentProjectId: (...args: Array<unknown>) => {
|
|
59
|
+
return getCurrentProjectIdMock(...args);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* The recent-calls table below the tiles is a real AnalyticsModelTable that
|
|
67
|
+
* fetches on mount. Stubbed out so this test stays on the KPI row and no
|
|
68
|
+
* request escapes.
|
|
69
|
+
*/
|
|
70
|
+
jest.mock(
|
|
71
|
+
"../../../../App/FeatureSet/Dashboard/src/Components/AI/LlmCallsTable",
|
|
72
|
+
() => {
|
|
73
|
+
return {
|
|
74
|
+
__esModule: true,
|
|
75
|
+
default: () => {
|
|
76
|
+
return null;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
import LlmOverview from "../../../../App/FeatureSet/Dashboard/src/Components/AI/LlmOverview";
|
|
83
|
+
import AggregatedModel from "../../../Types/BaseDatabase/AggregatedModel";
|
|
84
|
+
import AggregatedResult from "../../../Types/BaseDatabase/AggregatedResult";
|
|
85
|
+
import Includes from "../../../Types/BaseDatabase/Includes";
|
|
86
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
87
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
88
|
+
import {
|
|
89
|
+
LlmCostMetricNames,
|
|
90
|
+
LlmMicroUsdCostMetricNames,
|
|
91
|
+
} from "../../../Types/Telemetry/LlmMetricConventions";
|
|
92
|
+
|
|
93
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
94
|
+
"11111111-1111-4111-8111-111111111111",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
interface AggregateCall {
|
|
98
|
+
modelType: { new (): unknown };
|
|
99
|
+
aggregateBy: JSONObject;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type ModelNameFunction = (call: AggregateCall) => string;
|
|
103
|
+
|
|
104
|
+
const modelNameOf: ModelNameFunction = (call: AggregateCall): string => {
|
|
105
|
+
return (call.modelType as unknown as { name: string }).name;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ColumnNameFunction = (call: AggregateCall) => string;
|
|
109
|
+
|
|
110
|
+
const columnOf: ColumnNameFunction = (call: AggregateCall): string => {
|
|
111
|
+
return String(call.aggregateBy["aggregateColumnName"]);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type MetricNamesFunction = (call: AggregateCall) => Array<string>;
|
|
115
|
+
|
|
116
|
+
const metricNamesOf: MetricNamesFunction = (
|
|
117
|
+
call: AggregateCall,
|
|
118
|
+
): Array<string> => {
|
|
119
|
+
const query: JSONObject = call.aggregateBy["query"] as JSONObject;
|
|
120
|
+
const names: Includes = query["name"] as unknown as Includes;
|
|
121
|
+
|
|
122
|
+
return (names?.values as Array<string>) || [];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
type MatchesNameListFunction = (
|
|
126
|
+
call: AggregateCall,
|
|
127
|
+
list: Array<string>,
|
|
128
|
+
) => boolean;
|
|
129
|
+
|
|
130
|
+
const matchesNameList: MatchesNameListFunction = (
|
|
131
|
+
call: AggregateCall,
|
|
132
|
+
list: Array<string>,
|
|
133
|
+
): boolean => {
|
|
134
|
+
return metricNamesOf(call).includes(list[0]!);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type RowFunction = (value: number) => AggregatedModel;
|
|
138
|
+
|
|
139
|
+
const row: RowFunction = (value: number): AggregatedModel => {
|
|
140
|
+
return {
|
|
141
|
+
timestamp: new Date("2026-08-20T00:00:00.000Z"),
|
|
142
|
+
value: value,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
type ResultFunction = (rows: Array<AggregatedModel>) => AggregatedResult;
|
|
147
|
+
|
|
148
|
+
const result: ResultFunction = (
|
|
149
|
+
rows: Array<AggregatedModel>,
|
|
150
|
+
): AggregatedResult => {
|
|
151
|
+
return { data: rows };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type RenderFunction = () => Promise<void>;
|
|
155
|
+
|
|
156
|
+
const renderOverview: RenderFunction = async (): Promise<void> => {
|
|
157
|
+
await act(async () => {
|
|
158
|
+
render(
|
|
159
|
+
<MemoryRouter>
|
|
160
|
+
<LlmOverview />
|
|
161
|
+
</MemoryRouter>,
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* Span figures that keep every tile OTHER than cost on the span stream, so a
|
|
168
|
+
* "from GenAI metrics" hint on screen can only have come from the cost tile.
|
|
169
|
+
* Cost itself is zero — the precondition for the metric fallback.
|
|
170
|
+
*/
|
|
171
|
+
type SpanSumFunction = (call: AggregateCall) => AggregatedResult | null;
|
|
172
|
+
|
|
173
|
+
const spanSumsWithZeroCost: SpanSumFunction = (
|
|
174
|
+
call: AggregateCall,
|
|
175
|
+
): AggregatedResult | null => {
|
|
176
|
+
if (modelNameOf(call) !== "Span") {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (columnOf(call) === "llmInputTokens") {
|
|
181
|
+
return result([row(120)]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (columnOf(call) === "llmOutputTokens") {
|
|
185
|
+
return result([row(30)]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// llmCost — the span stream reports no spend at all.
|
|
189
|
+
return result([]);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
aggregateMock.mockReset();
|
|
194
|
+
countMock.mockReset();
|
|
195
|
+
getCurrentProjectIdMock.mockReset();
|
|
196
|
+
|
|
197
|
+
getCurrentProjectIdMock.mockReturnValue(PROJECT_ID);
|
|
198
|
+
countMock.mockResolvedValue(7 as never);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
afterEach(() => {
|
|
202
|
+
cleanup();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("LlmOverview - the metric-sourced cost tile", () => {
|
|
206
|
+
test("folds micro-USD cost metrics, so a Codex-only project does not read $0", async () => {
|
|
207
|
+
aggregateMock.mockImplementation((call: unknown) => {
|
|
208
|
+
const aggregateCall: AggregateCall = call as AggregateCall;
|
|
209
|
+
|
|
210
|
+
const spanResult: AggregatedResult | null =
|
|
211
|
+
spanSumsWithZeroCost(aggregateCall);
|
|
212
|
+
|
|
213
|
+
if (spanResult) {
|
|
214
|
+
return Promise.resolve(spanResult);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (matchesNameList(aggregateCall, LlmMicroUsdCostMetricNames)) {
|
|
218
|
+
// 1,500,000 millionths of a dollar is $1.50, not $1,500,000.
|
|
219
|
+
return Promise.resolve(result([row(1500000)]));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The USD cost list and the token list have nothing for this project.
|
|
223
|
+
return Promise.resolve(result([]));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
await renderOverview();
|
|
227
|
+
|
|
228
|
+
expect(screen.getByText("$1.5000")).toBeInTheDocument();
|
|
229
|
+
expect(screen.queryByText("$0.0000")).not.toBeInTheDocument();
|
|
230
|
+
expect(screen.getAllByText("from GenAI metrics").length).toBeGreaterThan(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("adds the USD and micro-USD streams after scaling each, never before", async () => {
|
|
234
|
+
aggregateMock.mockImplementation((call: unknown) => {
|
|
235
|
+
const aggregateCall: AggregateCall = call as AggregateCall;
|
|
236
|
+
|
|
237
|
+
const spanResult: AggregatedResult | null =
|
|
238
|
+
spanSumsWithZeroCost(aggregateCall);
|
|
239
|
+
|
|
240
|
+
if (spanResult) {
|
|
241
|
+
return Promise.resolve(spanResult);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (matchesNameList(aggregateCall, LlmMicroUsdCostMetricNames)) {
|
|
245
|
+
return Promise.resolve(result([row(1500000)]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (matchesNameList(aggregateCall, LlmCostMetricNames)) {
|
|
249
|
+
return Promise.resolve(result([row(2)]));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return Promise.resolve(result([]));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await renderOverview();
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
* $2.00 + $1.50. Summing the raw values first would read as $1,500,002 —
|
|
259
|
+
* which is exactly what a shared Sum over both name lists would produce.
|
|
260
|
+
*/
|
|
261
|
+
expect(screen.getByText("$3.5000")).toBeInTheDocument();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("queries BOTH cost name lists, not just the USD one", async () => {
|
|
265
|
+
aggregateMock.mockImplementation((call: unknown) => {
|
|
266
|
+
const aggregateCall: AggregateCall = call as AggregateCall;
|
|
267
|
+
|
|
268
|
+
const spanResult: AggregatedResult | null =
|
|
269
|
+
spanSumsWithZeroCost(aggregateCall);
|
|
270
|
+
|
|
271
|
+
return Promise.resolve(spanResult || result([]));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await renderOverview();
|
|
275
|
+
|
|
276
|
+
const metricCostCalls: Array<AggregateCall> = aggregateMock.mock.calls
|
|
277
|
+
.map((args: Array<unknown>): AggregateCall => {
|
|
278
|
+
return args[0] as AggregateCall;
|
|
279
|
+
})
|
|
280
|
+
.filter((candidate: AggregateCall): boolean => {
|
|
281
|
+
return modelNameOf(candidate) === "Metric";
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
expect(
|
|
285
|
+
metricCostCalls.some((candidate: AggregateCall): boolean => {
|
|
286
|
+
return matchesNameList(candidate, LlmCostMetricNames);
|
|
287
|
+
}),
|
|
288
|
+
).toBe(true);
|
|
289
|
+
|
|
290
|
+
expect(
|
|
291
|
+
metricCostCalls.some((candidate: AggregateCall): boolean => {
|
|
292
|
+
return matchesNameList(candidate, LlmMicroUsdCostMetricNames);
|
|
293
|
+
}),
|
|
294
|
+
).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("never sums spans and metrics: a project with span cost reads span cost only", async () => {
|
|
298
|
+
aggregateMock.mockImplementation((call: unknown) => {
|
|
299
|
+
const aggregateCall: AggregateCall = call as AggregateCall;
|
|
300
|
+
|
|
301
|
+
if (modelNameOf(aggregateCall) === "Metric") {
|
|
302
|
+
/*
|
|
303
|
+
* $9 of Codex spend sitting in the metric stream. Summed with the
|
|
304
|
+
* $4 of span cost it would surface as $13 — every dollar twice.
|
|
305
|
+
*/
|
|
306
|
+
return Promise.resolve(result([row(9000000)]));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (columnOf(aggregateCall) === "llmCost") {
|
|
310
|
+
return Promise.resolve(result([row(4)]));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (columnOf(aggregateCall) === "llmInputTokens") {
|
|
314
|
+
return Promise.resolve(result([row(120)]));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return Promise.resolve(result([row(30)]));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
await renderOverview();
|
|
321
|
+
|
|
322
|
+
expect(screen.getByText("$4.0000")).toBeInTheDocument();
|
|
323
|
+
expect(screen.queryByText("$13.0000")).not.toBeInTheDocument();
|
|
324
|
+
|
|
325
|
+
// The metric stream is not even consulted while spans have something.
|
|
326
|
+
const metricCalls: Array<unknown> = aggregateMock.mock.calls.filter(
|
|
327
|
+
(args: Array<unknown>): boolean => {
|
|
328
|
+
return modelNameOf(args[0] as AggregateCall) === "Metric";
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(metricCalls).toHaveLength(0);
|
|
333
|
+
expect(screen.queryAllByText("from GenAI metrics")).toHaveLength(0);
|
|
334
|
+
});
|
|
335
|
+
});
|